前言
手勢返回對用戶而言是一個很便捷的操作,蘋果原生支持,而 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 包更新,改動了某些字段,可能會造成手勢返回不能正常工作。