前言
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">
<TextView
android: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.ScrollChildLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="100dp"
android:text="I'm 1"
android:gravity="center"
android:textSize="24sp"
android:background="@color/red1"/>
<TextView
android:layout_width="match_parent"
android:layout_height="100dp"
android:text="I'm 2"
android:gravity="center"
android:textSize="24sp"
android:background="@color/red2"/>
<TextView
android: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 false
val action = ev.action
if (action == MotionEvent.ACTION_MOVE && isBeingDragged) {
return true
}
var currY = ev.y
when (action) {
MotionEvent.ACTION_MOVE -> {
if (abs(currY - lastY) >= touchSlop) {
isBeingDragged = true
val parent = parent
parent?.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 = false
stopNestedScroll()
}
}
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 - lastX
val deltaY = currY - lastY
var 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有两种常见处理方式:
- TopView和ScrollChildLayout同步调用layout(left,top,right,bottom)方法,TopView更新top和bottom,ScrollChildLayout更新top。(实现时遇到些问题,暂未采用)
- ScrollParentLayout调用scrollBy方法()整体滑动。看起来比较简单,下边代码即采用此方案。但需要先改造onMeasure()方法,让其能计算出其内容的高度(包括所有可滑动的子View内容的高度)作为其view的height属性
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//visibleHeight为控件可见高度
visibleHeight = measuredHeight
if (orientation == VERTICAL) {
var totalLength = paddingTop + paddingBottom
for (child in children) {
totalLength += child.marginTop + child.measuredHeight + child.marginBottom
}
totalHeight = totalLength
}
//将measureHeight设置为内容的高度
setMeasuredDimension(measuredWidth, totalHeight)
}
在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 <= 0
if (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.yVelocity
if (abs(yVelocity) >= minFlingVelocity) {
flingWithOverScroller(-yVelocity)
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH)
lastScrollY = scrollY
ViewCompat.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 - lastScrollY
var unconsumed = deltaY
lastScrollY = overScroller.currY
if (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.kt
override 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()
...
}
未解决的问题:
- 嵌套滑动时,可滑动边界判断不准,滑动到底部还多出一段空白(高度等于TopView)。
原因:ScrollChildLayout的可滑动范围=totalHeight - visibleHeight,初始时visibleHeight= ScrollParentLayout.visibleHeight - TopViewHeight,而随着ScrollChildLayout的向上滑动,其visibleHeight会慢慢增加,直到等于ScrollParentLayout.visibleHeight。目前ScrollChildLayout.visibleHeight未动态修改。
|