Android自定義view實現(xiàn)帶header和footer的Layout
前言
上兩篇文章對安卓自定義view的事件分發(fā)做了一些應(yīng)用:Android自定義view實現(xiàn)左滑刪除的RecyclerView詳解、Android自定義view實現(xiàn)列表內(nèi)左滑刪除Item,但是對于自定義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的運用,如何讓控件能夠超出給定的高度,如何獲得實際高度和控件高度,真是紙上得來終覺淺,絕知此事要躬行,看書那么多遍,實際叫自己寫起來真的費勁,不過最終寫完,才真的敢說自己對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的話,點擊該TextView會導(dǎo)致mFirstTouchTarget為null,
//致使onInterceptTouchEvent不會被調(diào)用,只有ACTION_DOWN能被收到,其他事件都沒有
//因為事件序列中ACTION_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è)大一點
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
//更新點擊的縱坐標(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ù)中實現(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實際在控件中是第二個,擺放的時候根據(jù)index要特殊處理一下。
其他控件我們根據(jù)左上右下的順序擺放就行了,注意onMeasure總對子控件measure了才有寬高。
控件總高度和控件高度
因為需求,我們的控件要求是中間可以滾動,所以在onMeasure總,我們用到了MeasureSpec.UNSPECIFIED,這時候控件的高度和實際總高度就不一致了。這里我們需要在onLayout中累加到來,實際擺放控件的時候也要用到這個高度,順勢而為了。
header和footer的初始化顯示與隱藏
這里希望在開始的時候隱藏header,所以需要在onLayout完了的時候,向上滾動控件,高度為header的高度。
根據(jù)需求,完全顯示內(nèi)容的時候,我們不希望顯示footer,這里也要在onLayout里面實現(xiàn),根據(jù)XML內(nèi)容的高度和控件高度一比較就知道需不需要layout footer了。
header和footer的動態(tài)顯示與隱藏
這里就和前面兩篇文章類似了,就是在縱坐標(biāo)上滾動控件,限定滾動范圍,在ACTION_UP事件時判定滾動后的狀態(tài),動態(tài)去顯示和隱藏header和footer,思路很明確,邏輯可能復(fù)雜一點。
使用
這里簡單說下使用吧,就是作為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的話,點擊該TextView會導(dǎo)致mFirstTouchTarget為null,致使onInterceptTouchEvent不會被調(diào)用,因為事件序列中ACTION_DOWN沒有被消耗(未返回true),整個事件序列被丟棄了。
結(jié)語
實際上這個控件寫的并不是很好,拿去用的話還是不太行的,但是用來學(xué)習(xí)的話還是能理解很多東西。
到此這篇關(guān)于Android自定義view實現(xiàn)帶header和footer的Layout的文章就介紹到這了,更多相關(guān)Android帶header和footer的Layout內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
android開發(fā)之方形圓角listview代碼分享
我寫這篇文章受到了kiritor的專欄發(fā)表的博文Android UI控件之ListView實現(xiàn)圓角效果的啟發(fā)。2013-06-06
Android如何跳轉(zhuǎn)到應(yīng)用商店的APP詳情頁面
最近做項目遇到這樣的需求,要求從App內(nèi)部點擊按鈕或鏈接,跳轉(zhuǎn)到應(yīng)用商店的某個APP的詳情頁面,怎么實現(xiàn)此功能呢?下面小編給大家分享Android如何跳轉(zhuǎn)到應(yīng)用商店的APP詳情頁面,需要的朋友參考下2017-01-01
ViewPager 與 Fragment相結(jié)合實現(xiàn)微信界面實例代碼
這篇文章主要介紹了ViewPager 與 Fragment相結(jié)合實現(xiàn)微信界面實例代碼的相關(guān)資料,需要的朋友可以參考下2016-07-07
android md5加密與rsa加解密實現(xiàn)代碼
本文將詳細(xì)介紹android上的MD5和RSA的加解密實現(xiàn)代碼分享,需要了解更多的朋友可以參考下2012-12-12
Flutter UI如何使用Provide實現(xiàn)主題切換詳解
這篇文章主要給大家介紹了關(guān)于Flutter UI如何使用Provide實現(xiàn)主題切換的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對大家學(xué)習(xí)或者使用Flutter具有一定的參考學(xué)習(xí)價值,需要的朋友們下面來一起學(xué)習(xí)學(xué)習(xí)吧2019-04-04
Android開發(fā)中計算器的sin、cos及tan值計算問題分析
這篇文章主要介紹了Android開發(fā)中計算器的sin、cos及tan值計算問題,結(jié)合實例形式分析了Android三角函數(shù)運算中的弧度與角度計算問題與相關(guān)解決方法,需要的朋友可以參考下2017-11-11

