SwipeRefreshLayout内部嵌套HorizontalScrollView

58

我在我的应用程序中实现了新的SwipeRefreshLayout组件,它可以很好地与任何垂直视图一起使用,例如ListViewGridViewScrollView

但是,与水平视图(如HorizontalScrollView)一起使用时会表现得非常糟糕。当向左或向右滚动时,SwipeRefreshLayout视图会缓存触摸事件,阻止HorizontalScrollView接收事件并开始垂直滚动以执行刷新操作。

我尝试解决这个问题,就像之前解决带有ViewPager的垂直ScrollView的问题一样,使用requestDisallowInterceptTouchEvent方法,但这并没有起作用。我还注意到,在原始的SwipeRefreshLayout类中,这个方法被覆盖而没有返回super。Google的开发人员留下了一个注释 "//Nope." :)

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


这个问题似乎在_1.2.0-alpha01_中已经修复。 - Muntashir Akon
5个回答

168

我通过扩展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);
    }
}

8
太棒了!感谢您提供了一个出色的解决方案!我可能永远也想不到这个。您只需要在 XML 文件中将自定义小部件放置在原始刷新布局的位置,它就能工作了!这应该被修补到Android源代码中! - Meanman
我遇到了类似的问题。我正在使用嵌套在SwipeRefreshLayout中的com.baoyz.swipemenulistview.SwipeMenuListView,如果我向左滑动,它会纯粹地显示我的列表项“删除”按钮。但是,如果我的左滑稍微向下倾斜,下拉刷新布局就会捕获手势,并且不会完全调用向左滑动的监听器。通过您的自定义实现,一切都可以正常工作! - Shiprack
1
为什么不直接使用 mPrevX = event.getX();而不是 mPrevX = MotionEvent.obtain(event).getX();MotionEvent.obtain(event)将创建一个需要回收的运动事件,否则会导致内存泄漏。 - Viky Leaf
你错过了一个构造函数 super(context, attrsSet)。 - Denis Makovsky
1
不...我拿了你的代码,删掉了一个没有任何作用并引起可能存在内存泄漏警告的额外MotionEvent.obtain()调用。然后我将其转换为Kotlin,并添加了额外的代码来检测是否实际到达了RecyclerView的顶部。因为使用你当前的代码会导致一个错误,即首先滚动到RecyclerView底部会意外触发下拉刷新(在再次向上滚动列表后)。 - Edward van Raak
显示剩余2条评论

28
如果您没有记住已经拒绝了 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的答案没有任何问题。 - shkschneider
3
当你开始水平拖动视图并回到起点时,这种情况就会发生。当你回到起点时,你会捕捉到事件。一个简单的复现方法是向左拖动,向下拖动和向右拖动。 - Twibit
确实。谢谢你的解释,我已经复现了它。你的解决方案有效。干得好 ;) - shkschneider
3
为什么不直接使用 mPrevX = event.getX(); 而是要用 MotionEvent.obtain(event).getX(); ?使用 MotionEvent.obtain(event) 会创建一个需要被回收的运动事件,否则将会导致内存泄漏。 - Viky Leaf
你如何在这一行中使用这个类:mySwipeRefreshLayout = (SwipeRefreshLayout)this.findViewById(R.id.swipeContainer);如果我将 SwipeRefreshLayout 替换为 CustomSwipeToRefresh,它会崩溃。 - Sam Tyurenkov

1
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


0
如果您正在使用Tim Roes的EnhancedListView,可以查看issues。这对我非常有用,因为他们添加了一个检测滑动开始和结束的功能。
当滑动开始时,我会禁用SwipeRefreshLayout,当滑动结束时,我会启用SwipeRefreshLayout。

0

以下是我所做的:

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 }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接