Android實(shí)現(xiàn)滑動折疊Header全流程詳解
前言
上一篇文章直接通過安卓自定義view的知識手撕了一個側(cè)滑欄,做的還不錯,很有成就感。這篇文章的控件沒有上一篇的復(fù)雜,比較簡單,通過一個內(nèi)容滾動造成header折疊的控件學(xué)習(xí)一下滑動事件沖突問題、更改view節(jié)點(diǎn)以及CoordinatorLayout事件傳遞(超低仿),基本都是一個引子,希望學(xué)完這個控件,要繼續(xù)省略學(xué)習(xí)下涉及的內(nèi)容。
需求
這里就是希望做一個滾動通過內(nèi)容能夠折疊header的控件,在XML內(nèi)寫的控件能夠有滾動效果,header暫時默認(rèn)實(shí)現(xiàn)。
核心思想:
1、兩部分,一個header和一個可以滾動的區(qū)域
2、header有兩種狀態(tài),一個是完全展開狀態(tài),一個是折疊狀態(tài)
3、在滾動區(qū)域向下滾動的時候,header會先滾動到折疊狀態(tài),header折疊后滾動區(qū)域才開始滾動
4、在滾動區(qū)域向上滾動的時候,滾動區(qū)域先滾動,滾動區(qū)域到頂了才開始展開header
5、低仿CoordinatorLayout,滾動區(qū)域效果通過自定義layoutParas向header傳遞
效果圖
編寫代碼
import android.annotation.SuppressLint import android.content.Context import android.graphics.Color import android.os.Build import android.util.AttributeSet import android.util.Log import android.view.Gravity import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import android.widget.TextView import androidx.core.view.forEach import androidx.core.widget.NestedScrollView import kotlin.math.abs /** * 內(nèi)容滾動造成header折疊的控件 */ class ScrollingCollapseTopLayout @JvmOverloads constructor( context: Context, attributeSet: AttributeSet? = null, defStyleAttr: Int = 0 ): ViewGroup(context, attributeSet, defStyleAttr) { //外部滑動距離 private var mScrollHeight = 0f //上次縱坐標(biāo) private var mLastY = 0f //當(dāng)前控件寬高 private var mHeight = 0 private var mWidth = 0 //兩個部分 private val header: Header = Header(context).apply { //設(shè)置header垂直方向,寬度鋪滿,高度自適應(yīng) orientation = LinearLayout.VERTICAL layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) } //NestedScrollView只允許一個子view(和ScrollView一樣),這里放一個垂直的LinearLayout private val scrollArea: NestedScrollView = NestedScrollView(context).apply { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) addView(LinearLayout(context).apply { setBackgroundColor(Color.LTGRAY) orientation = LinearLayout.VERTICAL layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) }) } //XML里面的view private val xmlViews: ArrayList<View> = ArrayList() //獲取XML內(nèi)view結(jié)束,沒執(zhí)行onMeasure override fun onFinishInflate() { super.onFinishInflate() //在這里獲得所有子view,攔截添加到scrollArea去 if (xmlViews.size == 0) { forEach { view -> xmlViews.add(view) } } //更換view的節(jié)點(diǎn) removeAllViewsInLayout() addView(header) addView(scrollArea) //把當(dāng)前控件全部view放到NestedScrollView內(nèi)的LinearLayout內(nèi)去 (scrollArea.getChildAt(0) as ViewGroup).also { linear-> for(view in xmlViews) { linear.addView(view) } } } //在onSizeChanged才能獲得正確的寬高,會在onMeasure后得到,這里只是學(xué)一下 override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) mHeight = h mWidth = w } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) //測量header header.onScroll(mScrollHeight.toInt()) header.measure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec),MeasureSpec.AT_MOST)) //先measure一下獲得實(shí)際高度,再減去滑動的距離,也可以把header.measuredHeight寫成全局變量 if (header.measuredHeight != 0) { val scrolledHeight = header.measuredHeight + mScrollHeight val headerHeightMeasureSpec = MeasureSpec.makeMeasureSpec(scrolledHeight.toInt(), MeasureSpec.getMode(MeasureSpec.EXACTLY)) //再次測量的目的是后面滾動部分要占滿剩余高度 header.measure(widthMeasureSpec, headerHeightMeasureSpec) } //測量滑動區(qū)域 val leftHeight = MeasureSpec.getSize(heightMeasureSpec) - header.measuredHeight scrollArea.measure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(leftHeight, MeasureSpec.EXACTLY)) Log.e("TAG", "onMeasure: leftHeight=$leftHeight") Log.e("TAG", "onMeasure: scrollArea.height=${scrollArea.height}") Log.e("TAG", "onMeasure: scrollArea.measuredHeight=${scrollArea.measuredHeight}") //直接占滿寬高 setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)) } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { //簡單布局下,上下兩部分 header.layout(l, t, r, t + header.measuredHeight) scrollArea.layout(l, t + header.measuredHeight, r,b) } //事件沖突使用外部攔截 override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { var isIntercepted = false ev?.let { when(ev.action) { //不攔截down事件 MotionEvent.ACTION_DOWN -> mLastY = ev.y MotionEvent.ACTION_MOVE -> { val dY = ev.y - mLastY //如果折疊了,優(yōu)先滾動折疊欄 val canScrollTop = scrollArea.canScrollVertically(-1) val canScrollBottom = scrollArea.canScrollVertically(1) //可以滾動 isIntercepted = if (canScrollTop || canScrollBottom) { //手指向上移動時,沒折疊前要攔截 val scrollUp = dY < 0 && mScrollHeight + dY > -header.collapsingArea.height.toFloat() //手指向下移動時,沒展開前且到頂了要攔截 val scrollDown = dY > 0 && mScrollHeight + dY < 0f && !canScrollTop scrollUp || scrollDown }else { //不能滾動 true } } //不攔截up事件 //MotionEvent.ACTION_UP -> } } return isIntercepted } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(ev: MotionEvent?): Boolean { ev?.let { when(ev.action) { //MotionEvent.ACTION_DOWN -> MotionEvent.ACTION_MOVE -> { //累加滑動值,請求重新布局 val dY = ev.y - mLastY if (mScrollHeight + dY <= 0 && mScrollHeight + dY >= -header.collapsingArea.height) { mScrollHeight += dY requestLayout() } mLastY = ev.y } //MotionEvent.ACTION_UP -> } } return super.onTouchEvent(ev) } //這里就做一個簡單的折疊header, @Suppress("MemberVisibilityCanBePrivate") inner class Header @JvmOverloads constructor( context: Context, attributeSet: AttributeSet? = null, defStyleAttr: Int = 0, ): LinearLayout(context, attributeSet, defStyleAttr){ //兩個區(qū)域 val defaultArea: TextView val collapsingArea: TextView init { //添加兩個header區(qū)域 defaultArea = makeTextView(context, "Default area", 80) collapsingArea = makeTextView(context, "Collapsing area", 300) addView(defaultArea) addView(collapsingArea) } //低配Behavior.onNestedPreScroll,這里就處理下ScrollingHideTopLayout傳過來的距離 @SuppressLint("SetTextI18n") fun onScroll(scrollHeight: Int) { val expandHeight = collapsingArea.height + scrollHeight //這里就改一下背景色的透明度吧 if (abs(expandHeight) <= collapsingArea.height) { val alpha = expandHeight.toFloat() / collapsingArea.height * 255 defaultArea.text = "Default area:${alpha.toInt()}" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { collapsingArea.setBackgroundColor(Color.argb(alpha.toInt(),88,88,88)) } } } //創(chuàng)建TextView private fun makeTextView(context: Context, textStr: String, height: Int): TextView { //簡單點(diǎn)height和textSize應(yīng)該用dp和sp的,前面文章有 return TextView(context).apply { layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height) text = textStr gravity = Gravity.CENTER textSize = 13f setBackgroundColor(Color.GRAY) } } } }
主要問題
NestedScrollView的使用
要想中間內(nèi)容能夠滾動,并且和當(dāng)前控件造成滑動沖突,就只能引入新的滑動控件了,這里使用了NestedScrollView,和ScrollView類似。NestedScrollView只允許有一個子view,至于為什么可以看下源碼,內(nèi)容不多。我這是直接創(chuàng)建了一個NestedScrollView,并往里面加個一個垂直的LinearLayout,后面更改xml里面的view節(jié)點(diǎn),往LinearLayout里面放。
修改xml內(nèi)view的節(jié)點(diǎn)
上一篇文章里面,側(cè)滑欄在xml里面的位置會影響繪制的層級,我是在onLayout里面通過移除再添加的方式做的,那如果要把view改到其他view里面去該怎么辦。一開始我覺得很簡單嘛,直接在onMeasure里面得到所有xml里面的view,再添加到其他viewgroup里面不就行了!想法很簡單,試一下結(jié)果出我問題了。
第一個問題是view添加到其他viewgroup必須先移除,那我就直接就removeViewInLayout,結(jié)果就出了第二個問題OverStackError,大致就是一直measure,試了下是addView導(dǎo)致的,邏輯還是有問題。后面想想不應(yīng)該在onMeasure里面實(shí)現(xiàn)的,應(yīng)該在viewgroup加載xml里面子view時攔截處理的。
于是找了下api,發(fā)現(xiàn)viewgroup提供了一個onFinishInflate方法,會在加載xml里面view完成時調(diào)用,關(guān)鍵是它只會調(diào)用一次,onMeasure會調(diào)用多次,正好符合了我們的需求。修改節(jié)點(diǎn)就簡單了,for循環(huán)一下就ok。
onSizeChanged函數(shù)
上面用到了onFinishInflate方法,找資料的時候看到自定義view里面常用重寫的方法還有一個onSizeChanged函數(shù)。其實(shí)用的也多,主要是自定義view時用來獲取控件寬高的,當(dāng)控件的Size發(fā)生變化,如measure結(jié)束,onSizeChanged被調(diào)用,這時候才能拿到寬高,不然拿到的height和width就是0。
滑動事件沖突處理
我覺得滑動事件沖突的處理都應(yīng)該根據(jù)實(shí)際情況去處理,知識的話可以去看看《安卓開發(fā)藝術(shù)探討》里面的相關(guān)知識,主要解決辦法就是內(nèi)部攔截法和外部攔截法。我這就是簡單的外部攔截法,本來想寫復(fù)雜點(diǎn),看看能不能多學(xué)點(diǎn)東西,結(jié)果根據(jù)需求,最后的代碼很簡單。
外部攔截法原理就是在onInterceptTouchEvent方法中,通過根據(jù)場景判斷是內(nèi)部滾動還是外部滾動,外部滾動就直接攔截,內(nèi)部是否能滾動可以通過canScrollVertically/canScrollHorizontally方法判斷。我這邏輯很簡單,首先判斷下內(nèi)部是否能滾動,內(nèi)部不能滾動就直接交給外部處理;然后又分兩種情況,一個是手指向上移動時,沒折疊前要攔截,另一個就是手指向下移動時,沒展開前且到內(nèi)部頂了要攔截。無論真么處理,還是得根據(jù)情景,
模仿CoordinatorLayout
本來還想模仿CoordinatorLayout做一個滑動狀態(tài)傳遞的,這里滾動控件用的NestedScrollingChild,想讓當(dāng)前控件繼承NestedScrollingParent處理滑動沖突,后面覺得還是簡單點(diǎn)自己在onInterceptTouchEvent方法中處理能學(xué)點(diǎn)東西。當(dāng)然讀者有興趣可借機(jī)學(xué)習(xí)一下NestedScrollingChild和NestedScrollingParent。
對于CoordinatorLayout,我也是學(xué)習(xí)了一下其中原理,私以為大致就是CoordinatorLayout的LayoutParams內(nèi)有一個Behavior屬性,Behavior作用就是構(gòu)建兩個子控件的關(guān)聯(lián)關(guān)系(在CoordinatorLayout的onMeasure中),建立關(guān)聯(lián)關(guān)系后,當(dāng)一個view變化就會造成關(guān)聯(lián)的view跟著變化(CoordinatorLayout控制),當(dāng)然原理沒這么簡單,還是要去看源碼。
本來我也想按這個邏輯模仿一下的,首先就是給當(dāng)前控件的LayoutParams加一個Behavior屬性,當(dāng)滾動控件設(shè)置這個Behavior屬性時,Header類在measure的時候就創(chuàng)建一個Behavior屬性的私有變量,當(dāng)前控件通過NestedScrollingChild接受滾動事件,并交給Header類的Behavior屬性的私有變量去處理,一套邏輯下來,總感覺有脫褲子放屁的感覺,畢竟我這個控件就兩個子控件。CoordinatorLayout的目的是協(xié)調(diào)多 View 之間的聯(lián)動,重點(diǎn)在多,我這真沒必要。
其實(shí)說到底,CoordinatorLayout就是一個協(xié)調(diào)功能,關(guān)聯(lián)兩個控件,比如我這就是滾動控件發(fā)出滾動消息,當(dāng)前控件收到滾動消息,傳遞到Header里面處理,就這么簡單,多了倒是可以按上面邏輯處理。
header折疊效果
這里的header的折疊效果是從onMeasure里面得到的!在測量時,根據(jù)滑動值,修改header的heightMeasureSpec,把header的高度設(shè)置為原有高度減去滑動高度,測量完header之后,把剩余的高度給到滑動區(qū)域,onLayout的時候?qū)蓚€控件挨著就行?;瑒拥臅r候,請求重新layout,header和滾動區(qū)域每次都會獲得不一樣的高度,看起來就有了折疊效果。
到此這篇關(guān)于Android實(shí)現(xiàn)滑動折疊Header全流程詳解的文章就介紹到這了,更多相關(guān)Android Header內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
flutter 動手?jǐn)]一個城市選擇citypicker功能
在一些項目開發(fā)中經(jīng)常會用到城市選擇器功能,今天小編動手?jǐn)]一個基于flutter 城市選擇citypicker功能,具體實(shí)現(xiàn)過程跟隨小編一起看看吧2021-08-08Android 處理OnItemClickListener時關(guān)于焦點(diǎn)顏色的設(shè)置問題
這篇文章主要介紹了Android 處理OnItemClickListener時關(guān)于焦點(diǎn)顏色的設(shè)置問題的相關(guān)資料,需要的朋友可以參考下2017-02-02一文教你如何使用Databinding寫一個關(guān)注功能
這篇文章主要介紹了一文教你如何使用Databinding寫一個關(guān)注功能,文章圍繞主題展開詳細(xì)的內(nèi)容介紹,具有一定的參考價值,需要的小伙伴可以參考一下2022-09-09Android應(yīng)用中仿今日頭條App制作ViewPager指示器
這篇文章主要介紹了Android應(yīng)用中仿今日頭條App制作ViewPager指示器的例子,一般就是導(dǎo)航條在翻頁時的動態(tài)字體變色效果,需要的朋友可以參考下2016-04-04Android Room數(shù)據(jù)庫容易遇到的問題以及解決方法
這篇文章給大家介紹了我們在Android Room數(shù)據(jù)庫容易遇到的坑以及解決方法,文中有詳細(xì)的代碼示例供我們參考,具有一定的參考價值,需要的朋友可以參考下2023-09-09深入android Unable to resolve target ''android-XX''詳解
本篇文章是對android Unable to resolve target 'android-XX'錯誤的解決方法進(jìn)行了詳細(xì)的分析介紹,需要的朋友參考下2013-06-06Android常用命令集錦(圖文并茂適應(yīng)于初學(xué)者)
大家好,今天我們要講的是android開發(fā)中,比較常用的名令集錦, 在我們開發(fā)中難免用到Android命令,有些確實(shí)命令確實(shí)很有用處,這也是我為什么總結(jié)這篇文章的原因了,希望對大家有所幫助2013-01-01