前言

上一篇文章直接经过安卓自定义view的常识手撕了一个侧滑栏,做的还不错,很有成就感。这篇文章的控件没有上一篇的杂乱,比较简略,经过一个内容翻滚形成header折叠的控件学习一下滑动事情抵触问题、更改view节点以及CoordinatorLayout事情传递(超低仿),基本都是一个引子,期望学完这个控件,要继续省掉学习下触及的内容。

需求

这儿便是期望做一个翻滚经过内容能够折叠header的控件,在XML内写的控件能够有翻滚作用,header暂时默许实现。 核心思想:

  • 1、两部分,一个header和一个能够翻滚的区域
  • 2、header有两种状况,一个是完全打开状况,一个是折叠状况
  • 3、在翻滚区域向下翻滚的时分,header会先翻滚到折叠状况,header折叠后翻滚区域才开端翻滚
  • 4、在翻滚区域向上翻滚的时分,翻滚区域先翻滚,翻滚区域到顶了才开端打开header
  • 5、低仿CoordinatorLayout,翻滚区域作用经过自定义layoutParas向header传递

作用图

自定义view实战(6):滑动折叠Header的控件(滑动冲突)

编写代码

import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.util.AttributeSet
import android.util.Log
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.forEach
import androidx.core.widget.NestedScrollView
import kotlin.math.abs
/**
 * 内容翻滚形成header折叠的控件
 */
class ScrollingCollapseTopLayout @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null,
    defStyleAttr: Int = 0
): ViewGroup(context, attributeSet, defStyleAttr) {
    //外部滑动间隔
    private var mScrollHeight = 0f
    //上次纵坐标
    private var mLastY = 0f
    //当时控件宽高
    private var mHeight = 0
    private var mWidth = 0
    //两个部分
    private val header: Header = Header(context).apply {
        //设置header笔直方向,宽度铺满,高度自适应
        orientation = LinearLayout.VERTICAL
        layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
    }
    //NestedScrollView只允许一个子view(和ScrollView相同),这儿放一个笔直的LinearLayout
    private val scrollArea: NestedScrollView = NestedScrollView(context).apply {
        layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
        addView(LinearLayout(context).apply {
            setBackgroundColor(Color.LTGRAY)
            orientation = LinearLayout.VERTICAL
            layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
        })
    }
    //XML里边的view
    private val xmlViews: ArrayList<View> = ArrayList()
    //获取XML内view完毕,没履行onMeasure
    override fun onFinishInflate() {
        super.onFinishInflate()
        //在这儿取得一切子view,阻拦增加到scrollArea去
        if (xmlViews.size == 0) {
            forEach { view ->
                xmlViews.add(view)
            }
        }
        //替换view的节点
        removeAllViewsInLayout()
        addView(header)
        addView(scrollArea)
        //把当时控件悉数view放到NestedScrollView内的LinearLayout内去
        (scrollArea.getChildAt(0) as ViewGroup).also { linear->
            for(view in xmlViews) {
                linear.addView(view)
            }
        }
    }
    //在onSizeChanged才干取得正确的宽高,会在onMeasure后得到,这儿仅仅学一下
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        mHeight = h
        mWidth = w
    }
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        //丈量header
        header.onScroll(mScrollHeight.toInt())
        header.measure(widthMeasureSpec,
            MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec),MeasureSpec.AT_MOST))
        //先measure一下取得实际高度,再减去滑动的间隔,也能够把header.measuredHeight写成全局变量
        if (header.measuredHeight != 0) {
            val scrolledHeight = header.measuredHeight + mScrollHeight
            val headerHeightMeasureSpec = MeasureSpec.makeMeasureSpec(scrolledHeight.toInt(),
                MeasureSpec.getMode(MeasureSpec.EXACTLY))
            //再次丈量的意图是后边翻滚部分要占满剩下高度
            header.measure(widthMeasureSpec, headerHeightMeasureSpec)
        }
        //丈量滑动区域
        val leftHeight = MeasureSpec.getSize(heightMeasureSpec) - header.measuredHeight
        scrollArea.measure(widthMeasureSpec,
            MeasureSpec.makeMeasureSpec(leftHeight, MeasureSpec.EXACTLY))
        Log.e("TAG", "onMeasure: leftHeight=$leftHeight")
        Log.e("TAG", "onMeasure: scrollArea.height=${scrollArea.height}")
        Log.e("TAG", "onMeasure: scrollArea.measuredHeight=${scrollArea.measuredHeight}")
        //直接占满宽高
        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),
            MeasureSpec.getSize(heightMeasureSpec))
    }
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        //简略布局下,上下两部分
        header.layout(l, t, r, t + header.measuredHeight)
        scrollArea.layout(l, t + header.measuredHeight, r,b)
    }
    //事情抵触运用外部阻拦
    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        var isIntercepted = false
        ev?.let {
            when(ev.action) {
                //不阻拦down事情
                MotionEvent.ACTION_DOWN -> mLastY = ev.y
                MotionEvent.ACTION_MOVE -> {
                    val dY = ev.y - mLastY
                    //假如折叠了,优先翻滚折叠栏
                    val canScrollTop = scrollArea.canScrollVertically(-1)
                    val canScrollBottom = scrollArea.canScrollVertically(1)
                    //能够翻滚
                    isIntercepted = if (canScrollTop || canScrollBottom) {
                        //手指向上移动时,没折叠前要阻拦
                        val scrollUp = dY < 0 &&
                                mScrollHeight + dY > -header.collapsingArea.height.toFloat()
                        //手指向下移动时,没打开前且到顶了要阻拦
                        val scrollDown = dY > 0 &&
                                mScrollHeight + dY < 0f &&
                                !canScrollTop
                        scrollUp || scrollDown
                    }else {
                        //不能翻滚
                        true
                    }
                }
                //不阻拦up事情
                //MotionEvent.ACTION_UP ->
            }
        }
        return isIntercepted
    }
    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(ev: MotionEvent?): Boolean {
        ev?.let {
            when(ev.action) {
                //MotionEvent.ACTION_DOWN ->
                MotionEvent.ACTION_MOVE -> {
                    //累加滑动值,请求重新布局
                    val dY = ev.y - mLastY
                    if (mScrollHeight + dY <= 0 &&
                        mScrollHeight + dY >= -header.collapsingArea.height) {
                            mScrollHeight += dY
                            requestLayout()
                    }
                    mLastY = ev.y
                }
                //MotionEvent.ACTION_UP ->
            }
        }
        return super.onTouchEvent(ev)
    }
    //这儿就做一个简略的折叠header,
    @Suppress("MemberVisibilityCanBePrivate")
    inner class Header @JvmOverloads constructor(
        context: Context,
        attributeSet: AttributeSet? = null,
        defStyleAttr: Int = 0,
    ): LinearLayout(context, attributeSet, defStyleAttr){
        //两个区域
        val defaultArea: TextView
        val collapsingArea: TextView
        init {
            //增加两个header区域
            defaultArea = makeTextView(context, "Default area", 80)
            collapsingArea = makeTextView(context, "Collapsing area", 300)
            addView(defaultArea)
            addView(collapsingArea)
        }
        //低配Behavior.onNestedPreScroll,这儿就处理下ScrollingHideTopLayout传过来的间隔
        @SuppressLint("SetTextI18n")
        fun onScroll(scrollHeight: Int) {
            val expandHeight = collapsingArea.height + scrollHeight
            //这儿就改一下背景色的透明度吧
            if (abs(expandHeight) <= collapsingArea.height) {
                val alpha = expandHeight.toFloat() / collapsingArea.height * 255
                defaultArea.text = "Default area:${alpha.toInt()}"
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    collapsingArea.setBackgroundColor(Color.argb(alpha.toInt(),88,88,88))
                }
            }
        }
        //创立TextView
        private fun makeTextView(context: Context, textStr: String, height: Int): TextView {
            //简略点height和textSize应该用dp和sp的,前面文章有
            return TextView(context).apply {
                layoutParams =
                    ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height)
                text = textStr
                gravity = Gravity.CENTER
                textSize = 13f
                setBackgroundColor(Color.GRAY)
            }
        }
    }
}

首要问题

NestedScrollView的运用

要想中间内容能够翻滚,并且和当时控件形成滑动抵触,就只能引入新的滑动控件了,这儿运用了NestedScrollView,和ScrollView类似。NestedScrollView只允许有一个子view,至于为什么能够看下源码,内容不多。我这是直接创立了一个NestedScrollView,并往里边加个一个笔直的LinearLayout,后边更改xml里边的view节点,往LinearLayout里边放。

修正xml内view的节点

上一篇文章里边,侧滑栏在xml里边的位置会影响制作的层级,我是在onLayout里边经过移除再增加的办法做的,那假如要把view改到其他view里边去该怎么办。一开端我觉得很简略嘛,直接在onMeasure里边得到一切xml里边的view,再增加到其他viewgroup里边不就行了!主意很简略,试一下成果出我问题了。

第一个问题是view增加到其他viewgroup必须先移除,那我就直接就removeViewInLayout,成果就出了第二个问题OverStackError,大致便是一直measure,试了下是addView导致的,逻辑仍是有问题。后边想想不该该在onMeasure里边实现的,应该在viewgroup加载xml里边子view时阻拦处理的。

于是找了下api,发现viewgroup提供了一个onFinishInflate办法,会在加载xml里边view完结时调用,关键是它只会调用一次,onMeasure会调用多次,正好契合了我们的需求。修正节点就简略了,for循环一下就ok。

onSizeChanged函数

上面用到了onFinishInflate办法,找材料的时分看到自定义view里边常用重写的办法还有一个onSizeChanged函数。其有用的也多,首要是自定义view时用来获取控件宽高的,当控件的Size发生改变,如measure完毕,onSizeChanged被调用,这时分才干拿到宽高,不然拿到的height和width便是0。

滑动事情抵触处理

我觉得滑动事情抵触的处理都应该依据实际情况去处理,常识的话能够去看看《安卓开发艺术探讨》里边的相关常识,首要解决办法便是内部阻拦法和外部阻拦法。我这便是简略的外部阻拦法,原本想写杂乱点,看看能不能多学点东西,成果依据需求,最后的代码很简略。

外部阻拦法原理便是在onInterceptTouchEvent办法中,经过依据场景判别是内部翻滚仍是外部翻滚,外部翻滚就直接阻拦,内部是否能翻滚能够经过canScrollVertically/canScrollHorizontally办法判别。我这逻辑很简略,首先判别下内部是否能翻滚,内部不能翻滚就直接交给外部处理;然后又分两种情况,一个是手指向上移动时,没折叠前要阻拦,另一个便是手指向下移动时,没打开前且到内部顶了要阻拦。无论真么处理,仍是得依据情形,

仿照CoordinatorLayout

原本还想仿照CoordinatorLayout做一个滑动状况传递的,这儿翻滚控件用的NestedScrollingChild,想让当时控件继承NestedScrollingParent处理滑动抵触,后边觉得仍是简略点自己在onInterceptTouchEvent办法中处理能学点东西。当然读者有兴趣可借机学习一下NestedScrollingChild和NestedScrollingParent。

关于CoordinatorLayout,我也是学习了一下其间原理,私认为大致便是CoordinatorLayout的LayoutParams内有一个Behavior特点,Behavior作用便是构建两个子控件的关联关系(在CoordinatorLayout的onMeasure中),建立关联关系后,当一个view改变就会形成关联的view跟着改变(CoordinatorLayout操控),当然原理没这么简略,仍是要去看源码。

原本我也想按这个逻辑仿照一下的,首先便是给当时控件的LayoutParams加一个Behavior特点,当翻滚控件设置这个Behavior特点时,Header类在measure的时分就创立一个Behavior特点的私有变量,当时控件经过NestedScrollingChild接受翻滚事情,并交给Header类的Behavior特点的私有变量去处理,一套逻辑下来,总感觉有脱裤子放屁的感觉,毕竟我这个控件就两个子控件。CoordinatorLayout的意图是和谐多 View 之间的联动,重点在多,我这真没必要。

其实说到底,CoordinatorLayout便是一个和谐功用,关联两个控件,比方我这便是翻滚控件发出翻滚音讯,当时控件收到翻滚音讯,传递到Header里边处理,就这么简略,多了倒是能够按上面逻辑处理。

header折叠作用

这儿的header的折叠作用是从onMeasure里边得到的!在丈量时,依据滑动值,修正header的heightMeasureSpec,把header的高度设置为原有高度减去滑动高度,丈量完header之后,把剩下的高度给到滑动区域,onLayout的时分将两个控件挨着就行。滑动的时分,请求重新layout,header和翻滚区域每次都会取得不相同的高度,看起来就有了折叠作用。