前语

上两篇文章对安卓自定义view的事情分发做了一些应用,可是对于自定义view来讲,并不仅仅是事情分发这么简略,还有一个很重要的内容便是view的制作流程。接下来我这经过带header和footer的Layout,来学习一下ViewGroup的自定义流程,并对其间的MeasureSpec、onMeasure以及onLayout加深了解。

需求

这儿便是一个有header和footer的翻滚控件,能够在XML中当Layout运用,核心思维如下:

  • 1、由header、XML内容、footer三部分组成
  • 2、翻滚中心控件时,上面有内容时header不显现,下面有内容时footer不显现
  • 3、滑动到header和footer最大值时不能滑动,开释的时分需求回弹
  • 4、彻底显现时躲藏footer

编写代码

编写代码这部分还真让我头疼了一会,首要便是MeasureSpec的运用,怎么让控件能够超出给定的高度,怎么取得实际高度和控件高度,真是纸上得来终觉浅,绝知此事要躬行,看书那么多遍,实际叫自己写起来真的费劲,不过终究写完,才真的敢说自己对measure和layout有一定了解了。

老习气,先看代码,再讲问题吧!

import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.util.TypedValue
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.Scroller
import android.widget.TextView
import androidx.core.view.forEach
import kotlin.math.min
/**
 * 有header和footer的翻滚控件
 * 核心思维:
 * 1、由header、container、footer三部分组成
 * 2、翻滚中心控件时,上面有内容时header不显现,下面有内容时footer不显现
 * 3、滑动到header和footer最大值时不能滑动,开释的时分需求回弹
 * 4、彻底显现时躲藏footer
 */
@SuppressLint("SetTextI18n", "ViewConstructor")
class HeaderFooterView @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null,
    defStyleAttr: Int = 0,
    var header: View? = null,
    var footer: View? = null
): ViewGroup(context, attributeSet, defStyleAttr){
    var onReachHeadListener: OnReachHeadListener? = null
    var onReachFootListener: OnReachFootListener? = null
    //前次事情的横坐标
    private var mLastY = 0f
    //总高度
    private var totalHeight = 0
    //是否悉数显现
    private var isAllDisplay = false
    //流畅滑动
    private var mScroller = Scroller(context)
    init {
        //设置默许的Header、Footer,这儿是从构造来的,假如外部设置需求另外处理
        header = header ?: makeTextView(context, "Header")
        footer = footer ?: makeTextView(context, "Footer")
        //添加对应控件
        addView(header, 0)
        //这儿还没有加入XML中的控件
        //Log.e("TAG", "init: childCount=$childCount", )
        addView(footer, 1)
    }
    //创立默许的Header\Footer
    private fun makeTextView(context: Context, textStr: String): TextView {
        return TextView(context).apply {
            layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp2px(context, 30f))
            text = textStr
            gravity = Gravity.CENTER
            textSize = sp2px(context, 13f).toFloat()
            setBackgroundColor(Color.GRAY)
            //不设置isClickable的话,点击该TextView会导致mFirstTouchTarget为null,
            //致使onInterceptTouchEvent不会被调用,只要ACTION_DOWN能被收到,其他事情都没有
            //由于事情序列中ACTION_DOWN没有被耗费(回来true),整个事情序列被丢掉了
            //假如XML内是TextView也会形成相同情况,
            isFocusable = true
            isClickable = true
        }
    }
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        //父容器给当时控件的宽高,默许值尽量设大一点
        val width = getSizeFromMeasureSpec(1080, widthMeasureSpec)
        val height = getSizeFromMeasureSpec(2160, heightMeasureSpec)
        //对子控件进行丈量
        forEach { child ->
            //宽度给定最大值
            val childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.AT_MOST)
            //高度不限制
            val childHeightMeasureSpec
                = MeasureSpec.makeMeasureSpec(height, MeasureSpec.UNSPECIFIED)
            //进行丈量,不丈量的话measuredWidth和measuredHeight会为0
            child.measure(childWidthMeasureSpec, childHeightMeasureSpec)
            //Log.e("TAG", "onMeasure: child.measuredWidth=${child.measuredWidth}")
            //Log.e("TAG", "onLayout: child.measuredHeight=${child.measuredHeight}")
        }
        //设置丈量高度为父容器最大宽高
        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),
            MeasureSpec.getSize(heightMeasureSpec))
    }
    private fun getSizeFromMeasureSpec(defaultSize: Int, measureSpec: Int): Int {
        //获取MeasureSpec内模式和尺度
        val mod = MeasureSpec.getMode(measureSpec)
        val size = MeasureSpec.getSize(measureSpec)
        return when (mod) {
            MeasureSpec.EXACTLY -> size
            MeasureSpec.AT_MOST -> min(defaultSize, size)
            else -> defaultSize //MeasureSpec.UNSPECIFIED
        }
    }
    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        var curHeight = 0
        //Log.e("TAG", "onLayout: childCount=${childCount}")
        forEach { child ->
            //footer最终处理
            if (indexOfChild(child) != 1) {
                //Log.e("TAG", "onLayout: child.measuredHeight=${child.measuredHeight}")
                child.layout(left, top + curHeight, right,
                    top + curHeight + child.measuredHeight)
                curHeight += child.measuredHeight
            }
        }
        //处理footer
        val footer = getChildAt(1)
        //彻底显现内容时不加载footer,header不算入内容
        if (measuredHeight < curHeight - header!!.height) {
            //设置悉数显现flag
            isAllDisplay = false
            footer.layout(left, top + curHeight, right,top + curHeight + footer.measuredHeight)
            curHeight += footer.measuredHeight
        }
        //布局完结,翻滚一段距离,躲藏header
        scrollBy(0, header!!.height)
        //设置总高度
        totalHeight = curHeight
    }
    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        //Log.e("TAG", "onInterceptTouchEvent: ev=$ev")
        ev?.let {
            when(ev.action) {
                MotionEvent.ACTION_DOWN -> mLastY = ev.y
                MotionEvent.ACTION_MOVE -> return true
            }
        }
        return super.onInterceptTouchEvent(ev)
    }
    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(ev: MotionEvent?): Boolean {
        //Log.e("TAG", "onTouchEvent: height=$height, measuredHeight=$measuredHeight")
        ev?.let {
            when(ev.action) {
                MotionEvent.ACTION_MOVE -> moveView(ev)
                MotionEvent.ACTION_UP -> stopMove()
            }
        }
        return super.onTouchEvent(ev)
    }
    private fun moveView(e: MotionEvent) {
        //Log.e("TAG", "moveView: height=$height, measuredHeight=$measuredHeight")
        val dy = mLastY - e.y
        //更新点击的纵坐标
        mLastY = e.y
        //纵坐标的可滑动范围,0 到 躲藏部分高度,悉数显现内容时是header高度
        val scrollMax = if (isAllDisplay) {
            header!!.height
        }else {
            totalHeight - height
        }
        //限制翻滚范围
        if ((scrollY + dy) <= scrollMax &&  (scrollY + dy) >= 0) {
            //触发移动
            scrollBy(0, dy.toInt())
        }
    }
    private fun stopMove() {
        //Log.e("TAG", "stopMove: height=$height, measuredHeight=$measuredHeight")
        //假如滑动到显现了header,就经过动画躲藏header,并触发抵达顶部回调
        if (scrollY < header!!.height) {
            mScroller.startScroll(0, scrollY, 0, header!!.height - scrollY)
            onReachHeadListener?.onReachHead()
        }else if(!isAllDisplay && scrollY > (totalHeight - height - footer!!.height)) {
            //假如滑动到显现了footer,就经过动画躲藏footer,并触发抵达底部回调
            mScroller.startScroll(0, scrollY,0,
                 (totalHeight - height- footer!!.height) - scrollY)
            onReachFootListener?.onReachFoot()
        }
        invalidate()
    }
    //流畅地滑动
    override fun computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.currX, mScroller.currY)
            postInvalidate()
        }
    }
    //单位转换
    @Suppress("SameParameterValue")
    private fun dp2px(context: Context, dpVal: Float): Int {
        return TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP, dpVal, context.resources
                .displayMetrics
        ).toInt()
    }
    @Suppress("SameParameterValue")
    private fun sp2px(context: Context, spVal: Float): Int {
        val fontScale = context.resources.displayMetrics.scaledDensity
        return (spVal * fontScale + 0.5f).toInt()
    }
    interface OnReachHeadListener{
        fun onReachHead()
    }
    interface OnReachFootListener{
        fun onReachFoot()
    }
}

首要问题

父容器给当时控件的宽高

这儿便是MeasureSpec的了解了,onMeasure中给了两个参数:widthMeasureSpec和heightMeasureSpec,里边包含了父控件给当时控件的宽高,依据模式的不同能够取出给的数值,依据需求设定本身的宽高,需求留意setMeasuredDimension函数设定后,measuredWidth和measuredHeight才有值。

对子控件进行丈量

这儿很简单疏忽的是,当承继viewgroup的时分,咱们要手动去调用child的measure函数,去丈量child的宽高。一开始我也没留意到,当我承继LineaLayout的时分是没问题的,后边改成viewgroup后就出问题了,看了下LineaLayout的源码,里边的onMeasure函数中完成了对child的丈量。

对子控件的丈量时,MeasureSpec又有用了,比如说咱们期望XML中的内容不限高度或者高度很大,这时分MeasureSpec.UNSPECIFIED就有用了,而宽度咱们期望最大便是控件宽度,就能够给个MeasureSpec.AT_MOST,留意咱们给子控件的MeasureSpec也是有两部分的,需求经过makeMeasureSpec创立。

子控件的摆放

由于咱们的footer和header是在构造里边创立并添加到控件中的,这时分XML内的view还没加进来,所以需求留意下footer实际在控件中是第二个,摆放的时分依据index要特别处理一下。

其他控件咱们依据左上右下的顺序摆放就行了,留意onMeasure总对子控件measure了才有宽高。

控件总高度和控件高度

由于需求,咱们的控件要求是中心能够翻滚,所以在onMeasure总,咱们用到了MeasureSpec.UNSPECIFIED,这时分控件的高度和实际总高度就不一致了。这儿咱们需求在onLayout中累加到来,实际摆放控件的时分也要用到这个高度,顺势而为了。

header和footer的初始化显现与躲藏

这儿期望在开始的时分躲藏header,所以需求在onLayout完了的时分,向上翻滚控件,高度为header的高度。

依据需求,彻底显现内容的时分,咱们不期望显现footer,这儿也要在onLayout里边完成,依据XML内容的高度和控件高度一比较就知道需不需求layout footer了。

header和footer的动态显现与躲藏

这儿就和前面两篇文章类似了,便是在纵坐标上翻滚控件,限制翻滚范围,在ACTION_UP事情时断定翻滚后的状态,动态去显现和躲藏header和footer,思路很明确,逻辑可能杂乱一点。

运用

这儿简略说下运用吧,便是作为Layout,中心能够放控件,中心控件能够指定特别大的高度,也能够wrap_content,可是内容很高。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <com.silencefly96.module_common.view.HeaderFooterView
        android:id="@+id/hhView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/teal_700"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent">
        <TextView
            android:text="@string/test_string"
            android:focusable="true"
            android:clickable="true"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
             />
    </com.silencefly96.module_common.view.HeaderFooterView>
</androidx.constraintlayout.widget.ConstraintLayout>

这儿的test_string特别长,翻滚起来header和footer能够拉出来,开释会缩回去。还能够在代码中取得控件增加触底和触顶的回调。

中心为TextView时不触发ACTION_MOVE事情

上面XML布局中,假如不加clickable=true的话,控件中只会收到一个ACTION_DOWN事情,然后就没有然后了,即使是dispatchTouchEvent中也没有事情了。经查,本来不设置isClickable的话,点击该TextView会导致mFirstTouchTarget为null,致使onInterceptTouchEvent不会被调用,由于事情序列中ACTION_DOWN没有被耗费(未回来true),整个事情序列被丢掉了。

结语

实际上这个控件写的并不是很好,拿去用的话仍是不太行的,可是用来学习的话仍是能了解很多东西。