返回介绍

处理与子视图的滚动交互

发布于 2024-12-23 22:24:44 字数 14273 浏览 0 评论 0 收藏 0

下拉刷新控件的主要功能是当子视图下拉到最顶部时,继续下拉可以出现刷新动画。而子视图可以滚动时需要将所有滚动事件都交给子视图。借助 Android 提供的 NestedScrolling 机制,使得 SwipeRefreshLayout 很轻松的解决了与子视图的滚动冲突问题。 SwipeRefreshLayout 通过实现 NestedScrollingParentNestedScrollingChild 接口来处理滚动冲突。SwipeRefreshLayout 作为 Parent 嵌套一个可以滚动的子视图,那么就需要了解一下 NestedScrollingParent 接口

/**
 当你希望自己的自定义布局支持嵌套子视图并且处理滚动操作,就可以实现该接口。
 实现这个接口后可以创建一个 NestedScrollingParentHelper 字段,使用它来帮助你处理大部分的方法。
 处理嵌套的滚动时应该使用  `ViewCompat`,`ViewGroupCompat`或`ViewParentCompat` 中的方法来处理,这是一些兼容库,
 他们保证 Android 5.0 之前的兼容性垫片的静态方法,这样可以兼容 Android 5.0 之前的版本。
 */
public interface NestedScrollingParent {
  /**
   * 当子视图调用 startNestedScroll(View, int) 后调用该方法。返回 true 表示响应子视图的滚动。
   * 实现这个方法来声明支持嵌套滚动,如果返回 true,那么这个视图将要配合子视图嵌套滚动。当嵌套滚动结束时会调用到 onStopNestedScroll(View)。
   *
   * @param child 可滚动的子视图
   * @param target NestedScrollingParent 的直接可滚动的视图,一般情况就是 child
   * @param nestedScrollAxes 包含 ViewCompat#SCROLL_AXIS_HORIZONTAL, ViewCompat#SCROLL_AXIS_VERTICAL 或者两个值都有。
   * @return 返回 true 表示响应子视图的滚动。
   */
  public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);

  /**
   * 如果 onStartNestedScroll 返回 true ,然后走该方法,这个方法里可以做一些初始化。
   */
  public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);


  /**
   * 子视图开始滚动前会调用这个方法。这时候父布局(也就是当前的 NestedScrollingParent 的实现类)可以通过这个方法来配合子视图同时处理滚动事件。
   *
   * @param target 滚动的子视图
   * @param dx 绝对值为手指在 x 方向滚动的距离,dx<0 表示手指在屏幕向右滚动
   * @param dy 绝对值为手指在 y 方向滚动的距离,dy<0 表示手指在屏幕向下滚动
   * @param consumed 一个数组,值用来表示父布局消耗了多少距离,未消耗前为[0,0], 如果父布局想处理滚动事件,就可以在这个方法的实现中为 consumed[0],consumed[1]赋值。
   *         分别表示 x 和 y 方向消耗的距离。如父布局想在竖直方向(y)完全拦截子视图,那么让 consumed[1] = dy,就把手指产生的触摸事件给拦截了,子视图便响应不到触摸事件了 。
   */
  public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);


  /**
   * 这个方法表示子视图正在滚动,并且把滚动距离回调用到该方法,前提是 onStartNestedScroll 返回了 true。
   * <p>Both the consumed and unconsumed portions of the scroll distance are reported to the
   * ViewParent. An implementation may choose to use the consumed portion to match or chase scroll
   * position of multiple child elements, for example. The unconsumed portion may be used to
   * allow continuous dragging of multiple scrolling or draggable elements, such as scrolling
   * a list within a vertical drawer where the drawer begins dragging once the edge of inner
   * scrolling content is reached.</p>
   *
   * @param target 滚动的子视图
   * @param dxConsumed 手指产生的触摸距离中,子视图消耗的 x 方向的距离
   * @param dyConsumed 手指产生的触摸距离中,子视图消耗的 y 方向的距离 ,如果 onNestedPreScroll 中 dy = 20, consumed[0] = 8,那么 dy = 12
    * @param dxUnconsumed 手指产生的触摸距离中,未被子视图消耗的 x 方向的距离
   * @param dyUnconsumed 手指产生的触摸距离中,未被子视图消耗的 y 方向的距离
   */
  public void onNestedScroll(View target, int dxConsumed, int dyConsumed,int dxUnconsumed, int dyUnconsumed);



  /**
   * 响应嵌套滚动结束
   *
   * 当一个嵌套滚动结束后(如 MotionEvent#ACTION_UP, MotionEvent#ACTION_CANCEL)会调用该方法,在这里可有做一些收尾工作,比如变量重置
   */
  public void onStopNestedScroll(View target);


  /**
   * 手指在屏幕快速滑触发 Fling 前回调,如果前面 onNestedPreScroll 中父布局消耗了事件,那么这个也会被触发
   * 返回 true 表示父布局完全处理 fling 事件
   *
   * @param target 滚动的子视图
   * @param velocityX x 方向的速度(px/s)
   * @param velocityY y 方向的速度
   * @return true if this parent consumed the fling ahead of the target view
   */
  public boolean onNestedPreFling(View target, float velocityX, float velocityY);

  /**
   * 子视图 fling 时回调,父布局可以选择监听子视图的 fling。
   * true 表示父布局处理 fling,false 表示父布局监听子视图的 fling
   *
   * @param target View that initiated the nested scroll
   * @param velocityX Horizontal velocity in pixels per second
   * @param velocityY Vertical velocity in pixels per second
   * @param consumed true 表示子视图处理了 fling

   */
  public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);

  /**
   * 返回当前 NestedScrollingParent 的滚动方向,
   *
   * @return
   * @see ViewCompat#SCROLL_AXIS_HORIZONTAL
   * @see ViewCompat#SCROLL_AXIS_VERTICAL
   * @see ViewCompat#SCROLL_AXIS_NONE
   */
  public int getNestedScrollAxes();
}

看一下 SwipeRefreshLayout 实现 NestedScrollingParent 的相关方法

// NestedScrollingParent

// 子 View (NestedScrollingChild)开始滚动前回调此方法,返回 true 表示接 Parent 收嵌套滚动,然后调用 onNestedScrollAccepted
// 具体可以看 NestedScrollingChildHelper 的源码
@Override
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
  // 子 View 回调,判断是否开始嵌套滚动 ,
  return isEnabled() && !mReturningToStart && !mRefreshing
      && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}

@Override
 public void onNestedScrollAccepted(View child, View target, int axes) {
   // Reset the counter of how much leftover scroll needs to be consumed.
   mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes);

   // ...省略代码
 }

SwipeRefreshLayout 只接受竖直方向(Y 轴)的滚动,并且在刷新动画进行中不接受滚动。

// NestedScrollingChild 在滚动的时候会触发, 看父类消耗了多少距离
//   * @param dx x 轴滚动的距离
//   * @param dy y 轴滚动的距离
//   * @param consumed 代表 父 View 消费的滚动距离
//
@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {

  // dy > 0 表示手指在屏幕向上移动
  //  mTotalUnconsumed 表示子视图 Y 轴未消费的距离
  // 现在表示
  if (dy > 0 && mTotalUnconsumed > 0) {

    if (dy > mTotalUnconsumed) {
      consumed[1] = dy - (int) mTotalUnconsumed; // SwipeRefreshLayout 就吧子视图位消费的距离全部消费了。
      mTotalUnconsumed = 0;
    } else {
      mTotalUnconsumed -= dy; // 消费的 y 轴的距离
      consumed[1] = dy;
    }
    // 出现动画圆圈,并向上移动
    moveSpinner(mTotalUnconsumed);
  }

  // ... 省略代码
}


// onStartNestedScroll 返回 true 才会调用此方法。此方法表示子 View 将滚动事件分发到父 View(SwipeRefreshLayout)
@Override
public void onNestedScroll(final View target, final int dxConsumed, final int dyConsumed,
    final int dxUnconsumed, final int dyUnconsumed) {
  // ... 省略代码

  // This is a bit of a hack. Nested scrolling works from the bottom up, and as we are
  // sometimes between two nested scrolling views, we need a way to be able to know when any
  // nested scrolling parent has stopped handling events. We do that by using the
  // 'offset in window 'functionality to see if we have been moved from the event.
  // This is a decent indication of whether we should take over the event stream or not.
  // 手指在屏幕上向下滚动,并且子视图不可以滚动
  final int dy = dyUnconsumed + mParentOffsetInWindow[1];
  if (dy < 0 && !canChildScrollUp()) {
    mTotalUnconsumed += Math.abs(dy);
    moveSpinner(mTotalUnconsumed);
  }
}

SwipeRefreshLayout 通过 NestedScrollingParent 接口完成了处理子视图的滚动的冲突,中间省略了一些 SwipeRefreshLayout 作为 child 的相关代码,这种情况是为了兼容将 SwipeRefreshLayout 作为子视图放在知识嵌套滚动的父布局的情况,这里不做深入讨论。但是下拉刷新需要判断手指在屏幕的状态来进行一个刷新的动画,所以我们还需要处理触摸事件,判断手指在屏幕中的状态。

首先是 onInterceptTouchEvent,返回 true 表示拦截触摸事件。

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
  ensureTarget();

  final int action = MotionEventCompat.getActionMasked(ev);

  // 手指按下时恢复状态
  if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
    mReturningToStart = false;
  }

  // 控件可用 || 刷新事件刚结束正在恢复初始状态时 || 子 View 可滚动 || 正在刷新 || 父 View 正在滚动
  if (!isEnabled() || mReturningToStart || canChildScrollUp()
      || mRefreshing || mNestedScrollInProgress) {
    // Fail fast if we're not in a state where a swipe is possible
    return false;
  }

  switch (action) {
    case MotionEvent.ACTION_DOWN:
      setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop(), true);
      mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
      mIsBeingDragged = false;
      // 记录手指按下的位置,为了判断是否开始滚动
      final float initialDownY = getMotionEventY(ev, mActivePointerId);
      if (initialDownY == -1) {
        return false;
      }
      mInitialDownY = initialDownY;
      break;

    case MotionEvent.ACTION_MOVE:
      if (mActivePointerId == INVALID_POINTER) {
        Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
        return false;
      }

      final float y = getMotionEventY(ev, mActivePointerId);
      if (y == -1) {
        return false;
      }
      // 判断当拖动距离大于最小距离时设置 mIsBeingDragged = true;
      final float yDiff = y - mInitialDownY;
      if (yDiff > mTouchSlop && !mIsBeingDragged) {
        mInitialMotionY = mInitialDownY + mTouchSlop;
        mIsBeingDragged = true;
        // 正在拖动状态,更新圆圈的 progressbar 的 alpha 值
        mProgress.setAlpha(STARTING_PROGRESS_ALPHA);
      }
      break;

    case MotionEventCompat.ACTION_POINTER_UP:
      onSecondaryPointerUp(ev);
      break;

    case MotionEvent.ACTION_UP:
    case MotionEvent.ACTION_CANCEL:
      mIsBeingDragged = false;
      mActivePointerId = INVALID_POINTER;
      break;
  }

  return mIsBeingDragged;
}

可以看到源码也就是进行简单处理,DOWN 的时候记录一下位置,MOVE 时判断移动的距离,返回值 mIsBeingDragged 为 true 时, 即 onInterceptTouchEvent 返回 true,SwipeRefreshLayout 拦截触摸事件,不分发给 mTarget,然后把 MotionEvent 传给 onTouchEvent 方法。其中有一个判断子 View 的是否还可以滚动的方法 canChildScrollUp

/**
 * @return Whether it is possible for the child view of this layout to
 *     scroll up. Override this if the child view is a custom view.
 */
public boolean canChildScrollUp() {
  if (android.os.Build.VERSION.SDK_INT < 14) {
    // 判断 AbsListView 的子类 ListView 或者 GridView 等
    if (mTarget instanceof AbsListView) {
      final AbsListView absListView = (AbsListView) mTarget;
      return absListView.getChildCount() > 0
          && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
              .getTop() < absListView.getPaddingTop());
    } else {
      return ViewCompat.canScrollVertically(mTarget, -1) || mTarget.getScrollY() > 0;
    }
  } else {
    return ViewCompat.canScrollVertically(mTarget, -1);
  }
}

当 SwipeRefreshLayout 拦截了触摸事件之后( mIsBeingDragged 为 true ),将 MotionEvent 交给 onTouchEvent 处理。

@Override
public boolean onTouchEvent(MotionEvent ev) {

  // ... 省略代码
  switch (action) {
    case MotionEvent.ACTION_DOWN:
      // 获取第一个按下的手指
      mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
      mIsBeingDragged = false;
      break;

    case MotionEvent.ACTION_MOVE: {
      // 处理多指触控
      pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId);

      // ... 省略代码

      final float y = MotionEventCompat.getY(ev, pointerIndex);
      final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
      if (mIsBeingDragged) {
        if (overscrollTop > 0) {
          // 正在拖动状态,更新圆圈的位置
          moveSpinner(overscrollTop);
        } else {
          return false;
        }
      }
      break;
    }

    // ... 省略代码
    case MotionEvent.ACTION_UP: {
      pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
      if (pointerIndex < 0) {
        Log.e(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id.");
        return false;
      }

      final float y = MotionEventCompat.getY(ev, pointerIndex);
      final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
      mIsBeingDragged = false;
      // 手指松开,将圆圈移动到正确的位置
      finishSpinner(overscrollTop);
      mActivePointerId = INVALID_POINTER;
      return false;
    }
    // ... 省略代码
  }

  return true;
}

在手指滚动过程中通过判断 mIsBeingDragged 来移动刷新的圆圈(对应的是 moveSpinner ),手指松开将圆圈移动到正确位置(初始位置或者刷新动画的位置,对应的是 finishSpinner 方法)。

// 手指下拉过程中触发的圆圈的变化过程,透明度变化,渐渐出现箭头,大小的变化
private void moveSpinner(float overscrollTop) {

  // 设置为有箭头的 progress
  mProgress.showArrow(true);

  // 进度转化成百分比
  float originalDragPercent = overscrollTop / mTotalDragDistance;

  // 避免百分比超过 100%
  float dragPercent = Math.min(1f, Math.abs(originalDragPercent));
  // 调整拖动百分比,造成视差效果
  float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3;
  //
  float extraOS = Math.abs(overscrollTop) - mTotalDragDistance;

  // 这里 mUsingCustomStart 为 true 代表用户自定义了起始出现的坐标
  float slingshotDist = mUsingCustomStart ? mSpinnerFinalOffset - mOriginalOffsetTop
      : mSpinnerFinalOffset;

  // 弹性系数
  float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2)
      / slingshotDist);
  float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow(
      (tensionSlingshotPercent / 4), 2)) * 2f;
  float extraMove = (slingshotDist) * tensionPercent * 2;

  // 因为有弹性系数,不同的手指滚动距离不同于 view 的移动距离
  int targetY = mOriginalOffsetTop + (int) ((slingshotDist * dragPercent) + extraMove);

  // where 1.0f is a full circle
  if (mCircleView.getVisibility() != View.VISIBLE) {
    mCircleView.setVisibility(View.VISIBLE);
  }
  // 设置的是否有缩放
  if (!mScale) {
    ViewCompat.setScaleX(mCircleView, 1f);
    ViewCompat.setScaleY(mCircleView, 1f);
  }
  // 设置缩放进度
  if (mScale) {
    setAnimationProgress(Math.min(1f, overscrollTop / mTotalDragDistance));
  }
  // 移动距离未达到最大距离
  if (overscrollTop < mTotalDragDistance) {
    if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA
        && !isAnimationRunning(mAlphaStartAnimation)) {
      // Animate the alpha
      startProgressAlphaStartAnimation();
    }
  } else {
    if (mProgress.getAlpha() < MAX_ALPHA && !isAnimationRunning(mAlphaMaxAnimation)) {
      // Animate the alpha
      startProgressAlphaMaxAnimation();
    }
  }
  // 出现的进度,裁剪 mProgress
  float strokeStart = adjustedPercent * .8f;
  mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart));
  mProgress.setArrowScale(Math.min(1f, adjustedPercent));

  // 旋转
  float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f;
  mProgress.setProgressRotation(rotation);
  setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop, true /* requires update */);
}

刷新圆圈的移动过程也是有好几种状态,看上面的注释基本上就比较清楚了。

private void finishSpinner(float overscrollTop) {
  if (overscrollTop > mTotalDragDistance) {
    //移动距离超过了刷新的临界值,触发刷新动画
    setRefreshing(true, true /* notify */);
  } else {
    // 取消刷新的圆圈,将圆圈移动到初始位置
    mRefreshing = false;
    mProgress.setStartEndTrim(0f, 0f);
    // ...省略代码

    // 移动到初始位置
    animateOffsetToStartPosition(mCurrentTargetOffsetTop, listener);
    // 设置没有箭头
    mProgress.showArrow(false)
  }
}

可以看到调用 setRefresh(true,true) 方法触发刷新动画并进行回调,但是这个方法是 private 的。前面提到我们自己调用 setRefresh(true) 只能产生动画,而不能回调刷新函数,那么我们就可以用反射调用 2 个参数的 setRefresh 函数。 或者手动调 setRefreshing(true)+ OnRefreshListener.onRefresh 方法。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文