Android 的滑动手势返回的最好实现方式

     手势返回对用户而言是一个很便捷的操作,苹果原生支持,而 Android 到如今都没有考虑过这件事,所以只能有 App 开发者自己来完成。这里单独建立arch Module,把手势滑动和activity和fragment跳转动画都集中在这个module。

      然后使用 XUIFragmentActivity 和 XUIFragment 来作为 base 类搭建 UI。

Activity 的手势返回

目前开源的手势返回实现基本上都是针对 Activity 的,例如经典的实现:SwipeBackLayout, 之所以经典,是因为之后的实现基本上都使用的它提供的 View(SwipeBackLayout)。实现 Activity 手势返回的原理也很简单,就是在拖拽开始时把 Activity 改为透明的,这样就可以看到背后的 Activity 了,然而系统并没有提供接口来将 Activity 改为透明的,所以只能通过反射的方式来实现。当然,将 Activity 改为透明的,是有性能消耗的,并且可能引发其它坑点,所以也有其它方案的,例如 and_swipeback。对于 SwipeBackLayout 的使用和如何利用反射将 Activity 改为透明,这里推荐一篇博文 Android 平台滑动返回库对比

单 Activity 多 Fragment 的手势返回。

个人推崇单 Activity 多 Fragment 的 UI 架构:轻量级,更灵活,不用每次添加新界面就去改 AndroidManifest,等等。

目前业界也有针对 Fragment 的手势返回实现,不过前提是 Fragment 一个一个的 add 到 视图上的,这里其实不是很优雅,如果你的导航很深,那么你的视图就会同时存在很多Fragment, 应该会越来越容易出现卡顿的情况。XUIFragment 采用 replace 的方式,这样视图上就会只存在一个Fragment,保证性能,可以看一下 XUIFragmentActivity.startFragment 方法:

    public int startFragment(XUIFragment fragment) {
        Log.i(TAG, "startFragment");
        XUIFragment.TransitionConfig transitionConfig = fragment.onFetchTransitionConfig();
        String tagName = fragment.getClass().getSimpleName();
        return getSupportFragmentManager()
                .beginTransaction()
                .setCustomAnimations(transitionConfig.enter, transitionConfig.exit, transitionConfig.popenter, transitionConfig.popout)
                .replace(getContextViewId(), fragment, tagName)
                .addToBackStack(tagName)
                .commit();
    }

采用 replace 方法实现 Fragment 的跳转,带来的代价就是手势返回非常不好实现。如果不清楚 FragmentManager 和 BackStackRecord 的运作机制,基本上很难实现这个功能,所以前期花费了大量的时间去理顺 FragmentManager 的实现逻辑。

首先我们要知道 addToBackStack 具体是做的什么,可能从字面意思上理解,是将 Fragment 添加到 BackStack 里。 其实不是的,其添加的是操作过程(Op)。比如说 replace 操作, 它是两个操作:一个 remove 和 一个 add,那么 BackStackRcord 就会记录这两个操作, 在 popBackStack 时根据所记录的操作执行逆向的操作。 所以实现手势返回的一个关键点就可以确定下来, 修改 BackStackRcord 里记录的操作。

首先看手势返回触发的操作:

            @Override
            public void onEdgeTouch(int edgeFlag) {
                Log.i(TAG, "SwipeListener:onEdgeTouch: edgeFlag = " + edgeFlag);
                FragmentManager fragmentManager = getFragmentManager();
                if (fragmentManager == null) {
                    return;
                }
                XUIKeyboardHelper.hideKeyboard(swipeBackLayout);
                int backstackCount = fragmentManager.getBackStackEntryCount();
                // 如果 backstackCount > 1, 则手势返回后依然是Fragment
                if (backstackCount > 1) {
                    try {
                        // 后去最后一个 BackStackRcord, BackStackRcord 是 BackStackEntry 的唯一实现类
                        FragmentManager.BackStackEntry backStackEntry = fragmentManager.getBackStackEntryAt(backstackCount - 1);
                        // 通过反射获取此次操作记录: 一般是两个:remove 前一个fragment 和 add 后一个操作
                        Field opsField = backStackEntry.getClass().getDeclaredField("mOps");
                        opsField.setAccessible(true);
                        Object opsObj = opsField.get(backStackEntry);
                        if (opsObj instanceof List<?>) {
                            List<?> ops = (List<?>) opsObj;
                            for (Object op : ops) {
                                // 遍历所有操作,通过 cmd 确定操作类型
                                Field cmdField = op.getClass().getDeclaredField("cmd");
                                cmdField.setAccessible(true);
                                int cmd = (int) cmdField.get(op);
                                if (cmd == 3) {
                                    // 如果 cmd == 3, 则是 remove 操作,那么将其进入动画置为0.这样手势返回就不会触发前一个 fragment 的进入动画了
                                    Field popEnterAnimField = op.getClass().getDeclaredField("popEnterAnim");
                                    popEnterAnimField.setAccessible(true);
                                    popEnterAnimField.set(op, 0);

                                    // 通过反射 fragment 字段可以获取之前被 remove 的 fragment, 也就是前一个 fragment
                                    Field fragmentField = op.getClass().getDeclaredField("fragment");
                                    fragmentField.setAccessible(true);
                                    Object fragmentObject = fragmentField.get(op);
                                    if (fragmentObject instanceof XUIFragment) {
                                        mModifiedFragment = (XUIFragment) fragmentObject;
                                        ViewGroup container = getBaseFragmentActivity().getFragmentContainer();
                                        mModifiedFragment.isCreateForSwipeBack = true;
                                        // 触发前一个 fragment 的 onCreateView(3参数),得到 fragment 所管理的 view
                                        View baseView = mModifiedFragment.onCreateView(LayoutInflater.from(getContext()), container, null);
                                        mModifiedFragment.isCreateForSwipeBack = false;
                                        if (baseView != null) {
                                            // 添加 tag, 标示是手势返回过程中用到的 View
                                            baseView.setTag(R.id.xui_arch_swipe_layout_in_back, SWIPE_BACK_VIEW);
                                            // 将它添加到视图最下层
                                            container.addView(baseView, 0);

                                            // handle issue #235:https://github.com/QMUI/QMUI_Android/issues/235
                                            Field viewField = Fragment.class.getDeclaredField("mView");
                                            viewField.setAccessible(true);
                                            viewField.set(mModifiedFragment, baseView);
                                            FragmentManager childFragmentManager = mModifiedFragment.getChildFragmentManager();
                                            Method dispatchCreatedMethod = childFragmentManager.getClass().getMethod("dispatchActivityCreated");
                                            dispatchCreatedMethod.setAccessible(true);
                                            dispatchCreatedMethod.invoke(childFragmentManager);

                                            // 模仿微信的手势返回,提供一个init offset,可实现视差滚动
                                            int offset = Math.abs(backViewInitOffset());
                                            if (edgeFlag == EDGE_BOTTOM) {
                                                ViewCompat.offsetTopAndBottom(baseView, offset);
                                            } else if (edgeFlag == EDGE_RIGHT) {
                                                ViewCompat.offsetLeftAndRight(baseView, offset);
                                            } else {
                                                ViewCompat.offsetLeftAndRight(baseView, -1 * offset);
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    } catch (NoSuchFieldException e) {
                        e.printStackTrace();
                    } catch (IllegalAccessException e) {
                        e.printStackTrace();
                    } catch (NoSuchMethodException e) {
                        e.printStackTrace();
                    } catch (InvocationTargetException e) {
                        e.printStackTrace();
                    }
                } else {
                    // 如果已经是第一个 fragment, 那么就就回归到 Activity 的手势返回,将其 Activity 改为透明的
                    if (getActivity() != null) {
                        getActivity().getWindow().getDecorView().setBackgroundColor(0);
                        Utils.convertActivityToTranslucent(getActivity());
                    }
                }
            }

主要的核心就是去掉前一个 fragment 的进入动画,将其管理的 view 添加到视图下层。为了模仿微信的视差效果,这里也提供了一个方法 backInitOffset(), 子类重写,可以得到完美模仿视差滚动,当然如果 activity, 就没有支持到了。

在拖拽过程中,基本上就是更新背后 view 的位置,没有太多的内容。然后就是拖拽完成。 分为两种情况,一种是放弃返回,一种是执行返回。如果放弃返回,则删除背后的View,如果执行返回,则需要将当前 fragment 的退出动画置为0,然后执行 popbackstack。 具体代码为:


            @Override
            public void onScrollStateChange(int state, float scrollPercent) {
                Log.i(TAG, "SwipeListener:onScrollStateChange: state = " + state + " ;scrollPercent = " + scrollPercent);
                ViewGroup container = getBaseFragmentActivity().getFragmentContainer();
                int childCount = container.getChildCount();
                if (state == SwipeBackLayout.STATE_IDLE) {
                    if (scrollPercent <= 0.0F) {
                    // 放弃反回,根据 tag 移除 view
                        for (int i = childCount - 1; i >= 0; i--) {
                            View view = container.getChildAt(i);
                            Object tag = view.getTag(R.id.xui_arch_swipe_layout_in_back);
                            if (tag != null && SWIPE_BACK_VIEW.equals(tag)) {
                                container.removeView(view);
                                if (mModifiedFragment != null) {
                                    // give up swipe back, we should reset the revise
                                    try {
                                        Field viewField = Fragment.class.getDeclaredField("mView");
                                        viewField.setAccessible(true);
                                        viewField.set(mModifiedFragment, null);
                                        FragmentManager childFragmentManager = mModifiedFragment.getChildFragmentManager();
                                        Method dispatchCreatedMethod = childFragmentManager.getClass().getMethod("dispatchCreate");
                                        dispatchCreatedMethod.setAccessible(true);
                                        dispatchCreatedMethod.invoke(childFragmentManager);
                                    } catch (NoSuchFieldException e) {
                                        e.printStackTrace();
                                    } catch (NoSuchMethodException e) {
                                        e.printStackTrace();
                                    } catch (IllegalAccessException e) {
                                        e.printStackTrace();
                                    } catch (InvocationTargetException e) {
                                        e.printStackTrace();
                                    }
                                    mModifiedFragment = null;
                                }

                            }
                        }
                    } else if (scrollPercent >= 1.0F) {
                        // 执行返回, 已经要根据 tag 移除 view, 还原正常的返回流程
                        for (int i = childCount - 1; i >= 0; i--) {
                            View view = container.getChildAt(i);
                            Object tag = view.getTag(R.id.xui_arch_swipe_layout_in_back);
                            if (tag != null && SWIPE_BACK_VIEW.equals(tag)) {
                                container.removeView(view);
                            }
                        }
                        FragmentManager fragmentManager = getFragmentManager();
                        Utils.findAndModifyOpInBackStackRecord(fragmentManager, -1, new Utils.OpHandler() {
                            @Override
                            public boolean handle(Object op) {
                                Field cmdField;
                                try {
                                    cmdField = op.getClass().getDeclaredField("cmd");
                                    cmdField.setAccessible(true);
                                    int cmd = (int) cmdField.get(op);
                                    // 如果 cmd == 1, 则说明之前的操作是 add, 也就是添加当前 fragment 的操作, 我们需要去除其 remove 动画
                                    if (cmd == 1) {
                                        Field popEnterAnimField = op.getClass().getDeclaredField("popEnterAnim");
                                        popEnterAnimField.setAccessible(true);
                                        popEnterAnimField.set(op, 0);
                                    } else if (cmd == 3) {//如果cmd==1,则说明之前的操作是删除
                                        Field popExitAnimField = op.getClass().getDeclaredField("popExitAnim");
                                        popExitAnimField.setAccessible(true);
                                        popExitAnimField.set(op, 0);
                                    }
                                } catch (NoSuchFieldException e) {
                                    e.printStackTrace();
                                } catch (IllegalAccessException e) {
                                    e.printStackTrace();
                                }

                                return false;
                            }
                        });
                        popBackStack();
                    }
                }

这样整个手势返回的流程就通了。还有存在一个问题。 前一个 fragment 的 onCreateView(3参数)会执行多次。 手势返回会触发一次,popBackStack又会触发一次,所以我们需要对 Fragment 创建的 View 做 cache。但这里并不能简简单单的用一个成员变量保存它。 需要考虑一下几种情况:

  1. View 正在动画过程中,有些时候,我们会进入一个界面,然后在动画还没结束时就快速返回,这样会触发 View 的移除动画还没结束就添加动画,这里的问题具体可看 这里

  2. android support 包升级到 27 以后, FragmentManager 支持了 transition。 不过 transition 和动画同时使用,又会掉进 view 不能成功移除的坑, 我给 google 提了个 bug单,期待官方可以处理下。

针对这两点,我的做法是:

  1. 通过反射 fragment.getAnimatingAway(),判断是否是在动画过程中,如果是,则抛弃重新创建View, 后期看看能不能寻找到更好的方式
  2. 如果掉进 view 不能成功移除的坑,会有一个现象:view.getParent != null && view.getParent.indexOfChild(view) == -1。 因此。如果满足这种条件,那就通过反射强制将 mParent 置为 null。 具体代码:

    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        SwipeBackLayout swipeBackLayout;
        if (mCacheView == null) {
            swipeBackLayout = newSwipeBackLayout();
            mCacheView = swipeBackLayout;
        } else if (isCreateForSwipeBack) {
            // in swipe back, exactly not in animation
            swipeBackLayout = mCacheView;
        } else {
            boolean isInRemoving = false;
            try {
                Method method = Fragment.class.getDeclaredMethod("getAnimatingAway");
                method.setAccessible(true);
                Object object = method.invoke(this);
                if (object != null) {
                    isInRemoving = true;
                }
            } catch (NoSuchMethodException e) {
                isInRemoving = true;
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                isInRemoving = true;
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                isInRemoving = true;
                e.printStackTrace();
            }
            if (isInRemoving) {
                swipeBackLayout = newSwipeBackLayout();
                mCacheView = swipeBackLayout;
            } else {
                swipeBackLayout = mCacheView;
            }
        }


        if (!isCreateForSwipeBack) {
            mBaseView = swipeBackLayout.getContentView();
            swipeBackLayout.setTag(R.id.xui_arch_swipe_layout_in_back, null);
        }

        ViewCompat.setTranslationZ(swipeBackLayout, mBackStackIndex);

        swipeBackLayout.setFitsSystemWindows(false);

        if (getActivity() != null) {
            XUIViewHelper.requestApplyInsets(getActivity().getWindow());
        }

        if (swipeBackLayout.getParent() != null) {
            ViewGroup viewGroup = (ViewGroup) swipeBackLayout.getParent();
            if (viewGroup.indexOfChild(swipeBackLayout) > -1) {
                viewGroup.removeView(swipeBackLayout);
            } else {
                // see https://issuetracker.google.com/issues/71879409
                try {
                    Field parentField = View.class.getDeclaredField("mParent");
                    parentField.setAccessible(true);
                    parentField.set(swipeBackLayout, null);
                } catch (NoSuchFieldException e) {
                    e.printStackTrace();
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
            }
        }

        return swipeBackLayout;
    }

最后 XUIFragment 提供 canDragBack, 控制当前 fragment 能否手势返回。

目前这个方案个人能想到的最好版本。后期可能会通过精读源码,有跟多的改进。目前这个方案主要还是存在一个不足: 大量的运用反射,如果 support 包更新,改动了某些字段,可能会造成手势返回不能正常工作

ViewDragHelper 部分原理分析 http://blog.qiji.tech/archives/8295

Android 实现滑动的几种方法 http://blog.qiji.tech/archives/8295

SwipeBackLayout源码解析 https://blog.csdn.net/yoonerloop/article/details/78839322

猜你喜欢

转载自blog.csdn.net/heng615975867/article/details/81626545