From 656e82de9fb3204bdd8491dd62d4407eac177669 Mon Sep 17 00:00:00 2001 From: Krzysztof Magiera Date: Wed, 11 Dec 2019 22:28:19 +0100 Subject: [PATCH] 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. --- .../rnscreens/ScreenAppearEvent.java | 30 +++++++++++ .../swmansion/rnscreens/ScreenContainer.java | 51 +++++++++++++++++++ .../swmansion/rnscreens/ScreenFragment.java | 37 +++++++++++--- .../com/swmansion/rnscreens/ScreenStack.java | 19 +++---- .../rnscreens/ScreenStackFragment.java | 17 +++++-- .../rnscreens/ScreenViewManager.java | 4 +- createNativeStackNavigator.js | 17 +++---- ios/RNSScreen.h | 1 + ios/RNSScreen.m | 18 +++++++ 9 files changed, 165 insertions(+), 29 deletions(-) create mode 100644 android/src/main/java/com/swmansion/rnscreens/ScreenAppearEvent.java diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenAppearEvent.java b/android/src/main/java/com/swmansion/rnscreens/ScreenAppearEvent.java new file mode 100644 index 0000000000..31d77c5b9a --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenAppearEvent.java @@ -0,0 +1,30 @@ +package com.swmansion.rnscreens; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.uimanager.events.Event; +import com.facebook.react.uimanager.events.RCTEventEmitter; + +public class ScreenAppearEvent extends Event { + + public static final String EVENT_NAME = "topAppear"; + + public ScreenAppearEvent(int viewId) { + super(viewId); + } + + @Override + public String getEventName() { + return EVENT_NAME; + } + + @Override + public short getCoalescingKey() { + // All events for a given view can be coalesced. + return 0; + } + + @Override + public void dispatch(RCTEventEmitter rctEventEmitter) { + rctEventEmitter.receiveEvent(getViewTag(), getEventName(), Arguments.createMap()); + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.java b/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.java index e64922eca3..0b141e5943 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenContainer.java @@ -2,6 +2,7 @@ import android.content.Context; import android.content.ContextWrapper; +import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; @@ -22,11 +23,14 @@ public class ScreenContainer extends ViewGroup { protected final ArrayList mScreenFragments = new ArrayList<>(); private final Set mActiveScreenFragments = new HashSet<>(); + private final ArrayList mAfterTransitionRunnables = new ArrayList<>(1); private @Nullable FragmentManager mFragmentManager; private @Nullable FragmentTransaction mCurrentTransaction; + private @Nullable FragmentTransaction mProcessingTransaction; private boolean mNeedUpdate; private boolean mIsAttached; + private boolean mIsTransitioning; private boolean mLayoutEnqueued = false; private final ChoreographerCompat.FrameCallback mFrameCallback = new ChoreographerCompat.FrameCallback() { @@ -101,6 +105,36 @@ protected void removeScreenAt(int index) { markUpdated(); } + @Override + public void startViewTransition(View view) { + super.startViewTransition(view); + mIsTransitioning = true; + } + + @Override + public void endViewTransition(View view) { + super.endViewTransition(view); + if (mIsTransitioning) { + mIsTransitioning = false; + notifyTransitionFinished(); + } + } + + public boolean isTransitioning() { + return mIsTransitioning || mProcessingTransaction != null; + } + + public void postAfterTransition(Runnable runnable) { + mAfterTransitionRunnables.add(runnable); + } + + protected void notifyTransitionFinished() { + for (int i = 0, size = mAfterTransitionRunnables.size(); i < size; i++) { + mAfterTransitionRunnables.get(i).run(); + } + mAfterTransitionRunnables.clear(); + } + protected int getScreenCount() { return mScreenFragments.size(); } @@ -159,6 +193,19 @@ protected FragmentTransaction getOrCreateTransaction() { protected void tryCommitTransaction() { if (mCurrentTransaction != null) { + final FragmentTransaction transaction = mCurrentTransaction; + mProcessingTransaction = transaction; + mProcessingTransaction.runOnCommit(new Runnable() { + @Override + public void run() { + if (mProcessingTransaction == transaction) { + // we need to take into account that commit is initiated with some other transaction while + // the previous one is still processing. In this case mProcessingTransaction gets overwritten + // and we don't want to set it to null until the second transaction is finished. + mProcessingTransaction = null; + } + } + }); mCurrentTransaction.commitAllowingStateLoss(); mCurrentTransaction = null; } @@ -184,6 +231,10 @@ protected boolean isScreenActive(ScreenFragment screenFragment) { return screenFragment.getScreen().isActive(); } + protected boolean hasScreen(ScreenFragment screenFragment) { + return mScreenFragments.contains(screenFragment); + } + @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.java b/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.java index 09fd4582d1..028ca5a851 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.java @@ -7,12 +7,10 @@ import android.view.ViewGroup; import androidx.annotation.Nullable; -import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; import com.facebook.react.bridge.ReactContext; import com.facebook.react.uimanager.UIManagerModule; -import com.facebook.react.uimanager.events.EventDispatcher; public class ScreenFragment extends Fragment { @@ -39,12 +37,39 @@ public Screen getScreen() { return mScreenView; } - @Override - public void onDestroy() { - super.onDestroy(); + private void dispatchOnAppear() { ((ReactContext) mScreenView.getContext()) .getNativeModule(UIManagerModule.class) .getEventDispatcher() - .dispatchEvent(new ScreenDismissedEvent(mScreenView.getId())); + .dispatchEvent(new ScreenAppearEvent(mScreenView.getId())); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ScreenContainer container = mScreenView.getContainer(); + if (container.isTransitioning()) { + container.postAfterTransition(new Runnable() { + @Override + public void run() { + dispatchOnAppear(); + } + }); + } else { + dispatchOnAppear(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + ScreenContainer container = mScreenView.getContainer(); + if (container == null || !container.hasScreen(this)) { + // we only send dismissed even when the screen has been removed from its container + ((ReactContext) mScreenView.getContext()) + .getNativeModule(UIManagerModule.class) + .getEventDispatcher() + .dispatchEvent(new ScreenDismissedEvent(mScreenView.getId())); + } } } diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStack.java b/android/src/main/java/com/swmansion/rnscreens/ScreenStack.java index 58bb884f76..01878b8205 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStack.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStack.java @@ -97,6 +97,11 @@ protected void removeScreenAt(int index) { 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 @@ -128,19 +133,15 @@ protected void onUpdate() { } for (ScreenStackFragment screen : mScreenFragments) { - // add all new views that weren't on stack before - if (!mStack.contains(screen) && !mDismissed.contains(screen)) { - getOrCreateTransaction().add(getId(), screen); - } // detach all screens that should not be visible if (screen != newTop && screen != belowTop && !mDismissed.contains(screen)) { - getOrCreateTransaction().hide(screen); + getOrCreateTransaction().remove(screen); } } // attach "below top" screen if set - if (belowTop != null) { + if (belowTop != null && !belowTop.isAdded()) { final ScreenStackFragment top = newTop; - getOrCreateTransaction().show(belowTop).runOnCommit(new Runnable() { + getOrCreateTransaction().add(getId(), belowTop).runOnCommit(new Runnable() { @Override public void run() { top.getScreen().bringToFront(); @@ -148,8 +149,8 @@ public void run() { }); } - if (newTop != null) { - getOrCreateTransaction().show(newTop); + if (newTop != null && !newTop.isAdded()) { + getOrCreateTransaction().add(getId(), newTop); } if (!mStack.contains(newTop)) { diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.java b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.java index c38906d6e1..32cb92b9cd 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.java @@ -22,6 +22,7 @@ public class ScreenStackFragment extends ScreenFragment { private AppBarLayout mAppBarLayout; private Toolbar mToolbar; private boolean mShadowHidden; + private CoordinatorLayout mScreenRootView; @SuppressLint("ValidFragment") public ScreenStackFragment(Screen screenView) { @@ -59,10 +60,7 @@ public void onStackUpdate() { } } - @Override - public View onCreateView(LayoutInflater inflater, - @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { + private CoordinatorLayout configureView() { CoordinatorLayout view = new CoordinatorLayout(getContext()); CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT); @@ -87,6 +85,17 @@ public View onCreateView(LayoutInflater inflater, return view; } + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + if (mScreenRootView == null) { + mScreenRootView = configureView(); + } + + return mScreenRootView; + } + public boolean isDismissable() { View child = mScreenView.getChildAt(0); if (child instanceof ScreenStackHeaderConfig) { diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.java b/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.java index 2bd073cb4d..b18e56eaea 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.java +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.java @@ -62,6 +62,8 @@ public void setStackAnimation(Screen view, String animation) { public Map getExportedCustomDirectEventTypeConstants() { return MapBuilder.of( ScreenDismissedEvent.EVENT_NAME, - MapBuilder.of("registrationName", "onDismissed")); + MapBuilder.of("registrationName", "onDismissed"), + ScreenAppearEvent.EVENT_NAME, + MapBuilder.of("registrationName", "onAppear")); } } diff --git a/createNativeStackNavigator.js b/createNativeStackNavigator.js index d05af4a5c2..4736f5b3c7 100644 --- a/createNativeStackNavigator.js +++ b/createNativeStackNavigator.js @@ -4,7 +4,6 @@ import { StackRouter, SceneView, StackActions, - NavigationActions, createNavigator, } from '@react-navigation/core'; import { createKeyboardAwareNavigator } from '@react-navigation/native'; @@ -27,14 +26,13 @@ function renderComponentOrThunk(componentOrThunk, props) { class StackView extends React.Component { _removeScene = route => { - const { navigation } = this.props; - navigation.dispatch( - NavigationActions.back({ - key: route.key, - immediate: true, - }) + this.props.navigation.dispatch(StackActions.pop({ key: route.key })); + }; + + _onSceneFocus = route => { + this.props.navigation.dispatch( + StackActions.completeTransition({ toChildKey: route.key }) ); - navigation.dispatch(StackActions.completeTransition()); }; _renderHeaderConfig = (index, route, descriptor) => { @@ -165,7 +163,7 @@ class StackView extends React.Component { transparentCard || options.cardTransparent ? 'transparentModal' : mode; } - let stackAnimation = undefined; + let stackAnimation; if (options.animationEnabled === false) { stackAnimation = 'none'; } @@ -177,6 +175,7 @@ class StackView extends React.Component { style={options.cardStyle} stackAnimation={stackAnimation} stackPresentation={stackPresentation} + onAppear={() => this._onSceneFocus(route)} onDismissed={() => this._removeScene(route)}> {this._renderHeaderConfig(index, route, descriptor)} *reactSuperview; @property (nonatomic, retain) UIViewController *controller; diff --git a/ios/RNSScreen.m b/ios/RNSScreen.m index 22bba3cfe4..90cf4dd6ad 100644 --- a/ios/RNSScreen.m +++ b/ios/RNSScreen.m @@ -135,6 +135,17 @@ - (void)notifyDismissed } } +- (void)notifyAppear +{ + if (self.onAppear) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.onAppear) { + self.onAppear(nil); + } + }); + } +} + - (BOOL)isMountedUnderScreenOrReactRoot { for (UIView *parent = self.superview; parent != nil; parent = parent.superview) { @@ -235,6 +246,12 @@ - (void)viewDidDisappear:(BOOL)animated } } +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + [((RNSScreenView *)self.view) notifyAppear]; +} + - (void)notifyFinishTransitioning { [_previousFirstResponder becomeFirstResponder]; @@ -258,6 +275,7 @@ @implementation RNSScreenManager RCT_EXPORT_VIEW_PROPERTY(active, BOOL) RCT_EXPORT_VIEW_PROPERTY(stackPresentation, RNSScreenStackPresentation) RCT_EXPORT_VIEW_PROPERTY(stackAnimation, RNSScreenStackAnimation) +RCT_EXPORT_VIEW_PROPERTY(onAppear, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onDismissed, RCTDirectEventBlock); - (UIView *)view