【问题标题】:HorizontalScrollView inside SwipeRefreshLayoutSwipeRefreshLayout 内的 Horizo​​ntalScrollView
【发布时间】:2026-01-06 12:55:02
【问题描述】:

我在我的应用程序中实现了新的SwipeRefreshLayout 组件,它适用于任何垂直视图,例如ListViewGridViewScrollView

它在水平视图中表现得非常糟糕,例如HorizontalScrollView。 当向右或向左滚动时,SwipeRefreshLayout 视图缓存触摸,阻止 HorizontalScrollView 接收并开始垂直滚动以执行刷新。

我尝试解决这个问题,因为我之前使用requestDisallowInterceptTouchEvent 解决了垂直ScrollViewViewPager 内部的问题,但它不起作用。我还注意到这个方法在原始的SwipeRefreshLayout 类中被覆盖,而没有返回超值。谷歌的开发者留下了评论“//Nope.”:)

因为SwipeRefreshLayout 组件相对较新,我找不到解决水平滚动问题同时仍允许滑动刷新视图以跟踪和处理垂直滚动的解决方案,所以我想我会带着希望分享我的解决方案它会节省某人一两个小时。

【问题讨论】:

  • 这个问题似乎在 1.2.0-alpha01 中得到修复

标签: android horizontalscrollview swiperefreshlayout


【解决方案1】:

这是我所做的:

class HorizontalScrollViewWithDragListener
    @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : HorizontalScrollView(context, attrs) {
    var draggingState: Boolean = false
        set(value) {
            if (field != value) {
                field = value
                listener?.invoke(value)
            }
        }

    var listener: ((draggingState: Boolean) -> Unit)? = null

    override fun onInterceptTouchEvent(ev: MotionEvent) =
        super.onInterceptTouchEvent(ev)
            .also { draggingState = it }


    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(ev: MotionEvent) =
        super.onTouchEvent(ev)
            .also {
                if(ev.action == MotionEvent.ACTION_UP) {
                    draggingState = false
                }
            }
}

然后我只是在设置代码中执行此操作:

   myScrollView.listener = { refreshView.isEnabled = !it }

【讨论】:

    【解决方案2】:

    Lior Iluz 提出的覆盖 onInterceptTouchEvent() 的解决方案存在严重问题。 如果内容可滚动容器未完全向上滚动,则可能无法在相同的向上滚动手势中激活滑动刷新。 实际上,当您开始滚动内部容器并无意中将手指水平移动到 mTouchSlop 时(默认为 8dp), 建议的 CustomSwipeToRefresh 拒绝此手势。因此,用户必须再次尝试开始刷新。这对用户来说可能看起来很奇怪。

    我从支持库中提取了原始 SwipeRefreshLayout 的源代码到我的项目中,并重新编写了 onInterceptTouchEvent()。新的类名为 TouchSafeSwipeRefreshLayout

    private boolean mPendingActionDown;
    private float mInitialDownY;
    private float mInitialDownX;
    private boolean mGestureDeclined;
    
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        ensureTarget();
        final int action = ev.getActionMasked();
        int pointerIndex;
    
        if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
            mReturningToStart = false;
        }
    
        if (!isEnabled() || mReturningToStart || mRefreshing ) {
            // Fail fast if we're not in a state where a swipe is possible
            if (D) Log.e(LOG_TAG, "Fail because of not enabled OR refreshing OR returning to start. "+motionEventToShortText(ev));
            return false;
        }
    
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop());
                mActivePointerId = ev.getPointerId(0);
    
                if ((pointerIndex = ev.findPointerIndex(mActivePointerId)) >= 0) {
    
                    if (mNestedScrollInProgress || canChildScrollUp()) {
                        if (D) Log.e(LOG_TAG, "Fail because of nested content is Scrolling. Set pending DOWN=true. "+motionEventToShortText(ev));
                        mPendingActionDown = true;
                    } else {
                        mInitialDownX = ev.getX(pointerIndex);
                        mInitialDownY = ev.getY(pointerIndex);
                    }
                }
                return false;
    
            case MotionEvent.ACTION_MOVE:
                if (mActivePointerId == INVALID_POINTER) {
                    if (D) Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
                    return false;
                } else if (mGestureDeclined) {
                    if (D) Log.e(LOG_TAG, "Gesture was declined previously because of horizontal swipe");
                    return false;
                } else if ((pointerIndex = ev.findPointerIndex(mActivePointerId)) < 0) {
                    return false;
                } else if (mNestedScrollInProgress || canChildScrollUp()) {
                    if (D) Log.e(LOG_TAG, "Fail because of nested content is Scrolling. "+motionEventToShortText(ev));
                    return false;
                } else if (mPendingActionDown) {
                    // This is the 1-st Move after content stops scrolling.
                    // Consider this Move as Down (a start of new gesture)
                    if (D) Log.e(LOG_TAG, "Consider this move as down - setup initial X/Y."+motionEventToShortText(ev));
                    mPendingActionDown = false;
                    mInitialDownX = ev.getX(pointerIndex);
                    mInitialDownY = ev.getY(pointerIndex);
                    return false;
                } else if (Math.abs(ev.getX(pointerIndex) - mInitialDownX) > mTouchSlop) {
                    mGestureDeclined = true;
                    if (D) Log.e(LOG_TAG, "Decline gesture because of horizontal swipe");
                    return false;
                }
    
                final float y = ev.getY(pointerIndex);
                startDragging(y);
                if (!mIsBeingDragged) {
                    if (D) Log.d(LOG_TAG, "Waiting for dY to start dragging. "+motionEventToShortText(ev));
                } else {
                    if (D) Log.d(LOG_TAG, "Dragging started! "+motionEventToShortText(ev));
                }
                break;
    
            case MotionEvent.ACTION_POINTER_UP:
                onSecondaryPointerUp(ev);
                break;
    
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                mIsBeingDragged = false;
                mGestureDeclined = false;
                mPendingActionDown = false;
                mActivePointerId = INVALID_POINTER;
                break;
        }
    
        return mIsBeingDragged;
    }
    

    Github 上查看我的示例项目。

    【讨论】:

      【解决方案3】:

      我通过扩展SwipeRefreshLayout 并覆盖它的onInterceptTouchEvent 解决了这个问题。在里面,我计算用户游走的 X 距离是否大于触摸斜率。如果是这样,则意味着用户正在水平滑动,因此我返回false,它让子视图(在本例中为HorizontalScrollView)获取触摸事件。

      
      public class CustomSwipeToRefresh extends SwipeRefreshLayout {
      
          private int mTouchSlop;
          private float mPrevX;
      
          public CustomSwipeToRefresh(Context context, AttributeSet attrs) {
              super(context, attrs);
      
              mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
          }
      
          @Override
          public boolean onInterceptTouchEvent(MotionEvent event) {
      
              switch (event.getAction()) {
                  case MotionEvent.ACTION_DOWN:
                      mPrevX = MotionEvent.obtain(event).getX();
                      break;
      
                  case MotionEvent.ACTION_MOVE:
                      final float eventX = event.getX();
                      float xDiff = Math.abs(eventX - mPrevX);
      
                      if (xDiff > mTouchSlop) {
                          return false;
                      }
              }
      
              return super.onInterceptTouchEvent(event);
          }
      }
      

      【讨论】:

      • 多么美妙的解决方案!你救了我一天!!谢谢你:)
      • 太棒了。感谢您提供出色的解决方案!我可能永远也想不通。您只需将自定义小部件放置在 xml 文件中的原始滑动刷新布局位置,它就可以工作了!这可能应该修补到 Android 源代码中!
      • 谢谢你救了我的一天
      • 为什么不直接使用 mPrevX = event.getX();而不是 mPrevX = MotionEvent.obtain(event).getX(); ? MotionEvent.obtain(event) 将创建一个需要回收的运动事件,除非会导致内存泄漏。
      • 不...我拿走了你的代码,删除了额外的 MotionEvent.obtain() 调用,它没有做任何事情 + 导致警告可能存在内存泄漏。然后我转换为 kotlin 并添加了额外的代码来检测是否实际到达了 recyclerview 的顶部。因为使用您当前的代码,这会导致一个错误,即第一次滚动到 recyclerview 的底部会导致 pull to refresh 意外触发(再次向上滚动列表之后)。
      【解决方案4】:

      如果你使用 Tim Roes EnhancedListView

      看到这个issues。我对我很有用,因为他们添加了一个功能,可以检测何时开始滑动以及何时结束。

      当滑动开始时,我禁用 SwipeRefreshLayout,当滑动完成时,我启用 swipeRefreshLayout。

      【讨论】:

        【解决方案5】:

        如果您没有记住您已经拒绝 ACTION_MOVE 事件的事实,那么如果用户回到您最初的 mPrevX 附近,您最终会接受它。

        只需添加一个布尔值即可记住它。

        public class CustomSwipeToRefresh extends SwipeRefreshLayout {
        
            private int mTouchSlop;
            private float mPrevX;
            // Indicate if we've already declined the move event
            private boolean mDeclined;
        
            public CustomSwipeToRefresh(Context context, AttributeSet attrs) {
                super(context, attrs);
        
                mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
            }
        
            @Override
            public boolean onInterceptTouchEvent(MotionEvent event) {
        
                switch (event.getAction()) {
                    case MotionEvent.ACTION_DOWN:
                        mPrevX = MotionEvent.obtain(event).getX();
                        mDeclined = false; // New action
                        break;
        
                    case MotionEvent.ACTION_MOVE:
                        final float eventX = event.getX();
                        float xDiff = Math.abs(eventX - mPrevX);
        
                        if (mDeclined || xDiff > mTouchSlop) {
                            mDeclined = true; // Memorize
                            return false;
                        }
                }
        
                return super.onInterceptTouchEvent(event);
            }
        }
        

        【讨论】:

        • 如何产生你描述的问题?我对@LiorIluz 的回答没有意见。
        • 当您开始水平拖动视图并返回起点时会发生这种情况。当你回来时,你会赶上这个事件。一个简单的复现方法是向左拖动,向下拖动,向右拖动。
        • 确实如此。感谢您的解释,我复制了它。你的解决方案奏效了。不错的收获;)
        • 为什么不直接使用 mPrevX = event.getX();而不是 mPrevX = MotionEvent.obtain(event).getX(); ? MotionEvent.obtain(event) 将创建一个需要回收的运动事件,除非会导致内存泄漏。
        • 你如何在这一行中使用这个类mySwipeRefreshLayout = (SwipeRefreshLayout)this.findViewById(R.id.swipeContainer); 如果我用 CustomSwipeToRefresh 替换 SwipeRefreshLayout 它会崩溃。