Android 实现嵌套滑动
前言
Android實現簡易版滑動
上次文章中實現了簡易的ScrollerView滑動,但實際使用中許多場景都會涉及到嵌套滑動,在今天的博文中我們基于上次的ScrollLayout來進一步實現嵌套滑動。
嵌套滑動預備知識:https://juejin.cn/post/6844904184911773709
整體頁面結構
<?xml version="1.0" encoding="utf-8"?> <com.example.nestedscroll.ScrollParentLayout xmlns:android="http://schemas.android.com/apk/res/android"android:layout_width="match_parent"android:layout_height="match_parent"android:orientation="vertical"><TextViewandroid:layout_width="match_parent"android:layout_height="300dp"android:text="I'm TOP!"android:gravity="center"android:textSize="24sp"android:background="@color/teal_700"/><com.example.nestedscroll.ScrollChildLayoutandroid:layout_width="match_parent"android:layout_height="wrap_content"android:orientation="vertical"><TextViewandroid:layout_width="match_parent"android:layout_height="100dp"android:text="I'm 1"android:gravity="center"android:textSize="24sp"android:background="@color/red1"/><TextViewandroid:layout_width="match_parent"android:layout_height="100dp"android:text="I'm 2"android:gravity="center"android:textSize="24sp"android:background="@color/red2"/><TextViewandroid:layout_width="match_parent"android:layout_height="100dp"android:text="I'm 3"android:gravity="center"android:textSize="24sp"android:background="@color/red1"/>... 后邊還有n個TextView</com.example.nestedscroll.ScrollChildLayout></com.example.nestedscroll.ScrollParentLayout>嵌套結構中父ViewGroup為ScrollParentLayout,子ViewGroup為ScrollChildLayout。
class ScrollParentLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, ) : NestedScrollLayout(context, attrs), NestedScrollingParent3 class ScrollChildLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, ) : NestedScrollLayout(context, attrs), NestedScrollingChild3- ScrollParentLayout和ScrollChildLayout均繼承自NestedScrollLayout(NestedScrollLayout為ScrollLayout的copy,以不修改ScrollLayout實現)以提供滑動功能
- ScrollParentLayout實現了NestedScrollingParent3接口,作為嵌套滑動的父控件
- ScrollChildLayout實現了NestedScrollingChild3接口,作為嵌套滑動的子控件
頁面滑不動
運行后發現頁面滑不動,查看NestedScrollLayout的onInterceptTouchEvent()實現,為簡單實現滑動效果,上節中簡單將NestedScrollLayout設置為攔截所有觸摸事件。
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {return true }這直接導致了頁面滑不動,因為ScrollParentLayout其子View的高度經過onMeasure后都是固定的了,所以ScrollParentLayout的控件高度和內容高度相等,ScrollParentLayout不可滑動。同時由于ScrollParentLayout在外層攔截了觸摸事件,ScrollChildLayout無法接收到觸摸事件,因此也無法響應,所以頁面無法滑動。
結合嵌套滑動的機制(NestedScrollingParent,NestedScrollingChild機制),滑動時間需由子控件來接收,然后通過嵌套滑動機制來確定父控件是否消費部分滑動距離,因此ScrollParentLayout需要保證不攔截觸摸事件,同時ScrollChildLayout需要接收到觸摸事件。
//ScrollParentLayout.kt override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {return false } //ScrollChildLayout.kt //實現參考了NestedScrollView //實現參考了NestedScrollView override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {if (ev == null) return falseval action = ev.actionif (action == MotionEvent.ACTION_MOVE && isBeingDragged) {return true}var currY = ev.ywhen (action) {MotionEvent.ACTION_MOVE -> {if (abs(currY - lastY) >= touchSlop) {isBeingDragged = trueval parent = parentparent?.requestDisallowInterceptTouchEvent(true)}}MotionEvent.ACTION_DOWN -> {isBeingDragged = false//開始嵌套滑動,注意不是startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL)startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH)}MotionEvent.ACTION_CANCEL,MotionEvent.ACTION_UP,-> {//結束嵌套滑動isBeingDragged = falsestopNestedScroll()}}return isBeingDragged }重寫onInterceptTouchEvent()中,我們默認不攔截觸摸事件,只有當View表現為正在滑動時才進行攔截,以處理滑動,并在開始滑動時調用startNestedScroll(),手指抬起時調用stopNestedScroll(),由于一個事件序列中會有多個ACTION_MOVE事件,而startNestedScroll()僅僅只在第一次判定為滑動時調用,所以引入了isBeingDragged變量,用以判斷當前是否已經在嵌套滑動了,如果是則直接返回true,對應的邏輯為下邊的代碼。
if (action == MotionEvent.ACTION_MOVE && isBeingDragged){return true}經過處理后子View可以正常滑動了。
嵌套Scroll
ScrollChildLayout實現NestedScrollChild3接口
嵌套滑動機制中為我們提供了NestedScrollingChildHelper工具類,封裝了基本的子ScrollView向父ScrollView傳遞滑動事件的操作,我們只需要NestedScrollingChildHelper對應的方法即可。注意NestedScrollingChildHelper要手動設置isNestedScrollingEnabled為ture。
private val childHelper = NestedScrollingChildHelper(this).apply {//注意要手動設置isNestedScrollingEnabled為ture,只有開啟此開關,嵌套滑動才有效isNestedScrollingEnabled = true }override fun startNestedScroll(axes: Int, type: Int): Boolean {return childHelper.startNestedScroll(axes, type) }override fun stopNestedScroll(type: Int) {return childHelper.stopNestedScroll(type) }override fun hasNestedScrollingParent(type: Int): Boolean {return childHelper.hasNestedScrollingParent(type) }override fun dispatchNestedScroll(dxConsumed: Int,dyConsumed: Int,dxUnconsumed: Int,dyUnconsumed: Int,offsetInWindow: IntArray?,type: Int,consumed: IntArray, ) {childHelper.dispatchNestedScroll(dxConsumed,dyConsumed,dxUnconsumed,dyUnconsumed,offsetInWindow,type,consumed) }override fun dispatchNestedScroll(dxConsumed: Int,dyConsumed: Int,dxUnconsumed: Int,dyUnconsumed: Int,offsetInWindow: IntArray?,type: Int, ): Boolean {return childHelper.dispatchNestedScroll(dxConsumed,dyConsumed,dxUnconsumed,dyUnconsumed,offsetInWindow,type) }override fun dispatchNestedPreScroll(dx: Int,dy: Int,consumed: IntArray?,offsetInWindow: IntArray?,type: Int, ): Boolean {return childHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type) }override fun dispatchNestedFling(velocityX: Float,velocityY: Float,consumed: Boolean, ): Boolean {return childHelper.dispatchNestedFling(velocityX, velocityY, consumed) }override fun dispatchNestedPreFling(velocityX: Float, velocityY: Float): Boolean {return childHelper.dispatchNestedPreFling(velocityX, velocityY) }ScrollParentLayout實現NestedScrollParent3接口
嵌套滑動機制中也提供了NestedScrollingParentHelper工具類,我們可以使用此工具類來實現onNestedScrollAccepted()和onStopNestedScroll(),其他很多接口需要我們自行根據業務需要實現。
private val parentHelper = NestedScrollingParentHelper(this)override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {//判斷是否處理嵌套滑動return axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 }override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {parentHelper.onNestedScrollAccepted(child, target, axes, type) }override fun onStopNestedScroll(target: View, type: Int) {parentHelper.onStopNestedScroll(target, type) }override fun onNestedScroll(target: View,dxConsumed: Int,dyConsumed: Int,dxUnconsumed: Int,dyUnconsumed: Int,type: Int,consumed: IntArray, ) {}override fun onNestedScroll(target: View,dxConsumed: Int,dyConsumed: Int,dxUnconsumed: Int,dyUnconsumed: Int,type: Int, ) {}override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {//TODO }override fun onNestedFling(target: View,velocityX: Float,velocityY: Float,consumed: Boolean ): Boolean {//TODO }override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {//TODO }讓嵌套Scroll生效
上邊的onInterceptTouchEvent()中我們通過在TOUCH_DOWN事件中調用了startNestedScroll()方法,開啟了嵌套滑動,此方法主要用于確定嵌套滑動的NestedScrollingParent是誰。
接下來就需要由ScrollChildLayout來在滑動時將事件分發給ScrollParentLayout。滑動事件在onTouchEvent()的ACTION_MOVE事件中處理,這里將其抽離出來單獨放在handleScroll()方法中。
override fun handleScroll(currX: Float, currY: Float) {val deltaX = currX - lastXval deltaY = currY - lastYvar realDeltaY = deltaY.toInt()if (dispatchNestedPreScroll(0,realDeltaY,scrollConsumed,scrollOffset,ViewCompat.TYPE_TOUCH)) {realDeltaY -= scrollConsumed[1]}if (canScrollVertically(1) || canScrollVertically(-1)) {//防止滑出邊界realDeltaY = limitRange(realDeltaY, scrollY, -getScrollRange() + scrollY)scrollBy(0, -realDeltaY)} }上面代碼中,利用嵌套滑動機制,首先dispatchNestedPreScroll()將滑動距離交由ScrollParentLayout來處理,ScrollParentLayout來先消費一部分距離,將剩下未消費的距離交由ScrollChildLayout繼續處理,
ScrollChildLayout在判斷了是否滑出邊界后,調用scrollBy()方法處理剩下的滑動距離。
然后ScrollParentLayout也需要配合完成相應的滑動操作,ScrollParentLayout在onNestedPreScroll()方法中接收到對應的嵌套滑動距離,判斷自身是否要消費。
回顧下目前布局結構是:
-ScrollParentLayout
-TopView
-ScrollChildLayout
ScrollParentLayout有兩種常見處理方式:
在onNestedPreScroll中,我們需要計算出ScrollParentLayout需要消費的滑動距離,主要要保證最后交由ScrollParentLayout處理的滑動的最終位置在[0, topViewHeight]范圍內(即保證TopView可見或剛好不可見的部分才交由ScrollParentLayout處理)。
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {var consumedY = 0//scrollY以向下為正向,整體相對于初始位置的偏移 -topViewHeight <= scrollY <= 0if (target == scrollChildLayout) {//下滑 && TopView還能再下滑(在初始位置之上)if (dy > 0 && scrollY > 0 && !scrollChildLayout.canScrollVertically(-1)) {consumedY = Math.min(scrollY, dy)//上滑 && TopView還能向上滑(TopView還可見)} else if (dy < 0 && scrollY < topViewHeight) {consumedY = Math.max(-topViewHeight + scrollY, dy)}}if (consumedY != 0) {scrollBy(0, -consumedY)consumed[1] = consumedY} }問題1:嵌套滑動距離小于手指滑動距離,滑動抖動
這個問題由于MotionEvent所對應的View(ScrollChildLayout)移動了所導致的,正常的跟手滑動為ScrollChildLayout不動,則每次滑動的deltaY = currY - lastY。currY和lastY都是通過event.getY()獲取到的,event.getY()獲取到的y值是相對于當前View(ScrollChildLayout)的Y值。由于當前View也朝相同方向滑動了,這導致計算出來的deltaY偏小,從而導致嵌套滑動距離小于手指滑動距離。(TODO滑動抖動)
解決辦法(參考NestedScrollView):
我們需要獲取到在ScrollParentLayout滑動時ScrollChildLayout的偏移量,查看dispatchNestedPreScroll()方法,可以使用offsetInWindow這個參數來獲取ScrollParentLayout此次嵌套滑動的偏移量,然后在最后賦值lastY = currY - offsetInWindow[1]來校準偏移量。
/*** 在滑動之前,將滑動值分發給NestedScrollingParent* @param dx 水平方向消費的距離* @param dy 垂直方向消費的距離* @param consumed 輸出坐標數組,consumed[0]為NestedScrollingParent消耗的水平距離、* consumed[1]為NestedScrollingParent消耗的垂直距離,此參數可空。* @param offsetInWindow 含有View從此方法調用之前到調用完成后的屏幕坐標偏移量,* 可以使用這個偏移量來調整預期的輸入坐標(即上面4個消費、剩余的距離)跟蹤,此參數可空。* @return 返回NestedScrollingParent是否消費部分或全部滑動值*/ boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,@NestedScrollType int type);至此就可以流暢的嵌套Scroll了~。
嵌套Fling
回顧前文中非嵌套的fling,通過OverScroller來實現滑動。OverScroller需配合computeScroll()方法一起處理fling動作。
NestedScrollChild接口提供了對應的dispatchNestedFling()和dispatchNestedPreFling()方法,NestedScrollParent接口也提供了對應的onNestedFlin()和onNestedPreFling()方法。由于目前還沒想到使用的時機,暫時不知道咋用。。所以暫不使用這兩個。通過scroll相關的接口也可以實現嵌套fling的效果。
fling事件一般在ACTION_UP事件中處理,先通過overScroller開始fling,然后開啟嵌套滑動,注意嵌套滑動的類型是ViewCompat.TYPE_NON_TOUCH,代表的就是fling類型。
//ScrollChildLayout.kt override fun touchUp() {velocityTracker.computeCurrentVelocity(1000, maxFlingVelocity.toFloat())val yVelocity = velocityTracker.yVelocityif (abs(yVelocity) >= minFlingVelocity) {flingWithOverScroller(-yVelocity)startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH)lastScrollY = scrollYViewCompat.postInvalidateOnAnimation(this)} }同時computeScroll()方法中也要配合實現嵌套滑動,在子View調用scrollBy()方法的之前先通過dispatchNestedPreScroll()詢問父View是否需要處理嵌套滑動事件,然后子View再消耗剩下的滑動距離,實現方法類似處理ACTION_MOVE事件中的嵌套滑動處理。但要注意滑動的類型是ViewCompat.TYPE_NON_TOUCH。
//ScrollChildLayout.kt override fun computeScroll() {if (overScroller.computeScrollOffset()) {val deltaY = overScroller.currY - lastScrollYvar unconsumed = deltaYlastScrollY = overScroller.currYif (dispatchNestedPreScroll(0,unconsumed,scrollConsumed,null,ViewCompat.TYPE_NON_TOUCH)) {unconsumed -= scrollConsumed[1]totalParentConsumeScrollY += scrollConsumed[1]}if (unconsumed != 0 && canScrollVertically(1) || canScrollVertically(-1)) {//防止滑出邊界val selfConsume = getRealScrollDistance(unconsumed)scrollBy(0, -selfConsume)}}if (!overScroller.isFinished) {ViewCompat.postInvalidateOnAnimation(this)} else {stopNestedScroll(ViewCompat.TYPE_NON_TOUCH)}awakenScrollBars()}之所以能通過startNestedScroll()的方式來處理嵌套fling,是因為嵌套scroll本質上是在調用scrollBy()方法之前詢問父View是否要消費滑動距離,而ACTION_MOVE中的跟手滑動和fling中的慣性滑動,都是調用的scrollBy()方法,所以都可以通過startNestedScroll()來處理嵌套滑動。
問題1:嵌套fling的滑動距離明顯不夠,比預期的要短
這個問題的原因類似于嵌套Scroll中的嵌套滑動距離過短,它們都是由于當前View(ScrollChildLayout)的位置也發生了變化,導致了計算的手指移動距離過短而導致的。由于fling事件需要通過velocityTracker.addMovement(event)事先添加該次觸摸事件序列中的所有事件,然后根據所有的event來計算出速度,由于event不加處理的情況下,會由于View(ScrollChildLayout)的滑動導致event的位置不準確,這樣計算出的速度也是不準確的。我們可以使用類似上邊處理嵌套滑動的手段計算出當前View(ScrollChildLayout)滑動的偏差。然后將event加上對應的偏差值,然后再添加到velocityTracker中即可校準速度。
//ScrollChildLayout.ktoverride fun handleScroll(currX: Float, currY: Float) {...if (dispatchNestedPreScroll(0,unconsumed,scrollConsumed,scrollOffset,ViewCompat.TYPE_TOUCH)) {unconsumed -= scrollConsumed[1]//計算此次滑動事件序列的總偏差值,用于校正fling的速度nestedYOffset += scrollOffset[1]lastY -= scrollOffset[1]}... }override fun onTouchEvent(event: MotionEvent?): Boolean {...val offsetEvent = MotionEvent.obtain(event)//根據總的嵌套滑動偏移量,校正速度offsetEvent.offsetLocation(0f, nestedYOffset.toFloat())velocityTracker.addMovement(offsetEvent)offsetEvent.recycle()... }未解決的問題:
原因:ScrollChildLayout的可滑動范圍=totalHeight - visibleHeight,初始時visibleHeight= ScrollParentLayout.visibleHeight - TopViewHeight,而隨著ScrollChildLayout的向上滑動,其visibleHeight會慢慢增加,直到等于ScrollParentLayout.visibleHeight。目前ScrollChildLayout.visibleHeight未動態修改。
總結
以上是生活随笔為你收集整理的Android 实现嵌套滑动的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 计算机动画关键技术,计算机动画关键技术综
- 下一篇: android sina oauth2.