【Android】从事件分发开始:原理解析如何解决滑动冲突

news2024/12/27 7:12:33

【Android】从事件分发开始:原理解析如何解决滑动冲突

文章目录

  • 【Android】从事件分发开始:原理解析如何解决滑动冲突
    • Activity层级结构
      • 浅析Activity的setContentView源码
      • 浅析AppCompatActivity的setContentView源码
    • 触控三分显纷争,滑动冲突待分明
    • 触控起源定归途
      • 事件分发机制
      • 点击事件分发的传递规则
      • 产生滑动冲突的原因
    • 拦截消冲理滑争
          • **`ACTION_DOWN` 事件的处理**
          • **`ACTION_MOVE` 事件的处理**
          • **`ACTION_CANCEL` 事件的触发**
          • **事件传递过程的重置**
      • EasyTry嵌套,强制拦截
      • NestedScrollableHost嵌套,选择性拦截
    • 结语

参考:

《Android进阶之光》

【Android】图解View事件分发机制_android view 事件分发-CSDN博客

Activity层级结构

要知道Activity的层级结构,首先我们来看看Activity中总是出现的setContentView方法是干啥的。

浅析Activity的setContentView源码

点进Activity的setContentView。

public void setContentView(@LayoutRes int layoutResID) {
    getWindow().setContentView(layoutResID);
    initWindowDecorActionBar();
}

这里调用了 getWindow().setContentView(layoutResID)。getWindow() 会得到什么呢?接着往下看,getWindow() 返回 mWindow:

public Window getWindow() {
    return mWindow;
}

那么,这里的 mWindow 又是什么呢?我们继续查看代码,最终在 Activity 的 attach 方法中发现了 mWindow。

final void attach(Context context, ActivityThread aThread,
    Instrumentation instr, IBinder token, int ident,
    Application application, Intent intent, ActivityInfo info,
    CharSequence title, Activity parent, String id,
    NonConfigurationInstances lastNonConfigurationInstances,
    Configuration config, String referrer, VoiceInteractor voiceInteractor) {
    attachBaseContext(context);
    fragments.attach(null /* opaque */);
    mWindow = new PhoneWindow(this);
    ...
}

由此可见,getWindow() 返回的是 PhoneWindow。下面来看看 PhoneWindow 的 setContentView 方法,代码如下所示:

@Override
public void setContentView(View view, ViewGroup.LayoutParams params) {
    if (mContentParent == null) {
        installDecor();
    } else if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
        mContentParent.removeAllViews();
    }
    if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
        view.setLayoutParams(params);
        final Scene newScene = new Scene(mContentParent, view);
        transitionTo(newScene);
    } else {
        mContentParent.addView(view, params);
    }
}
final Callback cb = getCallback();
if (cb != null && !isDestroyed()) {
    cb.onContentChanged();
}

原来 mWindow 指的就是 PhoneWindow,而 PhoneWindow 继承自抽象类 Window,这样就知道了getWindow() 得到的是一个 PhoneWindow,因为 Activity 中 setContentView 方法调用的是getWindow().setContentView(layoutResID)。

我们接着往下看,看看上面代码注释 1 处 installDecor 方法里面做了什么,代码如下所示:

private void installDecor() {
    if (mDecor == null) {
        mDecor = generateLayout();
    }
}

在前面的代码中没发现什么,紧接着查看上面代码注释 1 处的 generateDecor 方法里做了什么:

protected DecorView generateDecor() {
    return new DecorView(getContext(), -1);
}

这里创建了一个 DecorView。这个 DecorView 就是 Activity 中的根 View。接着查看 DecorView 的源码,发现 DecorView 是 PhoneWindow 类的内部类,并且继承了 FrameLayout。我们再回到 installDecor 方法中,查看 generateLayout(mDecor) 做了什么:

protected ViewGroup generateLayout(DecorView decor) {
    //根据不同的情况,给 layoutResource 加载不同的布局
    int layoutResource;
    int features = getLocalFeatures();
    if ((features & (1 << FEATURE_SWIPE_TO_DISMISS)) != 0) {
        layoutResource = R.layout.screen_swipe_dismiss;
    } else if ((features & ((1 << FEATURE_LEFT_ICON) | (1 << FEATURE_RIGHT_ICON))) != 0) {
        if (isFloating()) {
            TypedValue res = new TypedValue();
            getContext().getTheme().resolveAttribute(
                R.attr.dialogTitleDecorLayout, res, true);
            layoutResource = res.resourceId;
        } else {
            layoutResource = R.layout.screen_title_icons;
        }
        removeFeatures(FEATURE_LEFT_ICON);
        removeFeatures(FEATURE_RIGHT_ICON);
    } else if ((features & (1 << FEATURE_ACTION_BAR)) != 0
            && (features & (1 << FEATURE_ACTION_BAR_OVERLAY)) == 0) {
        layoutResource = R.layout.screen_action_bar;
    } else if ((features & (1 << FEATURE_NO_TITLE)) == 0) {
        if (isFloating()) {
            TypedValue res = new TypedValue();
            getContext().getTheme().resolveAttribute(
                R.attr.dialogTitleDecorLayout, res, true);
            layoutResource = res.resourceId;
        } else {
            layoutResource = R.layout.screen_title;
        }
        removeFeatures(FEATURE_ACTION_BAR);
    } else if ((features & (1 << FEATURE_ACTION_BAR)) != 0) {
        layoutResource = R.styleable.Window_windowActionBarFullscreenDecorLayout,
            R.layout.screen_action_bar;
    } else if ((features & (1 << FEATURE_NO_TITLE)) == 0) {
        layoutResource = R.layout.screen_simple;
    } else {
        layoutResource = R.layout.screen_simple;
    }
    return contentParent;
}

PhoneWindow 的 generateLayout 方法比较长,这里只截取了一小部分关键的代码,其主要内容就是,根据不同的情况给 layoutResource 加载不同的布局。现在查看上面代码注释 1 处的布局 R.layout.screen_title,这个文件在 frameworks 源码中,它的代码如下所示:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:fitsSystemWindows="true">
    <!Popup bar for action modes -->
    <ViewStub android:id="@+id/action_mode_bar_stub"
        android:inflatedLayout="@layout/action_mode_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        style="?android:attr/windowTitleSizeStyle">
        <TextView android:id="@android:id/title"
            style="?android:attr/windowTitleStyle"
            android:background="?null"
            android:fadingEdge="horizontal" />
    </FrameLayout>
</LinearLayout>

上面的 ViewStub 是用来显示 ActionMode 的。下面的两个 FrameLayout:一个是 title,用来显示标题;另一个是 content,用来显示内容。看到上面的源码,大家就知道了一个 Activity 包含一个 Window 对象,该对象是由 PhoneWindow 实现的。PhoneWindow 将 DecorView 作为整个应用窗口的根 View,这个 DecorView 又将屏幕分为两个区域:一个区域是 TitleView,另一个区域是 ContentView,而我们平常做应用所写的布局正是显示在 ContentView 中的,如下图。

image-20241201171333377

浅析AppCompatActivity的setContentView源码

我们在开发过程中用的基本都是AppCompatActivity,那么让我们看看AppCompatActivity的setContentView方法跟Activity的setContentView方法有什么不同。

首先来回忆以下Activity的setContentView方法中有两个很重要的东西:mDecor和mContentParent。前者是整个界面的根部局,后者是加载我们自定义布局的容器。其实AppCompatActivity的布局的层级跟Activity基本是一样的,只是在mContentParent中又多了一层布局而已。

话不多说,点开AppCompatActivity的setContentView方法:

@Override
public void setContentView(@LayoutRes int layoutResID) {
    getDelegate().setContentView(layoutResID);
}
    
@NonNull
public AppCompatDelegate getDelegate() {
   	if (mDelegate == null) {
        mDelegate = AppCompatDelegate.create(this, this);
    }
    return mDelegate;
}

public static AppCompatDelegate create(Activity activity, AppCompatCallback callback) {
        return new AppCompatDelegateImpl(activity, activity.getWindow(), callback);
}

可以看到,里面是调用了一个委托对象的setContentView方法,这个对象就是AppCompatDelegateImpl,那么我们找到AppCompatDelegateImpl的setContentView方法:

@Override
public void setContentView(int resId) {
    ensureSubDecor();
    ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
    contentParent.removeAllViews();
    LayoutInflater.from(mContext).inflate(resId, contentParent);
    mOriginalWindowCallback.onContentChanged();
}

明白了Activity布局加载的过程我们可以猜到,方法中第二行获取到的contentParent的功能就相当于Activity中mContentParent,我们自定义的布局就是加载到这个contentParent中的,接下来的代码LayoutInflater.from(mContext).inflate(resId, contentParent)马上就证实了我们的判断。

那么第一行的ensureSubDecor()是干啥的呢?在开篇的时候说过,AppCompatActivity的布局的层级跟Activity基本是一样的,只是在mContentParent中又多了一层布局。这里提前给出结论:mContentParent中又多的那一层布局就是mSubDecor,而contentParent又是在mSubDecor下面的一个子布局。

点进ensureSubDecor方法:

private void ensureSubDecor() {
    if (!mSubDecorInstalled) {
        mSubDecor = createSubDecor();
        ……
    }

可以看到通过createSubDecor方法创建mSubDecor。

private ViewGroup createSubDecor() {
        TypedArray a = mContext.obtainStyledAttributes(R.styleable.AppCompatTheme);

        if (!a.hasValue(R.styleable.AppCompatTheme_windowActionBar)) {
            a.recycle();
            throw new IllegalStateException(
                    "You need to use a Theme.AppCompat theme (or descendant) with this activity.");
        }

        if (a.getBoolean(R.styleable.AppCompatTheme_windowNoTitle, false)) {
            requestWindowFeature(Window.FEATURE_NO_TITLE);
        } else if (a.getBoolean(R.styleable.AppCompatTheme_windowActionBar, false)) {
            // Don't allow an action bar if there is no title.
            requestWindowFeature(FEATURE_SUPPORT_ACTION_BAR);
        }
        if (a.getBoolean(R.styleable.AppCompatTheme_windowActionBarOverlay, false)) {
            requestWindowFeature(FEATURE_SUPPORT_ACTION_BAR_OVERLAY);
        }
        if (a.getBoolean(R.styleable.AppCompatTheme_windowActionModeOverlay, false)) {
            requestWindowFeature(FEATURE_ACTION_MODE_OVERLAY);
        }
        mIsFloating = a.getBoolean(R.styleable.AppCompatTheme_android_windowIsFloating, false);
        a.recycle();

        // Now let's make sure that the Window has installed its decor by retrieving it
        ensureWindow();
        
        //注释1
        mWindow.getDecorView();

        final LayoutInflater inflater = LayoutInflater.from(mContext);
        ViewGroup subDecor = null;
        
        //根据设置给subDecor加载不同的布局
        if (!mWindowNoTitle) {
            if (mIsFloating) {
                // If we're floating, inflate the dialog title decor
                subDecor = (ViewGroup) inflater.inflate(
                        R.layout.abc_dialog_title_material, null);

                // Floating windows can never have an action bar, reset the flags
                mHasActionBar = mOverlayActionBar = false;
            } else if (mHasActionBar) {
                /**
                 * This needs some explanation. As we can not use the android:theme attribute
                 * pre-L, we emulate it by manually creating a LayoutInflater using a
                 * ContextThemeWrapper pointing to actionBarTheme.
                 */
                TypedValue outValue = new TypedValue();
                mContext.getTheme().resolveAttribute(R.attr.actionBarTheme, outValue, true);

                Context themedContext;
                if (outValue.resourceId != 0) {
                    themedContext = new ContextThemeWrapper(mContext, outValue.resourceId);
                } else {
                    themedContext = mContext;
                }

                // Now inflate the view using the themed context and set it as the content view
                subDecor = (ViewGroup) LayoutInflater.from(themedContext)
                        .inflate(R.layout.abc_screen_toolbar, null);

                mDecorContentParent = (DecorContentParent) subDecor
                        .findViewById(R.id.decor_content_parent);
                mDecorContentParent.setWindowCallback(getWindowCallback());

                /**
                 * Propagate features to DecorContentParent
                 */
                if (mOverlayActionBar) {
                    mDecorContentParent.initFeature(FEATURE_SUPPORT_ACTION_BAR_OVERLAY);
                }
                if (mFeatureProgress) {
                    mDecorContentParent.initFeature(Window.FEATURE_PROGRESS);
                }
                if (mFeatureIndeterminateProgress) {
                    mDecorContentParent.initFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
                }
            }
        } else {
            if (mOverlayActionMode) {
                subDecor = (ViewGroup) inflater.inflate(
                        R.layout.abc_screen_simple_overlay_action_mode, null);
            } else {
                subDecor = (ViewGroup) inflater.inflate(R.layout.abc_screen_simple, null);
            }

            if (Build.VERSION.SDK_INT >= 21) {
                // If we're running on L or above, we can rely on ViewCompat's
                // setOnApplyWindowInsetsListener
                ViewCompat.setOnApplyWindowInsetsListener(subDecor,
                        new OnApplyWindowInsetsListener() {
                            @Override
                            public WindowInsetsCompat onApplyWindowInsets(View v,
                                    WindowInsetsCompat insets) {
                                final int top = insets.getSystemWindowInsetTop();
                                final int newTop = updateStatusGuard(top);

                                if (top != newTop) {
                                    insets = insets.replaceSystemWindowInsets(
                                            insets.getSystemWindowInsetLeft(),
                                            newTop,
                                            insets.getSystemWindowInsetRight(),
                                            insets.getSystemWindowInsetBottom());
                                }

                                // Now apply the insets on our view
                                return ViewCompat.onApplyWindowInsets(v, insets);
                            }
                        });
            } else {
                // Else, we need to use our own FitWindowsViewGroup handling
                ((FitWindowsViewGroup) subDecor).setOnFitSystemWindowsListener(
                        new FitWindowsViewGroup.OnFitSystemWindowsListener() {
                            @Override
                            public void onFitSystemWindows(Rect insets) {
                                insets.top = updateStatusGuard(insets.top);
                            }
                        });
            }
        }

        if (subDecor == null) {
            throw new IllegalArgumentException(
                    "AppCompat does not support the current theme features: { "
                            + "windowActionBar: " + mHasActionBar
                            + ", windowActionBarOverlay: "+ mOverlayActionBar
                            + ", android:windowIsFloating: " + mIsFloating
                            + ", windowActionModeOverlay: " + mOverlayActionMode
                            + ", windowNoTitle: " + mWindowNoTitle
                            + " }");
        }

        if (mDecorContentParent == null) {
            mTitleView = (TextView) subDecor.findViewById(R.id.title);
        }

        // Make the decor optionally fit system windows, like the window's decor
        ViewUtils.makeOptionalFitsSystemWindows(subDecor);

        //注释2
        final ContentFrameLayout contentView = (ContentFrameLayout) subDecor.findViewById(
                R.id.action_bar_activity_content);
        final ViewGroup windowContentView = (ViewGroup) mWindow.findViewById(android.R.id.content);
        if (windowContentView != null) {
            // There might be Views already added to the Window's content view so we need to
            // migrate them to our content view
            while (windowContentView.getChildCount() > 0) {
                final View child = windowContentView.getChildAt(0);
                windowContentView.removeViewAt(0);
                contentView.addView(child);
            }

            // Change our content FrameLayout to use the android.R.id.content id.
            // Useful for fragments.
            //清除windowContentView的id
            windowContentView.setId(View.NO_ID);
            //将contentView的id设置成android.R.id.content
            contentView.setId(android.R.id.content);

            // The decorContent may have a foreground drawable set (windowContentOverlay).
            // Remove this as we handle it ourselves
            if (windowContentView instanceof FrameLayout) {
                ((FrameLayout) windowContentView).setForeground(null);
            }
        }

        // Now set the Window's content view with the decor
        //注释3
        mWindow.setContentView(subDecor);

        contentView.setAttachListener(new ContentFrameLayout.OnAttachListener() {
            @Override
            public void onAttachedFromWindow() {}

            @Override
            public void onDetachedFromWindow() {
                dismissPopups();
            }
        });

        return subDecor;
    }

在注释1处调用了mWindow的getDecorView方法,这里的mWindow就是PhoneWindow了,在PhoneWinow中找到getDecorView方法:

    @Override
    public final View getDecorView() {
        if (mDecor == null || mForceDecorInstall) {
            installDecor();
        }
        return mDecor;
    }

可以看到里面调用了installDecor方法,在前面的文章里提过,做的事情就是初始化mDecor和mContentParent。接下来是根据设置给subDecor加载不同的布局。再接着,在注释2处,通过subDecor的findViewById(R.id.action_bar_activity_content)方法获取到了id为R.id.action_bar_activity_content的contentView,然后再通过mWindow的findViewById(android.R.id.content)方法获取到了windowContentView(对应着Activity中的mContentParent),接着通过windowContentView.setId(View.NO_ID)方法将windowContentView的id清除,之后再用contentView.setId(android.R.id.content)将contentView的id设为android.R.id.content。这样一来,contentView 就成为了Activity中的mContentParent,我们编写的的布局加载到contentView中。

最后在注释3处,调用了PhoneWindow的setContentView(subDecor)方法将创建的subDecor放到mContentParent中,但是此时的mContentParent已经不是加载我们自己编写布局的那个容器了,加载我们编写的布局的容器已经变成了subDecor中的contentView。

让我们再回到AppCompatDelegateImpl的setContentView方法:

@Override
public void setContentView(int resId) {
    ensureSubDecor();
    ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
    contentParent.removeAllViews();
    LayoutInflater.from(mContext).inflate(resId, contentParent);
    mOriginalWindowCallback.onContentChanged();
}

经过ensureSubDecor方法后,下面获取到的contentParent已经替换成了刚刚提到的contentView了,接下来通过LayoutInflater.from(mContext).inflate(resId, contentParent)将自己编写的布局加载到这个contentParent中来。

来看看整个流程:

在这里插入图片描述

AppCompatActivity布局层级如下:

在这里插入图片描述

最后总结一下Activity和AppCompatActivity的setContView方法区别:Activity的setContView直接将我们的布局渲染到mContentParent容器里面AppCompactActivity的setContView会根据不同的主题特性在mContentParent容器里面添加一个不同主题的subDecor容器,在subDecor容器里面有一个id为action_bar_activity_content的ContentFrameLayout容器(后来被替换成了R.id.content),并将我们的布局渲染到ContentFrameLayout里面(ContentFrameLayout也继承自FrameLayout)。

本部分参考:

Android开发Activity的setContentView源码分析_android activity setcontentview 源码-CSDN博客

AppCompatActivity#setContentView源码分析_appcompactactivity decorview-CSDN博客

触控三分显纷争,滑动冲突待分明

接下来进入本文的正文部分(那上面是什么?)

在之前写demo的时候,总能遇到这样的问题:

image-20241201180858241

如图所示,ViewPager2嵌套RecyclerView(由于ViewPager2底层用RecyclerView实现,故而说是双层RecyclerView嵌套也并无不可)。同时,这两层嵌套的View都是水平方向滑动的。

看似是比较常见的布局,但实际运行起来就出现了问题:

当我想滑动内层布局的时候,滑动了外层布局。我如果只想滑动内层布局,就只能用非常缓慢的速度滑动,且滑动非常滞涩。

这是什么原因呢?

上网搜索会发现这样的问题十分经典,叫滑动冲突,初遇时翻看许多相关文档,往往提及事件分发,触摸事件Event,拦截机制等,不解其意。

今天我们就从这里开始,简单说说产生滑动冲突的原因以及应该如何解决滑动冲突。

触控起源定归途

首先我们需要对事件分发机制有一定了解:

我们从手指点击屏幕的那一瞬间开始,当我们点击屏幕时,就产生了点击事件,这个事件被封装成了一个类,MotionEvent。因为当这个 MotionEvent 产生后,系统会将这个 MotionEvent 传递给 View 的层级,MotionEvent 在 View 中的传递过程就是点击事件分发。

我们从三个方法——dispatchTouchEventonInterceptTouchEventonTouchEvent开始介绍事件分发机制。

dispatchTouchEvent(MotionEvent ev) —— dispatchTouchEvent 是事件分发的入口,负责将触摸事件传递给视图的层级。所有的事件传递都会经过这个方法。

onInterceptTouchEvent(MotionEvent ev) —— 用来进行事件的拦截,在 dispatchTouchEvent 方法中调用,需要注意的是 View 没有提供该方法。

onTouchEvent(MotionEvent ev) —— 用来处理点击事件,在 dispatchTouchEvent 方法中进行调用。

放一张流程图在这里:这张图完美诠释了事件分发过程中的向上和向下传递。

在这里插入图片描述

事件分发机制

ActivitydispatchTouchEvent 方法

当用户点击屏幕时,系统首先会在 ActivitydispatchTouchEvent 方法中处理这个事件。

Activity 会将事件交给其内部的 PhoneWindow 进行处理。具体来说,PhoneWindowActivity 的一个成员,负责处理窗口相关的事件。

PhoneWindow 的事件处理

PhoneWindow 负责将事件传递到 DecorViewDecorView 是一个包含窗口布局的特殊 ViewGroup,它是 Activity 窗口的根视图,所有 UI 元素都在它的控制下。

PhoneWindow 会调用 DecorViewdispatchTouchEvent 方法,将事件传递给 DecorView

DecorView 事件传递

DecorView 会进一步将事件传递给其子视图。如果 DecorView 是根视图,通常它的子视图就是整个布局的根 ViewGroup

DecorView 通过 dispatchTouchEvent 将事件传递给这个根 ViewGroup,并开始处理事件分发。

我们从ViewGroup的dispatchTouchEvent方法开始分析:

代码如下所示:

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    ...
    if ((actionMasked == MotionEvent.ACTION_DOWN) ||
        cancelAndClearTouchTargets(ev)) {
        resetTouchState();
    }
    if (actionMasked == MotionEvent.ACTION_DOWN) {
        final boolean isTarget = mFirstTouchTarget != null;
        final boolean disallowIntercept = (mGroupFlags & FLAGDisallowIntercept) != 0;
        if (!isTarget || disallowIntercept) {
            intercepted = onInterceptTouchEvent(ev);
            if (intercepted) {
                ev.setAction(action);
            }
        } else {
            intercepted = false;
        }
    } else {
        ...
    }
    return handled;
}

首先,系统会判断是否需要重置触摸状态或清除目标视图。接着,通过检查是否有触摸目标(mFirstTouchTarget)以及是否允许拦截(disallowIntercept 标志位),决定是否调用 onInterceptTouchEvent 来判断是否拦截当前事件。如果事件被拦截,ev.setAction(action) 会确保事件继续保持正确的状态。如果不拦截,事件会继续传递给子视图,直到被处理或消费。最后,handled 标志返回事件是否已经处理。

mFirstTouchTargetViewGroup 中的一个内部成员变量,表示当前触摸事件的第一个触摸目标视图。它的主要作用是在事件分发过程中,指示哪个视图正在处理触摸事件。

初始触摸目标:在触摸事件开始(ACTION_DOWN)时,mFirstTouchTarget 会被设置为接收到该事件的第一个视图。这个视图会处理接下来的所有触摸事件,直到事件处理完成或被中断。

接下来看看 onInterceptTouchEvent() 方法:

public boolean onInterceptTouchEvent(MotionEvent ev) {
    return false;
}

onInterceptTouchEvent() 方法默认为 false,不进行拦截。如果想要拦截事件,那么应该在自定义的 ViewGroup 中重写这个方法。

接着来看看 dispatchTouchEvent() 方法剩余的部分源码,如下所示:

public boolean dispatchTouchEvent(MotionEvent event) {
    boolean result = false;
    if (onFilterTouchEventForSecurity(event)) {
        final int action = event.getAction();
        if (action == MotionEvent.ACTION_DOWN) {
            result = onTouchEvent(event);
        } else {
            result = onTouchEvent(event);
        }
    }
    return result;
}

如果 View 设置了点击事件监听器 OnClickListener,那么它的 onClick 方法就会被执行。

点击事件分发的传递规则

用伪代码来简单表示一下点击事件分发的这 3 个重要方法的关系:

public boolean dispatchTouchEvent(MotionEvent event) {
    boolean result = false;
    if (onFilterTouchEventForSecurity(event)) {
        boolean intercepted = onInterceptTouchEvent(event);
        if (intercepted || (action == MotionEvent.ACTION_DOWN && mFirstTouchTarget != null)) {
            result = onTouchEvent(event);
        } else {
            for (int i = 0; i < childrenCount; i++) {
                final int childIndex = getChildIndex();
                View child = getChildAt(childIndex);
                if (canViewReceivePointerEvents(child) && isTransformedTouchPointInView(x, y, child, null)) {
                    ev.setTargetAccessibilityFocus(false);
                    newTouchTarget = getTouchTarget(child);
                    if (newTouchTarget != null) {
                        if (newTouchTarget.pointerIdBits != idBitsToAssign) {
                            break;
                        }
                        resetCancelNextUpFlag(child);
                        if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                            mLastTouchDownIndex = childIndex;
                            mLastTouchDownX = ev.getX();
                            mLastTouchDownY = ev.getY();
                            newTouchTarget = addTouchTarget(child, idBitsToAssign);
                            alreadyDispatchedNewTouchTarget = true;
                            break;
                        }
                    } else {
                        mLastTouchDownIndex = childIndex;
                    }
                }
            }
        }
    }
    ev.setTargetAccessibilityFocus(false);
}

在这段代码中,我们可以看到触摸事件分发的过程,特别是如何判断哪个子视图处理触摸事件。

通过 for 循环遍历 ViewGroup 的子视图时,采用倒序遍历的方式。这意味着,系统首先从最上层的子视图(通常是最上面显示的视图)开始检查,依次检查每个子视图是否能够接收当前的触摸事件。

倒序遍历的原因通常是基于“Z轴顺序”原则,最上层的视图应该优先接收触摸事件。如果最上层的视图无法接收事件,则继续检查其他子视图,直到找到合适的视图来处理该事件。

在判断是否将事件传递给子视图时,代码会检查触摸点是否位于当前子视图的区域内(即是否在该视图的边界范围内)。如果触摸点位于子视图内部,说明该视图可以处理该事件。

另外,代码还会检查该子视图是否正在执行动画。如果子视图正在播放动画,通常会阻止事件的处理,因为动画可能正在改变视图的状态或位置,导致触摸事件的处理不一致或不准确。

如果这两个条件都不满足(即触摸点不在子视图范围内,且子视图不在播放动画),则使用 continue 语句跳过当前子视图,继续遍历下一个子视图。

接下来看 dispatchTransformedTouchEvent() 方法做了什么,代码如下所示:

private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel, View child, int desiredPointerIndex) {
    final int oldAction = event.getAction();
    if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
        event.setAction(MotionEvent.ACTION_CANCEL);
        if (child == null) {
            handled = super.dispatchTouchEvent(event);
        } else {
            handled = child.dispatchTouchEvent(event);
        }
        event.setAction(oldAction);
        return handled;
    }
    ...
}

这个方法的作用是将事件分发到视图树中的特定视图,并根据需要调整事件的坐标或其他属性(例如,经过变换的坐标或视图的缩放)。

如果有子 View,则调用子 View 的 dispatchTouchEvent() 方法。如果是 ViewGroup 没有子 View,则调用 super.dispatchTouchEvent(event) 方法。ViewGroup 是继承自 View 的。下面再来看看 View 的 dispatchTouchEvent 方法:

public boolean dispatchTouchEvent(MotionEvent event) {
    boolean result = false;
    if ((mViewFlags & ENABLED_MASK) == ENABLED
        && !(mViewFlags & CLICKABLE || mViewFlags & LONG_CLICKABLE)) {
        if (!mOnTouchListener.hasListeners()) {
            result = true;
        }
        if (result || onTouchEvent(event)) {
            result = true;
        }
    }
    ...
    return result;
}

我们看到如果 OnTouchListener 不为 null 并且 onTouch 方法返回 true,则表示事件被消费,就不会执行 onTouchEvent(event),否则就会执行 onTouchEvent(event),可以看出 OnTouchListener 中的 onTouch 方法优先级要高于 onTouchEvent 方法。

下面再来看看 onTouchEvent 方法的部分源码:

public boolean onTouchEvent(MotionEvent event) {
    final int action = event.getAction();
    if (!(viewFlags & CLICKABLE || viewFlags & LONG_CLICKABLE || viewFlags & CONTEXT_CLICKABLE)) {
        return false;
    }
    switch (action) {
        case MotionEvent.ACTION_UP:
            boolean pressed = (mPrivateFlags & PFLAG_PRESSED) != 0;
            if ((mPrivateFlags & PFLAG_PRESSED) != 0 || pressed) {
                boolean focusTaken = false;
                if (!mHasPerformedLongPress && !ignoreNextUpEvent) {
                    if (mPerformClick != null) {
                        mPerformClick.onPerformClick();
                    }
                    if (post(mPerformClick)) {
                        performClick();
                    }
                }
            }
            break;
        ...
    }
    return true;
}

从上面的代码中可以看到,只要 View 的 CLICKABLE 和 LONG_CLICKABLE 有一个为 true,那么 onTouchEvent() 就会返回 true 消耗这个事件。CLICKABLE和 LONG_CLICKABLE 代表 View 可以被点击和长按点击,这可以通过 View 的 setClickable 和 setLongClickable 方法来设置,也可以通过 View 的 XML 文件来设置。接着在 ACTION_UP 事件中会调用 performClick 方法:

public boolean performClick() {
    final boolean result;
    final ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnClickListener != null) {
        playSoundEffect(SoundEffects.CLICK);
        li.mOnClickListener.onClick(this);
        result = true;
    } else {
        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
        result = false;
    }
    return result;
}

如果 View 设置了点击事件监听器 OnClickListener,那么它的 onClick 方法就会被执行。

产生滑动冲突的原因

了解了滑动冲突的基本原理,我们现在大概清楚了,是由于在Move事件分发的时候,被外层的ViewPager2提前拦截了,而导致RecyclerView无法接收到Move事件,从而也无法处理滑动了。

但既然如此,那么按理来说,只要产生任何滑动都会被ViewPager2拦截,但为什么在缓慢滑动的时候,RecyclerView有时会接收到滑动事件从而进行滑动呢?

我们直接看看RecyclerView的拦截onInterceptTouchEvent方法,一探究竟。

@Override
    public boolean onInterceptTouchEvent(MotionEvent e) {
        if (mLayoutSuppressed) {
            // When layout is suppressed,  RV does not intercept the motion event.
            // A child view e.g. a button may still get the click.
            return false;
        }

        // Clear the active onInterceptTouchListener.  None should be set at this time, and if one
        // is, it's because some other code didn't follow the standard contract.
        mInterceptingOnItemTouchListener = null;
        if (findInterceptingOnItemTouchListener(e)) {
            cancelScroll();
            return true;
        }

        if (mLayout == null) {
            return false;
        }

        final boolean canScrollHorizontally = mLayout.canScrollHorizontally();
        final boolean canScrollVertically = mLayout.canScrollVertically();

        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(e);

        final int action = e.getActionMasked();
        final int actionIndex = e.getActionIndex();

        switch (action) {
            case MotionEvent.ACTION_DOWN:
                if (mIgnoreMotionEventTillDown) {
                    mIgnoreMotionEventTillDown = false;
                }
                mScrollPointerId = e.getPointerId(0);
                mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
                mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);

                if (stopGlowAnimations(e) || mScrollState == SCROLL_STATE_SETTLING) {
                    getParent().requestDisallowInterceptTouchEvent(true);
                    setScrollState(SCROLL_STATE_DRAGGING);
                    stopNestedScroll(TYPE_NON_TOUCH);
                }

                // Clear the nested offsets
                mNestedOffsets[0] = mNestedOffsets[1] = 0;

                int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
                if (canScrollHorizontally) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
                }
                if (canScrollVertically) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
                }
                startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
                break;

            case MotionEvent.ACTION_POINTER_DOWN:
                mScrollPointerId = e.getPointerId(actionIndex);
                mInitialTouchX = mLastTouchX = (int) (e.getX(actionIndex) + 0.5f);
                mInitialTouchY = mLastTouchY = (int) (e.getY(actionIndex) + 0.5f);
                break;

            case MotionEvent.ACTION_MOVE: {
                final int index = e.findPointerIndex(mScrollPointerId);
                if (index < 0) {
                    Log.e(TAG, "Error processing scroll; pointer index for id "
                            + mScrollPointerId + " not found. Did any MotionEvents get skipped?");
                    return false;
                }

                final int x = (int) (e.getX(index) + 0.5f);
                final int y = (int) (e.getY(index) + 0.5f);
                if (mScrollState != SCROLL_STATE_DRAGGING) {
                    final int dx = x - mInitialTouchX;
                    final int dy = y - mInitialTouchY;
                    boolean startScroll = false;
                    if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
                        mLastTouchX = x;
                        startScroll = true;
                    }
                    if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
                        mLastTouchY = y;
                        startScroll = true;
                    }
                    if (startScroll) {
                        setScrollState(SCROLL_STATE_DRAGGING);
                    }
                }
            }
            break;

            case MotionEvent.ACTION_POINTER_UP: {
                onPointerUp(e);
            }
            break;

            case MotionEvent.ACTION_UP: {
                mVelocityTracker.clear();
                stopNestedScroll(TYPE_TOUCH);
            }
            break;

            case MotionEvent.ACTION_CANCEL: {
                cancelScroll();
            }
        }
        return mScrollState == SCROLL_STATE_DRAGGING;
    }

直接截取核心部分:

case MotionEvent.ACTION_MOVE: {
                final int x = (int) (e.getX(index) + 0.5f);
                final int y = (int) (e.getY(index) + 0.5f);
                if (mScrollState != SCROLL_STATE_DRAGGING) {
                    final int dx = x - mInitialTouchX;
                    final int dy = y - mInitialTouchY;
                    boolean startScroll = false;
                    if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
                        mLastTouchX = x;
                        startScroll = true;
                    }
                    if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
                        mLastTouchY = y;
                        startScroll = true;
                    }
                    if (startScroll) {
                        setScrollState(SCROLL_STATE_DRAGGING);
                    }
                }
            }
            break;

当触摸事件为 ACTION_MOVE 事件类型时,表示用户正在滑动手指。每次用户滑动手指时都会触发这个事件。

通过 e.getX(index)e.getY(index) 获取触摸事件的当前位置。

这里检查当前的滑动状态是否是 拖动中(SCROLL_STATE_DRAGGING)。如果不是拖动状态,才会继续判断是否进入拖动状态。

计算当前触摸点与最初触摸点(mInitialTouchXmInitialTouchY)的水平和垂直偏移量。dx 是水平方向上的偏移量,dy 是竖直方向上的偏移量。这个偏移量表示触摸点相对初始位置的滑动距离。

startScroll 用来标记是否开始滑动,初始设为 false

如果支持水平滑动(canScrollHorizontallytrue),并且水平方向的滑动距离 dx 超过了触摸滑动阈值 mTouchSlop,则认为开始了水平滑动。mLastTouchX 记录当前的 x 坐标,表示上次滑动位置,并将 startScroll 设置为 true,表示滑动开始。

同样,如果支持竖直滑动(canScrollVerticallytrue),并且竖直方向的滑动距离 dy 超过了 mTouchSlop,则认为开始了竖直滑动。mLastTouchY 记录当前的 y 坐标,表示上次滑动位置,并将 startScroll 设置为 true,表示滑动开始。

如果 startScrolltrue,表示触摸滑动已经开始,此时调用 setScrollState(SCROLL_STATE_DRAGGING) 将滚动状态设置为 拖动中(DRAGGING),标志着滑动已经开始,视图可以开始响应滚动。

综上所述,在判断是否拦截此滑动事件时,有一个属性mTouchSlop起到了关键作用。

那么是否是因为RecyclerView和ViewPager2的mTouchSlop的大小设置不同导致的呢?

我们再进入源码当中看一下,搜索mTouchSlop,可以找到这样一个方法。

image-20241201191612432

可以发现RecyclerView有两种TouchSlop,默认的TouchSlop是PagingTouchSlop的两倍。在RecyclerView中,TouchSlop默认是:

image-20241201192206938

而在ViewPager2中,我们找到了这样一句:

image-20241201191737962

破案!滑动滞涩本质上是事件拦截,而如果滑动的比较慢,那么ViewPager2不会将此滑动事件拦截,而是继续传递给内层的RecyclerView,由于内层的TouchSlop比较小,所以将事件拦截下来。

拦截消冲理滑争

现在我们已经了解了事件分发的机制,并且了解了产生滑动冲突的原因,那我们应该如何解决这个问题呢?

网络上常见的解决方案有几种:

  1. 禁用父 ViewPager2 的滑动:如果嵌套的 ViewPager2 实例不需要滚动,可以禁用父 ViewPager2 实例的滑动。这可以通过调用 ViewPager2.setUserInputEnabled(false) 方法来实现。(那为什么用VP2…?)

  2. 子view拿到事件后,通过调用requestDisallowInterceptTouchEvent(true)来禁用父view拦截事件(父View不会再调用自己的onInterceptTouchEvent方法了)

学习解决方案之前,还有一件事需要我们了解:DOWN事件不会被拦截。

根据刚刚的学习,我们只了解了什么时候拦截器会拦截MOVE事件,但如果拦截器会将任何正常情况下的MOVE事件拦截,我们如何确保事件能传入下一层View中呢?

我们再回到拦截事件的源码当中:

image-20241201201943373

在这个方法中,判断是否拦截事件的依据是,当前的滑动状态是否为SCROLL_STATE_DRAGGING,那这个状态在哪里会改变呢?

switch (action) {
            case MotionEvent.ACTION_DOWN:
                if (mIgnoreMotionEventTillDown) {
                    mIgnoreMotionEventTillDown = false;
                }
                mScrollPointerId = e.getPointerId(0);
                mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
                mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);

                if (stopGlowAnimations(e) || mScrollState == SCROLL_STATE_SETTLING) {
                    getParent().requestDisallowInterceptTouchEvent(true);
                    setScrollState(SCROLL_STATE_DRAGGING);
                    stopNestedScroll(TYPE_NON_TOUCH);
                }

                // Clear the nested offsets
                mNestedOffsets[0] = mNestedOffsets[1] = 0;

                int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
                if (canScrollHorizontally) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
                }
                if (canScrollVertically) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
                }
                startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
                break;

            case MotionEvent.ACTION_POINTER_DOWN:
                mScrollPointerId = e.getPointerId(actionIndex);
                mInitialTouchX = mLastTouchX = (int) (e.getX(actionIndex) + 0.5f);
                mInitialTouchY = mLastTouchY = (int) (e.getY(actionIndex) + 0.5f);
                break;

            case MotionEvent.ACTION_MOVE: {
                final int index = e.findPointerIndex(mScrollPointerId);
                if (index < 0) {
                    Log.e(TAG, "Error processing scroll; pointer index for id "
                            + mScrollPointerId + " not found. Did any MotionEvents get skipped?");
                    return false;
                }

                final int x = (int) (e.getX(index) + 0.5f);
                final int y = (int) (e.getY(index) + 0.5f);
                if (mScrollState != SCROLL_STATE_DRAGGING) {
                    final int dx = x - mInitialTouchX;
                    final int dy = y - mInitialTouchY;
                    boolean startScroll = false;
                    if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
                        mLastTouchX = x;
                        startScroll = true;
                    }
                    if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
                        mLastTouchY = y;
                        startScroll = true;
                    }
                    if (startScroll) {
                        setScrollState(SCROLL_STATE_DRAGGING);
                    }
                }
            }
            break;

            case MotionEvent.ACTION_POINTER_UP: {
                onPointerUp(e);
            }
            break;

            case MotionEvent.ACTION_UP: {
                mVelocityTracker.clear();
                stopNestedScroll(TYPE_TOUCH);
            }
            break;

            case MotionEvent.ACTION_CANCEL: {
                cancelScroll();
            }
        }

我们发现在MotionEvent.ACTION_DOWN这一case中,滑动状态仅有一种可能会改变,即SCROLL_STATE_SETTLING时。而一般情况的点击是不会对这一属性进行更改的,也就是说RecyclerView并没有拦截down事件。

我们来梳理一下大致的流程:

ACTION_DOWN 事件的处理

触发 ACTION_DOWN 事件:触摸事件从用户的手指接触屏幕开始,首先触发 ACTION_DOWN 事件。此事件首先进入 RecyclerViewonInterceptTouchEvent 方法。

RecyclerView 没有拦截事件:此时 RecyclerViewonInterceptTouchEvent 返回 false,并将事件交给其子项(通常是 ItemView)进行处理。

ItemView 消费了 ACTION_DOWN 事件:如果 ItemView 可以点击,并且它的 onTouchEvent 方法默认返回 true,那么它消费了该事件。此时,RecyclerView 依然没有拦截任何事件,mFirstTouchTarget 被设置为 ItemView,标记着 ItemView 现在是当前触摸的目标。

ACTION_MOVE 事件的处理

ACTION_MOVE 到达:用户开始滑动时,ACTION_MOVE 事件会继续传递给 RecyclerView。此时,mFirstTouchTarget 已经指向了 ItemView,所以事件会先进入 RecyclerViewonInterceptTouchEvent 方法。

滑动距离超过最小阈值:如果 ACTION_MOVE 事件的滑动距离超过了设定的最小滑动阈值(mTouchSlop),RecyclerView 会判断此次事件是滑动事件而非点击事件。

RecyclerView 返回 true:由于滑动事件的发生,RecyclerView 会开始拦截事件,返回 true,表示它已消费了该事件,阻止进一步的分发。这时,intercepted 被设置为 true,表示触摸事件已经被拦截。

ACTION_CANCEL 事件的触发

cancelChild 被设置为 true:由于滑动距离超过了阈值并且 RecyclerView 拦截了事件,cancelChild 被设置为 true,表示当前的子项(即 ItemView)需要取消事件的处理。

触发 ACTION_CANCEL:当 cancelChildtrue 时,RecyclerView 会调用 dispatchTransformedTouchEvent 方法,将一个 ACTION_CANCEL 事件发送给 ItemView,通知它取消当前的触摸事件处理。这意味着 ItemView 会清除所有当前的触摸状态,并停止响应当前的触摸事件。

事件传递过程的重置

mFirstTouchTarget 被重置为 null:在事件流程中的某个时刻,mFirstTouchTarget 被清空,意味着 RecyclerView 不再跟踪 ItemView 作为当前的触摸目标。

后续事件被 RecyclerView 消费:由于 mFirstTouchTarget 为空,后续的触摸事件将直接交由 RecyclerView 处理,RecyclerView 会重新开始处理触摸事件,直到触摸事件处理完毕。

由于RecyclerView并没有拦截down事件,我们可以在接受到down事件的时候请求父View不要拦截事件

后续move事件到我们这并且水平滑动距离大于最小滑动距离的时候,再问我们的子RecyclerVIew能否在这个滑动方向滑动,如果能,继续禁止父类拦截事件。如果不能则允许父类拦截事件(此时可以根据自己的想法来设置)。

EasyTry嵌套,强制拦截

public class easytryView extends LinearLayout {
    public easytryView(Context context) {
        super(context);
    }

    public easytryView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        getParent().requestDisallowInterceptTouchEvent(true);
        return false;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        getParent().requestDisallowInterceptTouchEvent(true);
        return true;
    }
}

防止父视图拦截事件

requestDisallowInterceptTouchEvent(true)onInterceptTouchEventonTouchEvent 中都调用,告诉父视图不要拦截触摸事件,确保当前的 easytryView 能够完全控制事件的处理。

确保当前视图处理触摸事件

onInterceptTouchEvent 返回 false 表示 easytryView 不打算拦截事件,而是将事件交给 onTouchEvent 处理。

onTouchEvent 返回 true 表示事件已被消费,不再交给其他视图处理。

NestedScrollableHost嵌套,选择性拦截

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.widget.FrameLayout;

import androidx.viewpager2.widget.ViewPager2;

public class NestedScrollableHost extends FrameLayout {
    private int touchSlop;
    private float initialX = 0f;
    private float initialY = 0f;

    // 循环遍历找到ViewPager2
    private ViewPager2 parentViewPager;

    public NestedScrollableHost(Context context) {
        super(context);
        init(context);
    }

    public NestedScrollableHost(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    private void init(Context context) {
        touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
    }

    // 循环遍历找到ViewPager2
    private ViewPager2 getParentViewPager() {
        View v = (View) getParent();
        while (v != null && !(v instanceof ViewPager2)) {
            v = (View) v.getParent();
        }
        return (ViewPager2) v;
    }

    // 找到子RecyclerView
    private View getChildView() {
        return getChildCount() > 0 ? getChildAt(0) : null;
    }

    private boolean canChildScroll(int orientation, float delta) {
        int direction = (int) (-Math.signum(delta));
        if (orientation == 0) {
            return getChildView() != null && getChildView().canScrollHorizontally(direction);
        } else if (orientation == 1) {
            return getChildView() != null && getChildView().canScrollVertically(direction);
        } else {
            throw new IllegalArgumentException("Invalid orientation");
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent e) {
        handleInterceptTouchEvent(e);
        return super.onInterceptTouchEvent(e);
    }

    private void handleInterceptTouchEvent(MotionEvent e) {
        ViewPager2 viewPager2 = getParentViewPager();
        if (viewPager2 == null) return;

        int orientation = viewPager2.getOrientation();

        // 如果子RecyclerView在viewPager2的滑动方向上不能滑动直接返回
        if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
            return;
        }

        if (e.getAction() == MotionEvent.ACTION_DOWN) {
            initialX = e.getX();
            initialY = e.getY();
            // down事件直接强制禁止父view拦截事件
            // 后续事件先交给子RecyclerView判断是否能够消费
            // 如果这一块不强制禁止父view会导致后续事件可能直接没到子RecyclerView就被父view拦截了
            getParent().requestDisallowInterceptTouchEvent(true);
        } else if (e.getAction() == MotionEvent.ACTION_MOVE) {
            // 计算手指滑动距离
            float dx = e.getX() - initialX;
            float dy = e.getY() - initialY;
            boolean isVpHorizontal = orientation == ViewPager2.ORIENTATION_HORIZONTAL;

            float scaledDx = Math.abs(dx) * (isVpHorizontal ? 0.5f : 1f);
            float scaledDy = Math.abs(dy) * (isVpHorizontal ? 1f : 0.5f);

            // 滑动距离超过最小滑动值
            if (scaledDx > touchSlop || scaledDy > touchSlop) {
                if (isVpHorizontal == (scaledDy > scaledDx)) {
                    // 如果viewPager2是横向滑动但手势是竖直方向滑动,则允许所有父类拦截
                    getParent().requestDisallowInterceptTouchEvent(false);
                } else {
                    // 手势滑动方向和viewPager2是同方向的,需要询问子RecyclerView是否在同方向能滑动
                    if (canChildScroll(orientation, isVpHorizontal ? dx : dy)) {
                        // 子RecyclerView能滑动直接禁止父view拦截事件
                        getParent().requestDisallowInterceptTouchEvent(true);
                    } else {
                        // 子RecyclerView不能滑动(划到第一个Item还往右滑或者划到最后一个Item还往左划的时候)允许父view拦截
                        getParent().requestDisallowInterceptTouchEvent(false);
                    }
                }
            }
        }
    }
}

本段内容参考:

【View系列】手把手教你解决ViewPager2滑动冲突 - 知乎

稍微解释一下这段代码,判断是否存在的安全性代码,暂且略过,我们且看核心部分的事件处理逻辑。

private void handleInterceptTouchEvent(MotionEvent e) {
        ViewPager2 viewPager2 = getParentViewPager();
        if (viewPager2 == null) return;

        int orientation = viewPager2.getOrientation();

        // 如果子RecyclerView在viewPager2的滑动方向上不能滑动直接返回
        if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
            return;
        }

        if (e.getAction() == MotionEvent.ACTION_DOWN) {
            initialX = e.getX();
            initialY = e.getY();
            // down事件直接强制禁止父view拦截事件
            // 后续事件先交给子RecyclerView判断是否能够消费
            // 如果这一块不强制禁止父view会导致后续事件可能直接没到子RecyclerView就被父view拦截了
            getParent().requestDisallowInterceptTouchEvent(true);
        } else if (e.getAction() == MotionEvent.ACTION_MOVE) {
            // 计算手指滑动距离
            float dx = e.getX() - initialX;
            float dy = e.getY() - initialY;
            boolean isVpHorizontal = orientation == ViewPager2.ORIENTATION_HORIZONTAL;

            float scaledDx = Math.abs(dx) * (isVpHorizontal ? 0.5f : 1f);
            float scaledDy = Math.abs(dy) * (isVpHorizontal ? 1f : 0.5f);

            // 滑动距离超过最小滑动值
            if (scaledDx > touchSlop || scaledDy > touchSlop) {
                if (isVpHorizontal == (scaledDy > scaledDx)) {
                    // 如果viewPager2是横向滑动但手势是竖直方向滑动,则允许所有父类拦截
                    getParent().requestDisallowInterceptTouchEvent(false);
                } else {
                    // 手势滑动方向和viewPager2是同方向的,需要询问子RecyclerView是否在同方向能滑动
                    if (canChildScroll(orientation, isVpHorizontal ? dx : dy)) {
                        // 子RecyclerView能滑动直接禁止父view拦截事件
                        getParent().requestDisallowInterceptTouchEvent(true);
                    } else {
                        // 子RecyclerView不能滑动(划到第一个Item还往右滑或者划到最后一个Item还往左划的时候)允许父view拦截
                        getParent().requestDisallowInterceptTouchEvent(false);
                    }
                }
            }
        }
    }

ACTION_DOWN:记录触摸初始位置,禁止父视图拦截事件(requestDisallowInterceptTouchEvent(true)),确保后续事件能够到达子视图。

ACTION_MOVE

计算当前手指滑动的距离,并根据滑动方向判断是否允许父视图拦截。

如果手指滑动的方向与 ViewPager2 的滚动方向不一致(例如,ViewPager2 是横向滑动,用户手势是竖直滑动),则允许父视图(ViewPager2)拦截事件。

如果手指滑动的方向与 ViewPager2 一致,则检查子 RecyclerView 是否能够滚动。如果可以滚动,禁止父视图拦截事件,反之,允许父视图拦截。

结语

本篇博客的代码部分仍然存在一些问题,关于Activtiy层级的部分也并没有多做阐释,学习不够深入,日后考虑写几篇博客填充一下这部分内容。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2253786.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

消息中间件-Kafka2-3.9.0源码构建

消息中间件-Kafka2-3.9.0源码构建 1、软件环境 JDK Version 1.8Scala Version 2.12.0Kafka-3.9.0 源码包 下载地址&#xff1a;https://downloads.apache.org/kafka/3.9.0/kafka-3.9.0-src.tgzGradle Version > 8.8Apache Zookeeper 3.7.0 2、源码编译 打开源码根目录修改…

【ElasticSearch】倒排索引与ik分词器

ElasticSearch&#xff0c;简称ES(后文将直接使用这一简称)&#xff0c;是一款卓越的开源分布式搜索引擎。其独特之处在于其近乎实时的数据检索能力&#xff0c;为用户提供了迅速、高效的信息查询体验。 它能够解决全文检索&#xff0c;模糊查询、数据分析等问题。那么它的搜索…

【开源免费】基于Vue和SpringBoot的洗衣店订单管理系统(附论文)

博主说明&#xff1a;本文项目编号 T 068 &#xff0c;文末自助获取源码 \color{red}{T068&#xff0c;文末自助获取源码} T068&#xff0c;文末自助获取源码 目录 一、系统介绍二、演示录屏三、启动教程四、功能截图五、文案资料5.1 选题背景5.2 国内外研究现状5.3 可行性分析…

CAN接口设计

CAN总线的拓扑结构 CAN总线的拓扑结构有点像485总线,都是差分的传输方式,总线上都可以支持多个设备,端接匹配电阻都是120Ω。 485和CAN通信方面最大的区别:网络特性。485是一主多从的通讯方式,CAN是多主通讯,多个设备都可以做主机。那多个设备都相要控制总线呢?…

Keil5配色方案修改为类似VSCode配色

1. 为什么修改Keil5配色方案 视觉习惯&#xff1a;如果你已经习惯了VSCode的配色方案&#xff0c;尤其是在使用ESP-IDF开发ESP32时&#xff0c;Keil5的默认配色可能会让你感到不习惯。减少视觉疲劳&#xff1a;Keil5的默认背景可能过于明亮&#xff0c;长时间使用可能会导致视…

C++设计模式之外观模式

动机 下图中左边方案的问题在于组件的客户和组件中各种复杂的子系统有了过多的耦合&#xff0c;随着外部客户程序和各子系统的演化&#xff0c;这种过多的耦合面临很多变化的挑战。 如何简化外部客户程序和系统间的交互接口&#xff1f;如何将外部客户程序的演化和内部子系统…

矩阵转置        ‌‍‎‏

矩阵转置 C语言代码C 语言代码Java语言代码Python语言代码 &#x1f490;The Begin&#x1f490;点点关注&#xff0c;收藏不迷路&#x1f490; 输入一个n行m列的矩阵A&#xff0c;输出它的转置 A T A^T AT。 输入 第一行包含两个整数n和m&#xff0c;表示矩阵A的行数和列数。…

Linux输入设备应用编程

本章学习输入设备的应用编程&#xff0c;首先要知道什么是输入设备&#xff1f;输入设备其实就是能够产生输入事件的设备就称为输入设备&#xff0c;常见的输入设备包括鼠标、键盘、触摸屏、按钮等等&#xff0c;它们都能够产生输入事件&#xff0c;产生输入数据给计算机系统。…

STM32MX 配置CANFD收发通讯

一、环境 MCU&#xff1a;STM32G0B1CEU6 CAN收发器&#xff1a;JIA1042 二、MX配置 配置SYS 配置canfd并开启中断&#xff0c;我开了两个FDCAN&#xff0c;配置是一样的&#xff0c;这里贴一下波特率的计算公式&#xff1a; 也就是&#xff1a;CAN时钟频率/预分频器/&…

第100+32步 ChatGPT学习:时间序列EMD分解

基于Python 3.9版本演示 一、写在前面 之前我们介绍过时间序列的季节性分解。 最近又学到了好几种骚操作分解&#xff0c;且可以用这些分解优化时间序列预测性能。 首先&#xff0c;我们来学一学经验模态分解&#xff08;Empirical Mode Decomposition&#xff0c;EMD&#…

Spring Shell如何与SpringBoot集成并快速创建命令行界面 (CLI) 应用程序

Spring Shell 介绍 Spring Shell 是一个强大的工具&#xff0c;可用于构建命令行应用程序&#xff0c;提供了简单的方式来创建和管理交互式 CLI。它适合那些希望通过命令行与 Java 应用程序进行交互的开发者&#xff0c;尤其是在需要自动化、交互式输入或与 Spring 生态系统集…

后端返回前端的数据量过大解决方案

后端返回前端的数据量过大解决方案 性能面板(Performance) chrome调试指南 原因 遇到一个页面有好几个表格&#xff0c;部分表格采用虚拟滚动条 数据量有点大 接近快60s了&#xff0c;看一下是哪里导致的慢 后台请求方法执行并不慢 2024-12-04 15:21:52.889 INFO 69948 …

linux 系列服务器 高并发下ulimit优化文档

系统输入 ulimit -a 结果如下 解除 Linux 系统的最大进程数 要解除或提高 Linux 系统的最大进程数&#xff0c;可以修改 ulimit 设置和 /etc/security/limits.conf 文件中的限制。 临时修改 ulimit 设置 可以使用 ulimit 命令来查看和修改当前会话的最大进程数&#xff1a; 查…

c++数据结构算法复习基础--11--高级排序算法-快速排序-归并排序-堆排序

高阶排序 1、快速排序 冒泡排序的升级算法 每次选择一个基准数&#xff0c;把小于基准数的放到基准数的左边&#xff0c;把大于基准数的放到基准数的右边&#xff0c;采用 “ 分治算法 ”处理剩余元素&#xff0c;直到整个序列变为有序序列。 最好和平均的复杂度&#xff1a…

洛谷P1827 [USACO3.4] 美国血统 American Heritage(c嘎嘎)

题目链接&#xff1a;P1827 [USACO3.4] 美国血统 American Heritage - 洛谷 | 计算机科学教育新生态 题目难度&#xff1a;普及 首先介绍下二叉树的遍历&#xff1a; 学过数据结构都知道二叉树有三种遍历&#xff1a; 1.前序遍历&#xff1a;根左右 2.中序遍历&#xff1a;左根…

# 全过程 快速创建一个Vue项目

如何快速创建一个Vue项目 前置知识 ​ 下载 Node.js 并且进行安装和配置 Node.js&#xff0c;因为 npm&#xff08;Node Package Manager&#xff09;是随 Node.js 一起安装的。 Node.js 下载地址 : Node.js 官方网站 ​ (如果你还没有关于 Node.js&webpack 的相关知识…

小程序 模版与配置

WXML模版语法 一、数据绑定 1、数据绑定的基本原则 &#xff08;1&#xff09;在data中定义数据 &#xff08;2&#xff09;在WXML中使用数据 2、在data中定义页面的数据 3、Mustache语法的格式&#xff08;双大括号&#xff09; 4、Mustache语法的应用场景 &#xff08;…

智慧银行反欺诈大数据管控平台方案(四)

智慧银行反欺诈大数据管控平台的核心内容&#xff0c;是通过整合多维度、多层次的金融交易信息&#xff0c;利用先进的大数据分析、机器学习与人工智能算法&#xff0c;构建一个系统性、实时性和智能化的反欺诈管控网络&#xff0c;旨在提供全面、高效、精准的风险评估机制。该…

MSSQL2022的一个错误:未在本地计算机上注册“Microsoft.ACE.OLEDB.16.0”提供程序

MSSQL2022导入Excel的一个错误&#xff1a;未在本地计算机上注册“Microsoft.ACE.OLEDB.16.0”提供程序 一、导入情况二、问题发现三、问题解决 最近在安装新版SQLServer SSMS 2022后&#xff0c;每次导入Excel都会出现错误提示&#xff1a;未在本地计算机上注册“Microsoft.AC…

基于极角排序实现二维点的逆时针排列

在二维几何计算中,常常需要对一组点进行逆时针排序,以便用于构建多边形、实现凸包算法或绘制几何图形等。本文将详细介绍一种基于极角计算的方法,使用C++实现将点集按照逆时针顺序排列,并提供完整代码和输出示例,适合直接应用于工程项目或算法学习。 一、问题背景 在一个…