Android自定義view實(shí)現(xiàn)有header和footer作為layout使用的滾動控件
前言
上兩篇文章對安卓自定義view的事件分發(fā)做了一些應(yīng)用,但是對于自定義view來講,并不僅僅是事件分發(fā)這么簡單,還有一個很重要的內(nèi)容就是view的繪制流程。接下來我這通過帶header和footer的Layout,來學(xué)習(xí)一下ViewGroup的自定義流程,并對其中的MeasureSpec、onMeasure以及onLayout加深理解。
需求
這里就是一個有header和footer的滾動控件,可以在XML中當(dāng)Layout使用,核心思想如下:
1、由header、XML內(nèi)容、footer三部分組成
2、滾動中間控件時,上面有內(nèi)容時header不顯示,下面有內(nèi)容時footer不顯示
3、滑動到header和footer最大值時不能滑動,釋放的時候需要回彈
4、完全顯示時隱藏footer
編寫代碼
編寫代碼這部分還真讓我頭疼了一會,主要就是MeasureSpec的運(yùn)用,如何讓控件能夠超出給定的高度,如何獲得實(shí)際高度和控件高度,真是紙上得來終覺淺,絕知此事要躬行,看書那么多遍,實(shí)際叫自己寫起來真的費(fèi)勁,不過最終寫完,才真的敢說自己對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、滾動中間控件時,上面有內(nèi)容時header不顯示,下面有內(nèi)容時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 //上次事件的橫坐標(biāo) private var mLastY = 0f //總高度 private var totalHeight = 0 //是否全部顯示 private var isAllDisplay = false //流暢滑動 private var mScroller = Scroller(context) init { //設(shè)置默認(rèn)的Header、Footer,這里是從構(gòu)造來的,如果外部設(shè)置需要另外處理 header = header ?: makeTextView(context, "Header") footer = footer ?: makeTextView(context, "Footer") //添加對應(yīng)控件 addView(header, 0) //這里還沒有加入XML中的控件 //Log.e("TAG", "init: childCount=$childCount", ) addView(footer, 1) } //創(chuàng)建默認(rèn)的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) //不設(shè)置isClickable的話,點(diǎn)擊該TextView會導(dǎo)致mFirstTouchTarget為null, //致使onInterceptTouchEvent不會被調(diào)用,只有ACTION_DOWN能被收到,其他事件都沒有 //因?yàn)槭录蛄兄蠥CTION_DOWN沒有被消耗(返回true),整個事件序列被丟棄了 //如果XML內(nèi)是TextView也會造成同樣情況, isFocusable = true isClickable = true } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) //父容器給當(dāng)前控件的寬高,默認(rèn)值盡量設(shè)大一點(diǎn) val width = getSizeFromMeasureSpec(1080, widthMeasureSpec) val height = getSizeFromMeasureSpec(2160, heightMeasureSpec) //對子控件進(jìn)行測量 forEach { child -> //寬度給定最大值 val childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.AT_MOST) //高度不限定 val childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.UNSPECIFIED) //進(jìn)行測量,不測量的話measuredWidth和measuredHeight會為0 child.measure(childWidthMeasureSpec, childHeightMeasureSpec) //Log.e("TAG", "onMeasure: child.measuredWidth=${child.measuredWidth}") //Log.e("TAG", "onLayout: child.measuredHeight=${child.measuredHeight}") } //設(shè)置測量高度為父容器最大寬高 setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)) } private fun getSizeFromMeasureSpec(defaultSize: Int, measureSpec: Int): Int { //獲取MeasureSpec內(nèi)模式和尺寸 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) //完全顯示內(nèi)容時不加載footer,header不算入內(nèi)容 if (measuredHeight < curHeight - header!!.height) { //設(shè)置全部顯示flag isAllDisplay = false footer.layout(left, top + curHeight, right,top + curHeight + footer.measuredHeight) curHeight += footer.measuredHeight } //布局完成,滾動一段距離,隱藏header scrollBy(0, header!!.height) //設(shè)置總高度 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 //更新點(diǎn)擊的縱坐標(biāo) mLastY = e.y //縱坐標(biāo)的可滑動范圍,0 到 隱藏部分高度,全部顯示內(nèi)容時是header高度 val scrollMax = if (isAllDisplay) { header!!.height }else { totalHeight - height } //限定滾動范圍 if ((scrollY + dy) <= scrollMax && (scrollY + dy) >= 0) { //觸發(fā)移動 scrollBy(0, dy.toInt()) } } private fun stopMove() { //Log.e("TAG", "stopMove: height=$height, measuredHeight=$measuredHeight") //如果滑動到顯示了header,就通過動畫隱藏header,并觸發(fā)到達(dá)頂部回調(diào) if (scrollY < header!!.height) { mScroller.startScroll(0, scrollY, 0, header!!.height - scrollY) onReachHeadListener?.onReachHead() }else if(!isAllDisplay && scrollY > (totalHeight - height - footer!!.height)) { //如果滑動到顯示了footer,就通過動畫隱藏footer,并觸發(fā)到達(dá)底部回調(diào) 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() } } //單位轉(zhuǎn)換 @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() } }
主要問題
父容器給當(dāng)前控件的寬高
這里就是MeasureSpec的理解了,onMeasure中給了兩個參數(shù):widthMeasureSpec和heightMeasureSpec,里面包含了父控件給當(dāng)前控件的寬高,根據(jù)模式的不同可以取出給的數(shù)值,根據(jù)需要設(shè)定自身的寬高,需要注意setMeasuredDimension函數(shù)設(shè)定后,measuredWidth和measuredHeight才有值。
對子控件進(jìn)行測量
這里很容易忽略的是,當(dāng)繼承viewgroup的時候,我們要手動去調(diào)用child的measure函數(shù),去測量child的寬高。一開始我也沒注意到,當(dāng)我繼承LineaLayout的時候是沒問題的,后面改成viewgroup后就出問題了,看了下LineaLayout的源碼,里面的onMeasure函數(shù)中實(shí)現(xiàn)了對child的測量。
對子控件的測量時,MeasureSpec又有用了,比如說我們希望XML中的內(nèi)容不限高度或者高度很大,這時候MeasureSpec.UNSPECIFIED就有用了,而寬度我們希望最大就是控件寬度,就可以給個MeasureSpec.AT_MOST,注意我們給子控件的MeasureSpec也是有兩部分的,需要通過makeMeasureSpec創(chuàng)建。
子控件的擺放
由于我們的footer和header是在構(gòu)造里面創(chuàng)建并添加到控件中的,這時候XML內(nèi)的view還沒加進(jìn)來,所以需要注意下footer實(shí)際在控件中是第二個,擺放的時候根據(jù)index要特殊處理一下。
其他控件我們根據(jù)左上右下的順序擺放就行了,注意onMeasure總對子控件measure了才有寬高。
控件總高度和控件高度
因?yàn)樾枨?,我們的控件要求是中間可以滾動,所以在onMeasure總,我們用到了MeasureSpec.UNSPECIFIED,這時候控件的高度和實(shí)際總高度就不一致了。這里我們需要在onLayout中累加到來,實(shí)際擺放控件的時候也要用到這個高度,順勢而為了。
header和footer的初始化顯示與隱藏
這里希望在開始的時候隱藏header,所以需要在onLayout完了的時候,向上滾動控件,高度為header的高度。
根據(jù)需求,完全顯示內(nèi)容的時候,我們不希望顯示footer,這里也要在onLayout里面實(shí)現(xiàn),根據(jù)XML內(nèi)容的高度和控件高度一比較就知道需不需要layout footer了。
header和footer的動態(tài)顯示與隱藏
這里就和前面兩篇文章類似了,就是在縱坐標(biāo)上滾動控件,限定滾動范圍,在ACTION_UP事件時判定滾動后的狀態(tài),動態(tài)去顯示和隱藏header和footer,思路很明確,邏輯可能復(fù)雜一點(diǎn)。
使用
這里簡單說下使用吧,就是作為Layout,中間可以放控件,中間控件可以指定特別大的高度,也可以wrap_content,但是內(nèi)容很高。
<?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可以拉出來,釋放會縮回去。還可以在代碼中獲得控件增加觸底和觸頂?shù)幕卣{(diào)。
中間為TextView時不觸發(fā)ACTION_MOVE事件
上面XML布局中,如果不加clickable=true的話,控件中只會收到一個ACTION_DOWN事件,然后就沒有然后了,即使是dispatchTouchEvent中也沒有事件了。經(jīng)查,原來不設(shè)置isClickable的話,點(diǎn)擊該TextView會導(dǎo)致mFirstTouchTarget為null,致使onInterceptTouchEvent不會被調(diào)用,因?yàn)槭录蛄兄蠥CTION_DOWN沒有被消耗(未返回true),整個事件序列被丟棄了。
結(jié)語
實(shí)際上這個控件寫的并不是很好,拿去用的話還是不太行的,但是用來學(xué)習(xí)的話還是能理解很多東西。
到此這篇關(guān)于Android自定義view實(shí)現(xiàn)有header和footer作為layout使用的滾動控件的文章就介紹到這了,更多相關(guān)Android滾動控件內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
android實(shí)現(xiàn)輪播圖引導(dǎo)頁
這篇文章主要為大家詳細(xì)介紹了android實(shí)現(xiàn)輪播圖引導(dǎo)頁,文中示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下2022-09-09Android Canvas之drawBitmap方法案例詳解
這篇文章主要介紹了Android Canvas之drawBitmap方法案例詳解,本篇文章通過簡要的案例,講解了該項(xiàng)技術(shù)的了解與使用,以下就是詳細(xì)內(nèi)容,需要的朋友可以參考下2021-08-08Android 接收微信、QQ其他應(yīng)用打開第三方分享功能
這篇文章主要介紹了Android 接收微信、QQ其他應(yīng)用打開,第三方分享 ,思路很簡單通過在AndroidManifest.xml注冊ACTION事件,在用于接收分享的Activity里面加接收代碼,感興趣的朋友可以一起學(xué)習(xí)下2022-11-11android6.0權(quán)限動態(tài)申請框架permissiondispatcher的方法
下面小編就為大家分享一篇android6.0權(quán)限動態(tài)申請框架permissiondispatcher的方法,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-01-01Flutter上線項(xiàng)目實(shí)戰(zhàn)記錄之路由篇
這篇文章主要給大家介紹了關(guān)于Flutter上線項(xiàng)目實(shí)戰(zhàn)記錄之路由篇的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對大家學(xué)習(xí)或者使用Flutter具有一定的參考學(xué)習(xí)價值,需要的朋友們下面來一起學(xué)習(xí)學(xué)習(xí)吧2019-09-09Android 進(jìn)入設(shè)備后臺data文件夾的辦法
Android 進(jìn)入設(shè)備后臺data文件夾的辦法,需要的朋友可以參考一下2013-05-05Android實(shí)現(xiàn)類似于PC中的右鍵彈出菜單效果
這篇文章主要介紹了Android實(shí)現(xiàn)類似于PC中的右鍵彈出菜單效果,需要的朋友可以參考下2015-12-12Android Service判斷設(shè)備聯(lián)網(wǎng)狀態(tài)詳解
本文主要介紹Android Service判斷聯(lián)網(wǎng)狀態(tài),這里提供了相關(guān)資料并附有示例代碼,有興趣的小伙伴可以參考下,幫助開發(fā)相關(guān)應(yīng)用功能2016-08-08