From 26384b625e7de7f5eab94d7418de606309ed6a19 Mon Sep 17 00:00:00 2001 From: Krzysztof Magiera Date: Wed, 6 Nov 2019 21:54:19 +0100 Subject: [PATCH] Use fragment manager back stack to handle hw back button presses on Android. (#216) Before this change we'd rely on new androidx OnBackPressedCallback mechanism. It turns out not to work well in a few cases. The biggest problem with it was that when registered it'd never allow for the hw back button press to fallback to a system default behaviour and instead would always "still" that event. After several attempts of trying to make it work I decided to revert back to a FragmentManager's default solution. This change adds an ability to use FragmentManager's back stack API. There are also a few problems with it we need to workaround though. One of the problem is the fact that we can not modify back stack history. What we do because of that is that we try to keep at most one item on the back stack and reset it each time our native stack updates. In order for that to work we create a fake transaction that hides and shows the same screen that displays on top. Thanks to that when hw back is pressed the built in transaction rollback logic does nothing to the UI and allows us to handle back navigation using back stack change listener. --- .../swmansion/rnscreens/ScreenContainer.java | 7 +- .../com/swmansion/rnscreens/ScreenStack.java | 64 +++++++++++++++++++ .../rnscreens/ScreenStackFragment.java | 8 +++ .../rnscreens/ScreenStackHeaderConfig.java | 28 ++------ 4 files changed, 82 insertions(+), 25 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.java b/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.java index 30d9ba9d..ecbfc1f7 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.java @@ -7,6 +7,7 @@ import android.view.ViewParent; import androidx.annotation.Nullable; import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import com.facebook.react.ReactRootView; @@ -109,9 +110,13 @@ public class ScreenContainer extends ViewGroup { return (FragmentActivity) context; } + protected FragmentManager getFragmentManager() { + return findRootFragmentActivity().getSupportFragmentManager(); + } + protected FragmentTransaction getOrCreateTransaction() { if (mCurrentTransaction == null) { - mCurrentTransaction = findRootFragmentActivity().getSupportFragmentManager().beginTransaction(); + mCurrentTransaction = getFragmentManager().beginTransaction(); mCurrentTransaction.setReorderingAllowed(true); } return mCurrentTransaction; diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStack.java b/android/src/main/java/com/swmansion/rnscreens/ScreenStack.java index af68298d..41bc5da0 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStack.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStack.java @@ -2,6 +2,7 @@ package com.swmansion.rnscreens; import android.content.Context; +import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import java.util.ArrayList; @@ -10,12 +11,26 @@ import java.util.Set; public class ScreenStack extends ScreenContainer { + private static final String BACK_STACK_TAG = "RN_SCREEN_LAST"; + private final ArrayList mStack = new ArrayList<>(); private final Set mDismissed = new HashSet<>(); private ScreenStackFragment mTopScreen = null; private boolean mLayoutEnqueued = false; + private final FragmentManager.OnBackStackChangedListener mBackStackListener = new FragmentManager.OnBackStackChangedListener() { + @Override + public void onBackStackChanged() { + if (getFragmentManager().getBackStackEntryCount() == 0) { + // when back stack entry count hits 0 it means the user's navigated back using hw back + // button. As the "fake" transaction we installed on the back stack does nothing we need + // to handle back navigation on our own. + dismiss(mTopScreen); + } + } + }; + public ScreenStack(Context context) { super(context); } @@ -59,6 +74,12 @@ public class ScreenStack extends ScreenContainer { } } + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + getFragmentManager().removeOnBackStackChangedListener(mBackStackListener); + } + private final Runnable mLayoutRunnable = new Runnable() { @Override public void run() { @@ -172,8 +193,51 @@ public class ScreenStack extends ScreenContainer { tryCommitTransaction(); + setupBackHandlerIfNeeded(mTopScreen); + for (ScreenStackFragment screen : mStack) { screen.onStackUpdate(); } } + + /** + * The below method sets up fragment manager's back stack in a way that it'd trigger our back + * stack change listener when hw back button is clicked. + * + * Because back stack by default rolls back the transaction the stack entry is associated with we + * generate a "fake" transaction that hides and shows the top fragment. As a result when back + * stack entry is rolled back nothing happens and we are free to handle back navigation on our + * own in `mBackStackListener`. + * + * We pop that "fake" transaction each time we update stack and we add a new one in case the top + * screen is allowed to be dismised using hw back button. This way in the listener we can tell + * if back button was pressed based on the count of the items on back stack. We expect 0 items + * in case hw back is pressed becakse we try to keep the number of items at 1 by always resetting + * and adding new items. In case we don't add a new item to back stack we remove listener so that + * it does not get triggered. + * + * It is important that we don't install back handler when stack contains a single screen as in + * that case we want the parent navigator or activity handler to take over. + */ + private void setupBackHandlerIfNeeded(ScreenStackFragment topScreen) { + getFragmentManager().removeOnBackStackChangedListener(mBackStackListener); + getFragmentManager().popBackStack(BACK_STACK_TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE); + ScreenStackFragment firstScreen = null; + for (int i = 0, size = mStack.size(); i < size; i++) { + ScreenStackFragment screen = mStack.get(i); + if (!mDismissed.contains(screen)) { + firstScreen = screen; + break; + } + } + if (topScreen != firstScreen && topScreen.isDismissable()) { + getFragmentManager().addOnBackStackChangedListener(mBackStackListener); + getFragmentManager() + .beginTransaction() + .hide(topScreen) + .show(topScreen) + .addToBackStack(BACK_STACK_TAG) + .commit(); + } + } } diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.java b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.java index ea6ab79c..c38906d6 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.java @@ -86,4 +86,12 @@ public class ScreenStackFragment extends ScreenFragment { return view; } + + public boolean isDismissable() { + View child = mScreenView.getChildAt(0); + if (child instanceof ScreenStackHeaderConfig) { + return ((ScreenStackHeaderConfig) child).isDismissable(); + } + return true; + } } diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.java b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.java index 4a6695c4..0e3fa9fb 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.java @@ -10,7 +10,6 @@ import android.view.ViewGroup; import android.view.ViewParent; import android.widget.TextView; -import androidx.activity.OnBackPressedCallback; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; @@ -36,18 +35,6 @@ public class ScreenStackHeaderConfig extends ViewGroup { private boolean mIsAttachedToWindow = false; - private OnBackPressedCallback mBackCallback = new OnBackPressedCallback(false) { - @Override - public void handleOnBackPressed() { - ScreenStack stack = getScreenStack(); - Screen current = getScreen(); - if (stack.getTopScreen() == current) { - stack.dismiss(getScreenFragment()); - } - mBackCallback.remove(); - } - }; - private OnClickListener mBackClickListener = new OnClickListener() { @Override public void onClick(View view) { @@ -116,27 +103,20 @@ public class ScreenStackHeaderConfig extends ViewGroup { return null; } + public boolean isDismissable() { + return mGestureEnabled; + } + public void onUpdate() { Screen parent = (Screen) getParent(); final ScreenStack stack = getScreenStack(); boolean isRoot = stack == null ? true : stack.getRootScreen() == parent; boolean isTop = stack == null ? true : stack.getTopScreen() == parent; - // we need to clean up back handler especially in the case given screen is no longer on top - // because we don't want it to capture back event if it is not responsible for handling it - // as that would block other handlers from running - mBackCallback.remove(); - if (!mIsAttachedToWindow || !isTop) { return; } - if (!isRoot && isTop && mGestureEnabled) { - Fragment fragment = getScreenFragment(); - fragment.requireActivity().getOnBackPressedDispatcher().addCallback(fragment, mBackCallback); - mBackCallback.setEnabled(true); - } - if (mIsHidden) { if (mToolbar.getParent() != null) { getScreenFragment().removeToolbar();