前语

上一篇做了一个滑动折叠的Header控件,首要便是操练了一下滑动事件抵触的问题,控件和文章写的都不怎样样。本来想通过这篇文章的控件,整合一下前面六篇文章的内容的,成果写的太杂乱了,就算了,没有新的技术知识,功用也和之前的安卓广东选择控件类似,不过在写的进程仍是有点难度的,用来了解自定义view知识仍是很不错的。

需求

这儿我也不知道应该怎样描绘这个控件,标题里用的巨细自动变换的类ViewPager,一开端我把它叫做模仿桌面切换的多页面切换控件。大致便是和电视那种切换页面时,中心页面大,边上页面小,切换到中心会有变大的动画效果,我是觉得这样的控件和炫酷。

核心思想如下:

  • 1、类似viewpager,但同时显现两种页面,中心为主页面,左右为小页面,小页面巨细相同,间隔摆放
  • 2、左右滑动能够将切换页面,超过页面数量巨细不能滑动,滑动中止主界面能自动移动到目标方位

效果图

自定义view实战(7):大小自动变换的类ViewPager

编写代码

这儿代码写的仍是挺简单的,没有用到ViewPager那样的Adapter,也没有处理预加载问题,滑动起来不是特别流畅,页面放置到顶层时切换很突兀,可是仍是达到了一开端的设计要求吧!

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
        // 状况
        const val SCROLL_STATE_IDLE = 0
        const val SCROLL_STATE_DRAGGING = 1
        const val SCROLL_STATE_SETTLING = 2
        // 默许padding值
        const val DEFAULT_PADDING_VALUE = 50
        // 竖向默许主界面份额
        const val DEFAULT_MAIN_PERCENT_VERTICAL = 0.8f
        // 横向默许主界面份额
        const val DEFAULT_MAIN_PERCENT_HORIZONTAL = 0.6f
        // 其他页面相对主界面页面最小的缩小份额
        const val DEFAULT_OTHER_VIEW_SCAN_SIZE = 0.5f
    }
    /**
     * 当时主页面的index
     */
    @Suppress("MemberVisibilityCanBePrivate")
    var curIndex = 0
    // 由于将view进步层级会搞乱次序,需求记载原始方位信息
    private var mInitViews = ArrayList<View>()
    // view之间的间隔
    private var mGateLength = 0
    // 滑动间隔
    private var mDxLen = 0f
    // 体系最小移动间隔
    private val mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
    // 控件状况
    private var mState = SCROLL_STATE_IDLE
    // 当时设置的特点动画
    private var mValueAnimator: ValueAnimator? = null
    // 实践布局的左右坐标值
    private var mRealLeft = 0
    private var mRealRight = 0
    // 上一次按下的横竖坐标
    private var mLastX = 0f
    // 方向,从XML内取得
    private var mOrientation: Int
    // 是否对屏幕方向自适应,从XML内取得
    private val isAutoFitOrientation: Boolean
    // padding,从XML内取得,如果左右移动,则上下要有padding,但左右没有padding
    private val mPaddingValue: Int
    // 竖向主内容份额,从XML内取得,剩下两头平分
    private val mMainPercentVertical: Float
    // 横向主内容份额,从XML内取得,剩下两头平分
    private val mMainPercentHorizontal: Float
    // 其他页面相对主界面页面最小的缩小份额
    private val mOtherViewScanMinSize: Float
    init {
        // 获取XML参数
        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内的view,保存原始次序
        mInitViews.addAll(children)
    }
    // 屏幕方向改变并不会触发,初始时会触发,自适应
    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")
        // 依据屏幕改变修正方向,自适应
        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()
        }
    }
    // 摆放规则:初始化第一个放中心,其他向右摆放,中心最大,中心在左右边上的最小,不行见的也是最小
    // view的巨细应该只和它在可见页面的方位有关,不应该和curIndex有关,是充分不必要联系
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        // 获取默许尺度,考虑背景巨细
        val width = max(getDefaultSize(0, widthMeasureSpec), suggestedMinimumWidth)
        val height = max(getDefaultSize(0, heightMeasureSpec), suggestedMinimumHeight)
        // 设置间隔
        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
        }
        // 两边 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
        // 开端时当时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) {
            // 依据二次函数核算份额
            scanSize = scanByParabola(scrollLeftLimit, scrollRightLimit, scrolledLen).toFloat()
        }
        return scanSize
    }
    // 依据抛物线核算份额,y归于[0, 1]
    // 映射联系:(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无关
    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]
            // 中心减去间隔,再减去一半的宽度,得到左面坐标
            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])
            }
        }
    }
    // 依据翻滚间隔取得当时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
                        // 去除完毕监听,完毕动画
                        mValueAnimator?.removeAllListeners()
                        mValueAnimator?.cancel()
                    }
                }
                MotionEvent.ACTION_MOVE -> {
                    // 若ACTION_DOWN是本view阻拦,则下面代码不会触发,要在onTouchEvent判别
                    val dX = mLastX - ev.x
                    return checkScrollInView(scrollX + dX)
                }
                MotionEvent.ACTION_UP -> {}
            }
        }
        return super.onInterceptHoverEvent(ev)
    }
    // 依据能够翻滚的规模,核算是否能够翻滚
    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及layout,再onLayout的最终完成移动
        mDxLen += dX
        if(abs(mDxLen) >= mTouchSlop) {
            requestLayout()
        }
        // 更新值
        mLastX = ev.x
    }
    private fun moveUp() {
        // 赋值
        val targetScrollLen = getCurrentIndex() * mGateLength
        // 不能运用scroller,无法在移动的时分进行测量
        // 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()
        }
        // 在动画完毕时修正curIndex
        animator.addListener (onEnd = {
            curIndex = getCurrentIndex()
            mState = SCROLL_STATE_IDLE
        })
        // 设置状况
        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>

首要问题

这儿用到的知识之前六篇文章都现已讲过了,首要便是有几点完成起来杂乱了一些,下面讲讲。

页面的自动缩放

讲解页面的缩放之前,需求先将一下页面的摆放。这儿以四分之一为间隔来摆放来自XML的view,第一个view放在中心,其他都在其右边按次序摆放。

所以页面的缩放,只和view的方位有关,而view的方位又只和当时控件左右滑动的间隔有关,变量便是当时控件横坐标上的滑动值scrollX。依据view的原始index能够得到每个view可见时的滑动值规模,在通过这个规模和实践的滑动值scrollX,进行映射换算得到其缩放份额。这儿用到了抛物线进行换算:

// 公式:y = 1 - (x - 1).toDouble().pow(2.0)
// 映射联系:(form, 0) ((from + to) / 2, 0) (to, 0) -> (0, 0) (1, 1) (2, 0)
滑动规模的限制

滑动规模的限制和上面类似,鸿沟便是第一个或者最终一个view移动到正中心的规模,只需实践的滑动值scrollX在这个规模内,那滑动便是有用的。

页面层级提高与康复

页面层级的提高在我之前文章:手撕安卓侧滑栏也有用到,便是自己把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不止一个了,并且后面还要康复,即不能打乱children的次序。所以我在onFinishInflate顶用一个数组保存下这些子view的原始次序,运用的时分用这个数组就行,children里边的次序不必管,只需让需求显现的view放在最终就行。我这儿因为间隔是四分之一的宽度,最多能够显现五个view,所以在onLayout的最终将这五个view得到,并按次序放到children的最终。

onDraw讨论

这儿我还想对onDraw讨论一下,一开端我认为已然onMeasure、onLayout中都需求去调用child的measure和layout,那能不能在onDraw里边自己去制作child,不必自带的,成果发现这是不行的。onDraw实践是View里边的一个空办法,实践对页面的制作是在控件的draw办法中,那重写draw办法自己去制作child呢?实践也不行,当把draw办法里边的super.draw时提示报错:

自定义view实战(7):大小自动变换的类ViewPager

也便是说有必要承继super.draw这个办法,点开源码发现,super.draw现已把child制作了,并且onDraw办法也是从里边传出来的。所以没办法,乖乖用bringChildToFront放到children最终去,来提高层级吧,否则也不会供给这一个办法来是不是?