日本免费高清视频-国产福利视频导航-黄色在线播放国产-天天操天天操天天操天天操|www.shdianci.com

學無先后,達者為師

網站首頁 編程語言 正文

Android自定義view實現有header和footer作為layout使用的滾動控件_Android

作者:撿一晌貪歡 ? 更新時間: 2022-12-05 編程語言

前言

上兩篇文章對安卓自定義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),整個事件序列被丟棄了。

結語

實際上這個控件寫的并不是很好,拿去用的話還是不太行的,但是用來學習的話還是能理解很多東西。

原文鏈接:https://blog.csdn.net/lfq88/article/details/127259569

相關推薦

欄目分類
最近更新