前言
手势返回对用户而言是一个很便捷的操作,苹果原生支持,而 Android 到如今都没有考虑过这件事,所以只能有 App 开发者自己来完成,不过这也给了开发者创造的空间。最近在繁忙的业务开发之余,将 QMUIDemo 中的 fragment 管理基础类提取出来作为一个新的库,然后添加了手势返回的功能,目前已经完成最初版本,有兴趣的可以试试,在 build.gragle 中引入:
implementation "com.qmuiteam:arch:0.0.1"
然后使用 QMUIFragmentActivity
和 QMUIFragment
来作为 base 类搭建 UI,怎么使用可以参考 QMUI_Android 项目,本文会介绍其实现原理和几个控制接口。
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, 应该会越来越容易出现卡顿的情况。QMUIFragment 采用 replace 的方式,这样视图上就会只存在一个Fragment,保证性能,可以看一下 QMUIFragmentActivity.startFragment 方法:
public void startFragment(QMUIFragment fragment) { QMUIFragment.TransitionConfig transitionConfig = fragment.onFetchTransitionConfig(); String tagName = fragment.getClass().getSimpleName(); 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 里记录的操作。
先来看看手势返回触发的操作:
public void onEdgeTouch(int edgeFlag) { Log.i(TAG, "SwipeListener:onEdgeTouch: edgeFlag = " + edgeFlag); FragmentManager fragmentManager = getFragmentManager(); if (fragmentManager == null) { return; } 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 QMUIFragment) { QMUIFragment fragment = (QMUIFragment) fragmentObject; // 将前一个 fragment 管理的 View 添加到视图最下层,因此手势返回时就可以看到背后的 view ViewGroup container = getBaseFragmentActivity().getFragmentContainer(); // 触发前一个 fragment 的 onCreateView(3参数),得到 fragment 所管理的 view。 fragment.isCreateForSwipeBack = true; View baseView = fragment.onCreateView(LayoutInflater.from(getContext()), container, null); fragment.isCreateForSwipeBack = false; if (baseView != null) { // 添加 tag, 标示是手势返回过程中用到的 View baseView.setTag(R.id.qmui_arch_swipe_layout_in_back, SWIPE_BACK_VIEW); // 将它添加到视图最下层 container.addView(baseView, 0); // 模仿微信的手势返回,提供一个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(); } } else { // 如果已经是第一个 fragment, 那么就就回归到 Activity 的手势返回,将其 Activity 改为透明的 if (getActivity() != null) { getActivity().getWindow().getDecorView().setBackgroundColor(0); Utils.convertActivityToTranslucent(getActivity()); } } }
主要的核心就是去掉前一个 fragment 的进入动画,将其管理的 view 添加到视图下层。为了模仿微信的视差效果,我也提供了一个方法 backInitOffset()
, 子类重写,可以得到完美模仿视差滚动,当然如果 activity, 就没有支持到了。
在拖拽过程中,基本上就是更新背后 view 的位置,没有太多的内容。然后就是拖拽完成。 分为两种情况,一种是放弃返回,一种是执行返回。如果放弃返回,则删除背后的View,如果执行返回,则需要将当前 fragment 的退出动画置为0,然后执行 popbackstack。 具体代码为:
public void onScrollStateChange(int state, float 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.qmui_arch_swipe_layout_in_back); if (tag != null && SWIPE_BACK_VIEW.equals(tag)) { container.removeView(view); } } } 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.qmui_arch_swipe_layout_in_back); if (tag != null && SWIPE_BACK_VIEW.equals(tag)) { container.removeView(view); } } FragmentManager fragmentManager = getFragmentManager(); if (fragmentManager == null) { return; } int backstackCount = fragmentManager.getBackStackEntryCount(); if (backstackCount > 0) { try { FragmentManager.BackStackEntry backStackEntry = fragmentManager.getBackStackEntryAt(backstackCount - 1); 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) { Field cmdField = op.getClass().getDeclaredField("cmd"); cmdField.setAccessible(true); int cmd = (int) cmdField.get(op); if (cmd == 1) { // 如果 cmd == 1, 则说明之前的操作是 add, 也就是添加当前 fragment 的操作, 我们需要去除其 remove 动画 Field popEnterAnimField = op.getClass().getDeclaredField("popExitAnim"); popEnterAnimField.setAccessible(true); popEnterAnimField.set(op, 0); } } } } catch (NoSuchFieldException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } } 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。 具体代码:
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, must 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.qmui_arch_swipe_layout_in_back, null); } ViewCompat.setTranslationZ(swipeBackLayout, mBackStackIndex); swipeBackLayout.setFitsSystemWindows(false); if (getActivity() != null) { QMUIViewHelper.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; }
最后 QMUIFragment
提供 canDragBack
, 控制当前 fragment 能否手势返回。
目前这个方案个人能想到的最好版本。后期可能会通过精读源码,有跟多的改进。目前这个方案主要还是存在一个不足: 大量的运用反射,如果 support 包更新,改动了某些字段,可能会造成手势返回不能正常工作。