網站首頁 編程語言 正文
前言
上兩篇文章對安卓自定義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
相關推薦
- 2023-01-15 Android傳感器使用實例介紹_Android
- 2022-07-26 css媒體查詢
- 2022-08-03 基于PyQt5完成pdf轉word功能_python
- 2022-04-01 SQL?Server?數據庫的設計詳解_MsSql
- 2022-07-10 css選擇器優先級問題
- 2022-10-11 spingboot默認日志系統LogBack、Log4j和slf4j使用詳解
- 2023-07-02 Python中星號的五種用法小結_python
- 2022-06-16 Golang?rabbitMQ生產者消費者實現示例_Golang
- 最近更新
-
- window11 系統安裝 yarn
- 超詳細win安裝深度學習環境2025年最新版(
- Linux 中運行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎操作-- 運算符,流程控制 Flo
- 1. Int 和Integer 的區別,Jav
- spring @retryable不生效的一種
- Spring Security之認證信息的處理
- Spring Security之認證過濾器
- Spring Security概述快速入門
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權
- redisson分布式鎖中waittime的設
- maven:解決release錯誤:Artif
- restTemplate使用總結
- Spring Security之安全異常處理
- MybatisPlus優雅實現加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務發現-Nac
- Spring Security之基于HttpR
- Redis 底層數據結構-簡單動態字符串(SD
- arthas操作spring被代理目標對象命令
- Spring中的單例模式應用詳解
- 聊聊消息隊列,發送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠程分支