谈谈 Android 的手势返回
前言
手势返回对用户而言是一个很便捷的操作,苹果原生支持,而 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。但这里并不能简简单单的用一个成员变量保存它。 需要考虑一下几种情况:
-
View 正在动画过程中,有些时候,我们会进入一个界面,然后在动画还没结束时就快速返回,这样会触发 View 的移除动画还没结束就添加动画,这里的问题具体可看 这里
-
android support 包升级到 27 以后, FragmentManager 支持了 transition。 不过 transition 和动画同时使用,又会掉进 view 不能成功移除的坑, 我给 google 提了个 bug单,期待官方可以处理下。
针对这两点,我的做法是:
- 通过反射 fragment.getAnimatingAway(),判断是否是在动画过程中,如果是,则抛弃重新创建View, 后期看看能不能寻找到更好的方式
- 如果掉进 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 包更新,改动了某些字段,可能会造成手势返回不能正常工作。