Android嵌套滑动

707 阅读2分钟

嵌套滑动

嵌套滑动的场景

  • 不同向嵌套
    • onInterceptTouchEvent 父 View 拦截
    • requestDisallowInterceptTouchEvent() 子 View 阻止父 View 拦截
  • 同向嵌套
    • 父 View 会彻底卡住子 View
      • 原因:抢夺条件一致,但 父 View 的 onInterceptTouchEvent() 早于子View 的 dispatchTouchEvent()
    • 本质上是策略问题:嵌套状态下用户手指滑动,他是想滑谁?
      • 场景一:NestedScrollView
        • 子 View 能滑动的时候,滑子 View;滑不动的时候,滑父 View
      • 场景二:Google 的样例
        • 上滑:优先父 View
        • 下滑:优先子 View
    • 解决方案:实现策略——父 View、子 View 谁来消费事件可以实时协商
    • 实现?
      • 其实大多数场景,SDK 就解决了
        • 案例 1: ScrollView 嵌套:没法滑动
        • 换成 NestedScrollView:可以滑动

嵌套滑动ImageView实现

  • 图片可以放大
  • 当图片滑动到底部时,可以向上滑动
  • 当图片滑动到顶部时,可以向下滑动
private val IMAGE_SIZE = 300.dp.toInt()
private const val EXTRA_SCALE_FACTOR = 1.5f

class NestedScalableImageView(context: Context?, attrs: AttributeSet?) : View(context, attrs),
  NestedScrollingChild3 {
  private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
  private val bitmap = getAvatar(resources, IMAGE_SIZE)
  private var originalOffsetX = 0f
  private var originalOffsetY = 0f
  private var offsetX = 0f
  private var offsetY = 0f
  private var smallScale = 0f
  private var bigScale = 0f
  private val dshGestureListener = DshGestureListener()
  private val dshScaleGestureListener = DshScaleGestureListener()
  private val dshFlingRunner = DshFlingRunner()
  private val gestureDetector = GestureDetectorCompat(context, dshGestureListener)
  private val scaleGestureDetector = ScaleGestureDetector(context, dshScaleGestureListener)
  private var big = false
  private var currentScale = 0f
    set(value) {
      field = value
      invalidate()
    }
  private val scaleAnimator = ObjectAnimator.ofFloat(this, "currentScale", smallScale, bigScale)
  private val scroller = OverScroller(context)
  private var childHelper = NestedScrollingChildHelper(this).apply {
    isNestedScrollingEnabled = true
  }

  override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)

    originalOffsetX = (width - IMAGE_SIZE) / 2f
    originalOffsetY = (height - IMAGE_SIZE) / 2f

    if (bitmap.width / bitmap.height.toFloat() > width / height.toFloat()) {
      smallScale = width / bitmap.width.toFloat()
      bigScale = height / bitmap.height.toFloat() * EXTRA_SCALE_FACTOR
    } else {
      smallScale = height / bitmap.height.toFloat()
      bigScale = width / bitmap.width.toFloat() * EXTRA_SCALE_FACTOR
    }
    currentScale = smallScale
    scaleAnimator.setFloatValues(smallScale, bigScale)
  }

  override fun onTouchEvent(event: MotionEvent?): Boolean {
    scaleGestureDetector.onTouchEvent(event)
    if (!scaleGestureDetector.isInProgress) {
      gestureDetector.onTouchEvent(event)
      childHelper.startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL)
    }
    return true
  }

  override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)

    val scaleFraction = (currentScale - smallScale) / (bigScale - smallScale)
    canvas.translate(offsetX * scaleFraction, offsetY * scaleFraction)
    canvas.scale(currentScale, currentScale, width / 2f, height / 2f)
    canvas.drawBitmap(bitmap, originalOffsetX, originalOffsetY, paint)
  }

  private fun fixOffsets(): Int {
    val rawOffsetY = offsetY
    val maxOffsetY = (bitmap.height * bigScale - height) / 2
    offsetX = min(offsetX, (bitmap.width * bigScale - width) / 2)
    offsetX = max(offsetX, -(bitmap.width * bigScale - width) / 2)
    offsetY = min(offsetY, maxOffsetY)
    offsetY = max(offsetY, -maxOffsetY)
    return when {
      rawOffsetY < -maxOffsetY -> (-maxOffsetY - rawOffsetY).toInt()
      rawOffsetY > maxOffsetY -> (maxOffsetY - rawOffsetY).toInt()
      else -> 0
    }
  }

  override fun startNestedScroll(axes: Int, type: Int): Boolean {
    return childHelper.startNestedScroll(axes, type)
  }

  override fun stopNestedScroll(type: Int) {
    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
  ) {
    return 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)
  }

  inner class DshGestureListener : GestureDetector.SimpleOnGestureListener() {
    override fun onDown(e: MotionEvent): Boolean {
      return true
    }

    override fun onFling(
      downEvent: MotionEvent?,
      currentEvent: MotionEvent?,
      velocityX: Float,
      velocityY: Float
    ): Boolean {
      if (big) {
        scroller.fling(
          offsetX.toInt(), offsetY.toInt(), velocityX.toInt(), velocityY.toInt(),
          (-(bitmap.width * bigScale - width) / 2).toInt(),
          ((bitmap.width * bigScale - width) / 2).toInt(),
          (-(bitmap.height * bigScale - height) / 2).toInt(),
          ((bitmap.height * bigScale - height) / 2).toInt()
        )
        ViewCompat.postOnAnimation(this@NestedScalableImageView, dshFlingRunner)
      }
      return false
    }

    override fun onScroll(
      downEvent: MotionEvent?,
      currentEvent: MotionEvent?,
      distanceX: Float,
      distanceY: Float
    ): Boolean {
      if (big) {
        offsetX -= distanceX
        offsetY -= distanceY
        val unconsumed = fixOffsets()
        if (unconsumed != 0) {
          childHelper.dispatchNestedScroll(0, 0, 0, unconsumed, null)
        } else {
          invalidate()
        }
      }
      return false
    }

    override fun onDoubleTap(e: MotionEvent): Boolean {
      big = !big
      if (big) {
        offsetX = (e.x - width / 2f) * (1 - bigScale / smallScale)
        offsetY = (e.y - height / 2f) * (1 - bigScale / smallScale)
        fixOffsets()
        scaleAnimator.start()
      } else {
        scaleAnimator.reverse()
      }
      return true
    }
  }

  inner class DshScaleGestureListener : ScaleGestureDetector.OnScaleGestureListener {
    override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
      offsetX = (detector.focusX - width / 2f) * (1 - bigScale / smallScale)
      offsetY = (detector.focusY - height / 2f) * (1 - bigScale / smallScale)
      return true
    }

    override fun onScaleEnd(detector: ScaleGestureDetector?) {

    }

    override fun onScale(detector: ScaleGestureDetector): Boolean {
      val tempCurrentScale = currentScale * detector.scaleFactor
      if (tempCurrentScale < smallScale || tempCurrentScale > bigScale) {
        return false
      } else {
        currentScale *= detector.scaleFactor // 0 1; 0 无穷
        return true
      }
    }
  }

  inner class DshFlingRunner : Runnable {
    override fun run() {
      if (scroller.computeScrollOffset()) {
        offsetX = scroller.currX.toFloat()
        offsetY = scroller.currY.toFloat()
        invalidate()
        ViewCompat.postOnAnimation(this@NestedScalableImageView, this)
      }
    }
  }
}

xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <View
            android:layout_width="match_parent"
            android:layout_height="400dp"
            android:background="@color/colorAccent" />

        <com.dsh.nestedscroll.view.NestedScalableImageView
            android:layout_width="match_parent"
            android:layout_height="400dp" />

    </LinearLayout>

</androidx.core.widget.NestedScrollView>