Files
react-navigation/android/src/main/java/com/swmansion/rnscreens/ScreenStack.java
Krzysztof Magiera 656e82de9f Dispatch appear event for screens. (#248)
Appear event is used by react-navigation to properly dispatch focus. It is important that appear is dispatched after dismissed event. The reverse order of actions would result in getting react-navigation stack in a weird state.

It is relatively streightforward to implement onAppear event on iOS where we hook into didAppear callback from UIViewController. It gets dispatched in the right moment, that is when the transition is fully over.

On Android however it is much more tricky. There is no standard way to be notified from the fragment level that fragment transition finished. One way that is frequently recommended is to override Fragment.onCreateAnimation. However, this only works when custom transitions are provided (e.g. if we set the transition to use fade animation). As we want the platform native transition to be run by default we had to look for other ways. The current approach relies on fragment container's callbacks startViewTransition and endViewTransition, with the latter being triggered once the animation is over. We also need to take into account that a good starting point for the transition is when we call commit on fragment transaction. We use these two methods to determine if the fragment is instantiated (onCreate) within a running transaction and if so we schedule event dispatch at the moment when endViewTransition is called.

Another change this commit introduces on the Android side is that we no longer rely on show/hide for replacing fragments on stack and we now use add/remove transaction methods. Due to this change we had to make our fragments reusable and make onCreateView indempotent.
2019-12-11 22:28:19 +01:00

249 lines
9.0 KiB
Java

package com.swmansion.rnscreens;
import android.content.Context;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Set;
public class ScreenStack extends ScreenContainer<ScreenStackFragment> {
private static final String BACK_STACK_TAG = "RN_SCREEN_LAST";
private final ArrayList<ScreenStackFragment> mStack = new ArrayList<>();
private final Set<ScreenStackFragment> mDismissed = new HashSet<>();
private ScreenStackFragment mTopScreen = null;
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);
}
}
};
private final FragmentManager.FragmentLifecycleCallbacks mLifecycleCallbacks = new FragmentManager.FragmentLifecycleCallbacks() {
@Override
public void onFragmentResumed(FragmentManager fm, Fragment f) {
if (mTopScreen == f) {
setupBackHandlerIfNeeded(mTopScreen);
}
}
};
public ScreenStack(Context context) {
super(context);
}
public void dismiss(ScreenStackFragment screenFragment) {
mDismissed.add(screenFragment);
onUpdate();
}
public Screen getTopScreen() {
return mTopScreen.getScreen();
}
public Screen getRootScreen() {
for (int i = 0, size = getScreenCount(); i < size; i++) {
Screen screen = getScreenAt(i);
if (!mDismissed.contains(screen.getFragment())) {
return screen;
}
}
throw new IllegalStateException("Stack has no root screen set");
}
@Override
protected ScreenStackFragment adapt(Screen screen) {
return new ScreenStackFragment(screen);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
FragmentManager fm = getFragmentManager();
fm.removeOnBackStackChangedListener(mBackStackListener);
getFragmentManager().unregisterFragmentLifecycleCallbacks(mLifecycleCallbacks);
if (!fm.isStateSaved()) {
// state save means that the container where fragment manager was installed has been unmounted.
// This could happen as a result of dismissing nested stack. In such a case we don't need to
// reset back stack as it'd result in a crash caused by the fact the fragment manager is no
// longer attached.
fm.popBackStack(BACK_STACK_TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE);
}
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
getFragmentManager().registerFragmentLifecycleCallbacks(mLifecycleCallbacks, false);
}
@Override
protected void removeScreenAt(int index) {
Screen toBeRemoved = getScreenAt(index);
mDismissed.remove(toBeRemoved);
super.removeScreenAt(index);
}
@Override
protected boolean hasScreen(ScreenFragment screenFragment) {
return super.hasScreen(screenFragment) && !mDismissed.contains(screenFragment);
}
@Override
protected void onUpdate() {
// remove all screens previously on stack
for (ScreenStackFragment screen : mStack) {
if (!mScreenFragments.contains(screen) || mDismissed.contains(screen)) {
getOrCreateTransaction().remove(screen);
}
}
// When going back from a nested stack with a single screen on it, we may hit an edge case
// when all screens are dismissed and no screen is to be displayed on top. We need to gracefully
// handle the case of newTop being NULL, which happens in several places below
ScreenStackFragment newTop = null; // newTop is nullable, see the above comment ^
ScreenStackFragment belowTop = null; // this is only set if newTop has TRANSPARENT_MODAL presentation mode
for (int i = mScreenFragments.size() - 1; i >= 0; i--) {
ScreenStackFragment screen = mScreenFragments.get(i);
if (!mDismissed.contains(screen)) {
if (newTop == null) {
newTop = screen;
if (newTop.getScreen().getStackPresentation() != Screen.StackPresentation.TRANSPARENT_MODAL) {
break;
}
} else {
belowTop = screen;
break;
}
}
}
for (ScreenStackFragment screen : mScreenFragments) {
// detach all screens that should not be visible
if (screen != newTop && screen != belowTop && !mDismissed.contains(screen)) {
getOrCreateTransaction().remove(screen);
}
}
// attach "below top" screen if set
if (belowTop != null && !belowTop.isAdded()) {
final ScreenStackFragment top = newTop;
getOrCreateTransaction().add(getId(), belowTop).runOnCommit(new Runnable() {
@Override
public void run() {
top.getScreen().bringToFront();
}
});
}
if (newTop != null && !newTop.isAdded()) {
getOrCreateTransaction().add(getId(), newTop);
}
if (!mStack.contains(newTop)) {
// if new top screen wasn't on stack we do "open animation" so long it is not the very first screen on stack
if (mTopScreen != null) {
// there was some other screen attached before
int transition = FragmentTransaction.TRANSIT_FRAGMENT_OPEN;
switch (mTopScreen.getScreen().getStackAnimation()) {
case NONE:
transition = FragmentTransaction.TRANSIT_NONE;
break;
case FADE:
transition = FragmentTransaction.TRANSIT_FRAGMENT_FADE;
break;
}
getOrCreateTransaction().setTransition(transition);
}
} else if (mTopScreen != null && !mTopScreen.equals(newTop)) {
// otherwise if we are performing top screen change we do "back animation"
int transition = FragmentTransaction.TRANSIT_FRAGMENT_CLOSE;
switch (mTopScreen.getScreen().getStackAnimation()) {
case NONE:
transition = FragmentTransaction.TRANSIT_NONE;
break;
case FADE:
transition = FragmentTransaction.TRANSIT_FRAGMENT_FADE;
break;
}
getOrCreateTransaction().setTransition(transition);
}
mTopScreen = newTop;
mStack.clear();
mStack.addAll(mScreenFragments);
tryCommitTransaction();
if (mTopScreen != null) {
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) {
if (!mTopScreen.isResumed()) {
// if the top fragment is not in a resumed state, adding back stack transaction would throw.
// In such a case we skip installing back handler and use FragmentLifecycleCallbacks to get
// notified when it gets resumed so that we can install the handler.
return;
}
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()
.beginTransaction()
.hide(topScreen)
.show(topScreen)
.addToBackStack(BACK_STACK_TAG)
.setPrimaryNavigationFragment(topScreen)
.commitAllowingStateLoss();
getFragmentManager().addOnBackStackChangedListener(mBackStackListener);
}
}
}