Android實現(xiàn)自動變換大小的ViewPager
前言
上一篇做了一個滑動折疊的Header控件,主要就是練習(xí)了一下滑動事件沖突的問題,控件和文章寫的都不怎么樣。本來想通過這篇文章的控件,整合一下前面六篇文章的內(nèi)容的,結(jié)果寫的太復(fù)雜了,就算了,沒有新的技術(shù)知識,功能也和之前的安卓廣東選擇控件類似,不過在寫的過程還是有點難度的,用來熟悉自定義view知識還是很不錯的。
需求
這里我也不知道應(yīng)該怎么描述這個控件,標(biāo)題里用的大小自動變換的類ViewPager,一開始我把它叫做模仿桌面切換的多頁面切換控件。大致就是和電視那種切換頁面時,中間頁面大,邊上頁面小,切換到中間會有變大的動畫效果,我是覺得這樣的控件和炫酷。
核心思想如下:
1、類似viewpager,但同時顯示兩種頁面,中間為主頁面,左右為小頁面,小頁面大小一樣,間距排列
2、左右滑動可以將切換頁面,超過頁面數(shù)量大小不能滑動,滑動停止主界面能自動移動到目標(biāo)位置
效果圖
編寫代碼
這里代碼寫的還是挺簡單的,沒有用到ViewPager那樣的Adapter,也沒有處理預(yù)加載問題,滑動起來不是特別流暢,頁面放置到頂層時切換很突兀,但是還是達(dá)到了一開始的設(shè)計要求吧!
import android.animation.ValueAnimator import android.annotation.SuppressLint import android.content.Context import android.content.res.Configuration import android.util.AttributeSet import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup import androidx.core.animation.addListener import androidx.core.view.children import com.silencefly96.module_common.R import java.util.* import kotlin.math.abs import kotlin.math.max import kotlin.math.pow import kotlin.math.roundToInt /** * @author silence * @date 2022-10-20 */ class DesktopLayerLayout @JvmOverloads constructor( context: Context, attributeSet: AttributeSet? = null, defStyleAttr: Int = 0 ) : ViewGroup(context, attributeSet, defStyleAttr) { companion object{ // 方向 const val ORIENTATION_VERTICAL = 0 const val ORIENTATION_HORIZONTAL = 1 // 狀態(tài) const val SCROLL_STATE_IDLE = 0 const val SCROLL_STATE_DRAGGING = 1 const val SCROLL_STATE_SETTLING = 2 // 默認(rèn)padding值 const val DEFAULT_PADDING_VALUE = 50 // 豎向默認(rèn)主界面比例 const val DEFAULT_MAIN_PERCENT_VERTICAL = 0.8f // 橫向默認(rèn)主界面比例 const val DEFAULT_MAIN_PERCENT_HORIZONTAL = 0.6f // 其他頁面相對主界面頁面最小的縮小比例 const val DEFAULT_OTHER_VIEW_SCAN_SIZE = 0.5f } /** * 當(dāng)前主頁面的index */ @Suppress("MemberVisibilityCanBePrivate") var curIndex = 0 // 由于將view提高層級會搞亂順序,需要記錄原始位置信息 private var mInitViews = ArrayList<View>() // view之間的間距 private var mGateLength = 0 // 滑動距離 private var mDxLen = 0f // 系統(tǒng)最小移動距離 private val mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop // 控件狀態(tài) private var mState = SCROLL_STATE_IDLE // 當(dāng)前設(shè)置的屬性動畫 private var mValueAnimator: ValueAnimator? = null // 實際布局的左右坐標(biāo)值 private var mRealLeft = 0 private var mRealRight = 0 // 上一次按下的橫豎坐標(biāo) private var mLastX = 0f // 方向,從XML內(nèi)獲得 private var mOrientation: Int // 是否對屏幕方向自適應(yīng),從XML內(nèi)獲得 private val isAutoFitOrientation: Boolean // padding,從XML內(nèi)獲得,如果左右移動,則上下要有padding,但左右沒有padding private val mPaddingValue: Int // 豎向主內(nèi)容比例,從XML內(nèi)獲得,剩余兩邊平分 private val mMainPercentVertical: Float // 橫向主內(nèi)容比例,從XML內(nèi)獲得,剩余兩邊平分 private val mMainPercentHorizontal: Float // 其他頁面相對主界面頁面最小的縮小比例 private val mOtherViewScanMinSize: Float init { // 獲取XML參數(shù) val typedArray = context.obtainStyledAttributes(attributeSet, R.styleable.DesktopLayerLayout) mOrientation = typedArray.getInteger(R.styleable.DesktopLayerLayout_mOrientation, ORIENTATION_VERTICAL) isAutoFitOrientation = typedArray.getBoolean(R.styleable.DesktopLayerLayout_isAutoFitOrientation, true) mPaddingValue = typedArray.getInteger(R.styleable.DesktopLayerLayout_mPaddingValue, DEFAULT_PADDING_VALUE) mMainPercentVertical = typedArray.getFraction(R.styleable.DesktopLayerLayout_mMainPercentVertical, 1, 1, DEFAULT_MAIN_PERCENT_VERTICAL) mMainPercentHorizontal = typedArray.getFraction(R.styleable.DesktopLayerLayout_mMainPercentHorizontal, 1, 1, DEFAULT_MAIN_PERCENT_HORIZONTAL) mOtherViewScanMinSize = typedArray.getFraction(R.styleable.DesktopLayerLayout_mOtherViewScanMinSize, 1, 1, DEFAULT_OTHER_VIEW_SCAN_SIZE) typedArray.recycle() } override fun onFinishInflate() { super.onFinishInflate() // 獲得所有xml內(nèi)的view,保留原始順序 mInitViews.addAll(children) } // 屏幕方向變化并不會觸發(fā),初始時會觸發(fā),自適應(yīng) override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) // Log.e("TAG", "onSizeChanged: w=$w, h=$h") // 根據(jù)屏幕變化修改方向,自適應(yīng) if (isAutoFitOrientation) { mOrientation = if (w > h) ORIENTATION_HORIZONTAL else ORIENTATION_VERTICAL requestLayout() } } // 需要在manifest中注冊捕捉事件類型,android:configChanges="orientation|keyboardHidden|screenSize" public override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) if(newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { mOrientation = ORIENTATION_VERTICAL requestLayout() }else if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) { mOrientation = ORIENTATION_HORIZONTAL requestLayout() } } // 排列規(guī)則:初始化第一個放中間,其他向右排列,中間最大,中心在左右邊上的最小,不可見的也是最小 // view的大小應(yīng)該只和它在可見頁面的位置有關(guān),不應(yīng)該和curIndex有關(guān),是充分不必要關(guān)系 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { // super.onMeasure(widthMeasureSpec, heightMeasureSpec) // 獲取默認(rèn)尺寸,考慮背景大小 val width = max(getDefaultSize(0, widthMeasureSpec), suggestedMinimumWidth) val height = max(getDefaultSize(0, heightMeasureSpec), suggestedMinimumHeight) // 設(shè)置間距 mGateLength = width / 4 // 中間 view 大小 val maxWidth: Int val maxHeight: Int // 不同方向尺寸不同 if (mOrientation == ORIENTATION_HORIZONTAL) { maxWidth = (width * mMainPercentHorizontal).toInt() maxHeight = height - 2 * mPaddingValue }else { maxWidth = (width * mMainPercentVertical).toInt() maxHeight = height - 2 * mPaddingValue } // 兩側(cè) view 大小,第三排 val minWidth = (maxWidth * mOtherViewScanMinSize).toInt() val minHeight = (maxHeight * mOtherViewScanMinSize).toInt() var childWidth: Int var childHeight: Int for (i in 0 until childCount) { val child = mInitViews[i] val scanSize = getViewScanSize(i, scrollX) childWidth = minWidth + ((maxWidth - minWidth) * scanSize).toInt() childHeight = minHeight + ((maxHeight - minHeight) * scanSize).toInt() // Log.e("TAG", "onMeasure($i): childWidth=$childWidth, childHeight=$childHeight") child.measure(MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY)) } setMeasuredDimension(width, height) } // 選中view為最大,可見部分會縮放,不可見部分和第三排一樣大 private fun getViewScanSize(index: Int, scrolledLen: Int): Float { var scanSize = 0f // 開始時當(dāng)前view未測量,不計算 if (measuredWidth == 0) return scanSize // 初始化的時候,第一個放中間,所以index移到可見范圍為[2+index, index-2],可見!=可移動 val scrollLeftLimit = (index - 2) * mGateLength val scrollRightLimit = (index + 2) * mGateLength // 先判斷child是否可見 if (scrolledLen in scrollLeftLimit..scrollRightLimit) { // 根據(jù)二次函數(shù)計算比例 scanSize = scanByParabola(scrollLeftLimit, scrollRightLimit, scrolledLen).toFloat() } return scanSize } // 根據(jù)拋物線計算比例,y屬于[0, 1] // 映射關(guān)系:(form, 0) ((from + to) / 2, 0) (to, 0) -> (0, 0) (1, 1) (2, 0) @Suppress("SameParameterValue") private fun scanByParabola(from: Int, to: Int, cur: Int): Double { // 公式:val y = 1 - (x - 1).toDouble().pow(2.0) // Log.e("TAG", "scanByParabola:from=$from, to=$to, cur=$cur ") val x = ((cur - from) / (to - from).toFloat() * 2).toDouble() return 1 - (x - 1).pow(2.0) } // layout 按順序間距排列即可,大小有onMeasure控制,開始位置在中心,也和curIndex無關(guān) override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { val startX = (r + l) / 2 // 排列布局 for (i in 0 until childCount) { val child = mInitViews[i] // 中間減去間距,再減去一半的寬度,得到左邊坐標(biāo) val left = startX + mGateLength * i - child.measuredWidth / 2 val top = (b + t) / 2 - child.measuredHeight / 2 val right = left + child.measuredWidth val bottom = top + child.measuredHeight // Log.e("TAG", "onLayout($i): left=$left, right=$right") child.layout(left, top, right, bottom) } // 修改大小,布局完成后移動 scrollBy(mDxLen.toInt(), 0) mDxLen = 0f // 完成布局及移動后,繪制之前,將可見view提高層級 val targetIndex = getCurrentIndex() for (i in 2 downTo 0) { val preIndex = targetIndex - i val aftIndex = targetIndex + i // 逐次提高層級,注意在mInitViews拿就可以,不可見不管 if (preIndex in 0..childCount) { bringChildToFront(mInitViews[preIndex]) } if (aftIndex != preIndex && aftIndex in 0 until childCount) { bringChildToFront(mInitViews[aftIndex]) } } } // 根據(jù)滾動距離獲得當(dāng)前index private fun getCurrentIndex()= (scrollX / mGateLength.toFloat()).roundToInt() override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { ev?.let { when(it.action) { MotionEvent.ACTION_DOWN -> { mLastX = ev.x if(mState == SCROLL_STATE_IDLE) { mState = SCROLL_STATE_DRAGGING }else if (mState == SCROLL_STATE_SETTLING) { mState = SCROLL_STATE_DRAGGING // 去除結(jié)束監(jiān)聽,結(jié)束動畫 mValueAnimator?.removeAllListeners() mValueAnimator?.cancel() } } MotionEvent.ACTION_MOVE -> { // 若ACTION_DOWN是本view攔截,則下面代碼不會觸發(fā),要在onTouchEvent判斷 val dX = mLastX - ev.x return checkScrollInView(scrollX + dX) } MotionEvent.ACTION_UP -> {} } } return super.onInterceptHoverEvent(ev) } // 根據(jù)可以滾動的范圍,計算是否可以滾動 private fun checkScrollInView(length : Float): Boolean { // 一層情況 if (childCount <= 1) return false // 左右兩邊最大移動值,即把最后一個移到中間 val leftScrollLimit = 0 val rightScrollLimit = (childCount - 1) * mGateLength return (length >= leftScrollLimit && length <= rightScrollLimit) } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(ev: MotionEvent?): Boolean { ev?.let { when(it.action) { // 防止點擊空白位置或者子view未處理touch事件 MotionEvent.ACTION_DOWN -> return true MotionEvent.ACTION_MOVE -> { // 如果是本view攔截的ACTION_DOWN,要在此判斷 val dX = mLastX - ev.x if(checkScrollInView(scrollX + dX)) { move(ev) } } MotionEvent.ACTION_UP -> moveUp() } } return super.onTouchEvent(ev) } private fun move(ev: MotionEvent) { val dX = mLastX - ev.x // 修改mScrollLength,重新measure及l(fā)ayout,再onLayout的最后實現(xiàn)移動 mDxLen += dX if(abs(mDxLen) >= mTouchSlop) { requestLayout() } // 更新值 mLastX = ev.x } private fun moveUp() { // 賦值 val targetScrollLen = getCurrentIndex() * mGateLength // 不能使用scroller,無法在移動的時候進(jìn)行測量 // mScroller.startScroll(scrollX, scrollY, (targetScrollLen - scrollX), 0) // 這里使用ValueAnimator處理剩余的距離,模擬滑動到需要的位置 val animator = ValueAnimator.ofFloat(scrollX.toFloat(), targetScrollLen.toFloat()) animator.addUpdateListener { animation -> // Log.e("TAG", "stopMove: " + animation.animatedValue as Float) mDxLen = animation.animatedValue as Float - scrollX requestLayout() } // 在動畫結(jié)束時修改curIndex animator.addListener (onEnd = { curIndex = getCurrentIndex() mState = SCROLL_STATE_IDLE }) // 設(shè)置狀態(tài) mState = SCROLL_STATE_SETTLING animator.duration = 300L animator.start() } }
desktop_layer_layout_style.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name ="DesktopLayerLayout"> <attr name="mOrientation"> <enum name ="vertical" value="0" /> <enum name ="horizontal" value="1" /> </attr> <attr name="isAutoFitOrientation" format="boolean"/> <attr name="mPaddingValue" format="integer"/> <attr name="mMainPercentVertical" format="fraction"/> <attr name="mMainPercentHorizontal" format="fraction"/> <attr name="mOtherViewScanMinSize" format="fraction"/> </declare-styleable> </resources>
主要問題
這里用到的知識之前六篇文章都已經(jīng)講過了,主要就是有幾點實現(xiàn)起來復(fù)雜了一些,下面講講。
頁面的自動縮放
講解頁面的縮放之前,需要先將一下頁面的擺放。這里以四分之一為間距來擺放來自XML的view,第一個view放在中間,其他都在其右邊按順序排列。
所以頁面的縮放,只和view的位置有關(guān),而view的位置又只和當(dāng)前控件左右滑動的距離有關(guān),變量就是當(dāng)前控件橫坐標(biāo)上的滑動值scrollX。根據(jù)view的原始index可以得到每個view可見時的滑動值范圍,在通過這個范圍和實際的滑動值scrollX,進(jìn)行映射換算得到其縮放比例。這里用到了拋物線進(jìn)行換算:
// 公式:y = 1 - (x - 1).toDouble().pow(2.0)
// 映射關(guān)系:(form, 0) ((from + to) / 2, 0) (to, 0) -> (0, 0) (1, 1) (2, 0)
滑動范圍的限定
滑動范圍的限定和上面類似,邊界就是第一個或者最后一個view移動到正中間的范圍,只要實際的滑動值scrollX在這個范圍內(nèi),那滑動就是有效的。
頁面層級提升與恢復(fù)
頁面層級的提升在我之前文章:手撕安卓側(cè)滑欄也有用到,就是自己把view放到children的最后去,實際上ViewGroup提供了類似的功能:bringChildToFront,但是原理是一樣的。
@Override public void bringChildToFront(View child) { final int index = indexOfChild(child); if (index >= 0) { removeFromArray(index); addInArray(child, mChildrenCount); child.mParent = this; requestLayout(); invalidate(); } }
這里的提升view不止一個了,而且后面還要恢復(fù),即不能打亂children的順序。所以我在onFinishInflate中用一個數(shù)組保存下這些子view的原始順序,使用的時候用這個數(shù)組就行,children里面的順序不用管,只要讓需要顯示的view放在最后就行。我這里因為間距是四分之一的寬度,最多可以顯示五個view,所以在onLayout的最后將這五個view得到,并按順序放到children的最后。
onDraw探討
這里我還想對onDraw探討一下,一開始我以為既然onMeasure、onLayout中都需要去調(diào)用child的measure和layout,那能不能在onDraw里面自己去繪制child,不用自帶的,結(jié)果發(fā)現(xiàn)這是不行的。onDraw實際是View里面的一個空方法,實際對頁面的繪制是在控件的draw方法中,那重寫draw方法自己去繪制child呢?實際也不行,當(dāng)把draw方法里面的super.draw時提示報錯:
也就是說必須繼承super.draw這個方法,點開源碼發(fā)現(xiàn),super.draw已經(jīng)把child繪制了,而且onDraw方法也是從里面?zhèn)鞒鰜淼?。所以沒辦法,乖乖用bringChildToFront放到children最后去,來提升層級吧,不然也不會提供這一個方法來是不是?
到此這篇關(guān)于Android實現(xiàn)自動變換大小的ViewPager的文章就介紹到這了,更多相關(guān)Android ViewPager內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
android模擬器開發(fā)和測試nfc應(yīng)用實例詳解
本文介紹android模擬器開發(fā)nfc應(yīng)用詳解,大家參考使用吧2013-12-12android中DatePicker和TimePicker的使用方法詳解
這篇文章主要介紹了android中DatePicker和TimePicker的使用方法,是Android中常用的功能,需要的朋友可以參考下2014-07-07Android?Flutter在點擊事件上添加動畫效果實現(xiàn)全過程
這篇文章主要給大家介紹了關(guān)于Android?Flutter在點擊事件上添加動畫效果實現(xiàn)的相關(guān)資料,通過實例代碼介紹的非常詳細(xì),對大家學(xué)習(xí)Android具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2023-03-03Android BroadcastReceiver常見監(jiān)聽整理
這篇文章主要介紹了Android BroadcastReceiver常見監(jiān)聽整理的相關(guān)資料,需要的朋友可以參考下2016-10-10Android PickerView實現(xiàn)三級聯(lián)動效果
這篇文章主要為大家詳細(xì)介紹了Android PickerView實現(xiàn)三級聯(lián)動效果,PickerView實現(xiàn)全國地址的選擇,具有一定的參考價值,感興趣的小伙伴們可以參考一下2019-01-01Android實現(xiàn)帶列表的地圖POI周邊搜索功能
這篇文章主要為大家詳細(xì)介紹了Android實現(xiàn)帶列表的地圖POI周邊搜索功能,具有一定的參考價值,感興趣的小伙伴們可以參考一下2016-05-05聊聊GridView實現(xiàn)拖拽排序及數(shù)據(jù)交互的問題
這篇文章主要介紹了聊聊GridView實現(xiàn)拖拽排序及數(shù)據(jù)交互的問題,整體實現(xiàn)思路是通過在一個容器里放置兩個dragview,DragView里面進(jìn)行View的動態(tài)交換以及數(shù)據(jù)交換,具體實現(xiàn)代碼跟隨小編一起看看吧2021-11-11