package com.swmansion.rnscreens import android.content.Context import android.content.ContextWrapper import android.view.Choreographer import android.view.View import android.view.ViewGroup import android.view.ViewParent import android.view.inputmethod.InputMethodManager import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction import com.facebook.react.ReactRootView import com.facebook.react.bridge.ReactContext import com.facebook.react.modules.core.ReactChoreographer import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.UIManagerHelper import com.swmansion.rnscreens.Screen.ActivityState import com.swmansion.rnscreens.events.ScreenDismissedEvent import com.swmansion.rnscreens.gamma.common.FragmentProviding open class ScreenContainer( context: Context?, ) : ViewGroup(context) { @JvmField protected val screenWrappers = ArrayList() @JvmField protected var fragmentManager: FragmentManager? = null private var isAttached = false private var needsUpdate = false private var isLayoutEnqueued = false private val layoutCallback: Choreographer.FrameCallback = object : Choreographer.FrameCallback { override fun doFrame(frameTimeNanos: Long) { isLayoutEnqueued = false measure( MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), ) layout(left, top, right, bottom) } } private var parentScreenWrapper: ScreenFragmentWrapper? = null override fun onLayout( changed: Boolean, l: Int, t: Int, r: Int, b: Int, ) { var i = 0 val size = childCount while (i < size) { getChildAt(i).layout(0, 0, width, height) i++ } } override fun removeView(view: View) { // The below block is a workaround for an issue with keyboard handling within fragments. Despite // Android handles input focus on the fragments that leave the screen, the keyboard stays open // in a number of cases. // The workaround is to force-hide keyboard when the screen that has focus is dismissed (we // detect that in removeView as super.removeView causes the input view to un-focus while keeping // the keyboard open). if (view === focusedChild) { (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) .hideSoftInputFromWindow(windowToken, InputMethodManager.HIDE_NOT_ALWAYS) } super.removeView(view) } override fun requestLayout() { super.requestLayout() @Suppress("SENSELESS_COMPARISON") // mLayoutCallback can be null here since this method can be called in init if (!isLayoutEnqueued && layoutCallback != null) { isLayoutEnqueued = true // we use NATIVE_ANIMATED_MODULE choreographer queue because it allows us to catch the current // looper loop instead of enqueueing the update in the next loop causing a one frame delay. ReactChoreographer .getInstance() .postFrameCallback( ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE, layoutCallback, ) } } val isNested: Boolean get() = parentScreenWrapper != null fun onChildUpdate() { performUpdatesNow() } protected open fun adapt(screen: Screen): ScreenFragmentWrapper = ScreenFragment(screen) fun addScreen( screen: Screen, index: Int, ) { val fragment = adapt(screen) screen.fragmentWrapper = fragment screenWrappers.add(index, fragment) screen.container = this onScreenChanged() } open fun removeScreenAt(index: Int) { screenWrappers[index].screen.container = null screenWrappers.removeAt(index) onScreenChanged() } open fun removeAllScreens() { for (screenFragment in screenWrappers) { screenFragment.screen.container = null } screenWrappers.clear() onScreenChanged() } val screenCount: Int get() = screenWrappers.size fun getScreenAt(index: Int): Screen = screenWrappers[index].screen fun getScreenFragmentWrapperAt(index: Int): ScreenFragmentWrapper = screenWrappers[index] open val topScreen: Screen? get() = screenWrappers.firstOrNull { getActivityState(it) === ActivityState.ON_TOP }?.screen private fun setFragmentManager(fm: FragmentManager) { fragmentManager = fm performUpdatesNow() } private fun findFragmentManagerForReactRootView(rootView: ReactRootView): FragmentManager { var context = rootView.context // ReactRootView is expected to be initialized with the main React Activity as a context but // in case of Expo the activity is wrapped in ContextWrapper and we need to unwrap it while (context !is FragmentActivity && context is ContextWrapper) { context = context.baseContext } check(context is FragmentActivity) { "In order to use RNScreens components your app's activity need to extend ReactActivity" } // In case React Native is loaded on a Fragment (not directly in activity) we need to find // fragment manager whose fragment's view is ReactRootView. As of now, we detect such case by // checking whether any fragments are attached to activity which hosts ReactRootView. // See: https://github.com/software-mansion/react-native-screens/issues/1506 on why the cases // must be treated separately. return if (context.supportFragmentManager.fragments.isEmpty()) { // We are in standard React Native application w/o custom native navigation based on fragments. context.supportFragmentManager } else { // We are in some custom setup & we want to use the closest fragment manager in hierarchy. // `findFragment` method throws IllegalStateException when it fails to resolve appropriate // fragment. It might happen when e.g. React Native is loaded directly in Activity // but some custom fragments are still used. Such use case seems highly unlikely // so, as for now we fallback to activity's FragmentManager in hope for the best. try { FragmentManager.findFragment(rootView).childFragmentManager } catch (ex: IllegalStateException) { context.supportFragmentManager } } } private fun setupFragmentManager() { var parent: ViewParent = this // We traverse view hierarchy up until we find screen parent or a root view while (!(parent is ReactRootView || parent is FragmentProviding) && parent.parent != null ) { parent = parent.parent } // If parent is of type Screen it means we are inside a nested fragment structure. // Otherwise we expect to connect directly with root view and get root fragment manager if (parent is Screen) { checkNotNull( parent.fragmentWrapper?.let { fragmentWrapper -> parentScreenWrapper = fragmentWrapper fragmentWrapper.addChildScreenContainer(this) setFragmentManager(fragmentWrapper.fragment.childFragmentManager) }, ) { "Parent Screen does not have its Fragment attached" } } else if (parent is FragmentProviding) { // TODO: We're missing parent-child relationship here between old container & new one val fragmentManager = checkNotNull( parent.getAssociatedFragment(), ) { "[RNScreens] Parent $parent returned nullish fragment" }.childFragmentManager setFragmentManager(fragmentManager) } else { // we expect top level view to be of type ReactRootView, this isn't really necessary but in // order to find root view we test if parent is null. This could potentially happen also when // the view is detached from the hierarchy and that test would not correctly indicate the root // view. So in order to make sure we indeed reached the root we test if it is of a correct type. // This allows us to provide a more descriptive error message for the aforementioned case. check(parent is ReactRootView) { "ScreenContainer is not attached under ReactRootView" } setFragmentManager(findFragmentManagerForReactRootView(parent)) } } protected fun createTransaction(): FragmentTransaction = requireNotNull(fragmentManager) { "fragment manager is null when creating transaction" } .beginTransaction() .setReorderingAllowed(true) private fun attachScreen( transaction: FragmentTransaction, fragment: Fragment, ) { transaction.add(id, fragment) } fun attachBelowTop() { if (screenWrappers.size < 2) { throw RuntimeException("[RNScreens] Unable to run transition for less than 2 screens.") } val transaction = createTransaction() val top = topScreen as Screen // we have to reattach topScreen so it is on top of the one below detachScreen(transaction, top.fragment as Fragment) attachScreen(transaction, screenWrappers[screenWrappers.size - 2].fragment) attachScreen(transaction, top.fragment as Fragment) transaction.commitNowAllowingStateLoss() } fun detachBelowTop() { if (screenWrappers.size < 2) { throw RuntimeException("[RNScreens] Unable to run transition for less than 2 screens.") } val transaction = createTransaction() detachScreen(transaction, screenWrappers[screenWrappers.size - 2].fragment) transaction.commitNowAllowingStateLoss() } fun notifyScreenDetached(screen: Screen) { if (context is ReactContext) { val surfaceId = UIManagerHelper.getSurfaceId(context) UIManagerHelper .getEventDispatcherForReactTag(context as ReactContext, screen.id) ?.dispatchEvent(ScreenDismissedEvent(surfaceId, screen.id)) } } fun notifyTopDetached() { val top = topScreen as Screen if (context is ReactContext) { val surfaceId = UIManagerHelper.getSurfaceId(context) UIManagerHelper .getEventDispatcherForReactTag(context as ReactContext, top.id) ?.dispatchEvent(ScreenDismissedEvent(surfaceId, top.id)) } } private fun detachScreen( transaction: FragmentTransaction, fragment: Fragment, ) { transaction.remove(fragment) } private fun getActivityState(screenFragmentWrapper: ScreenFragmentWrapper): ActivityState? = screenFragmentWrapper.screen.activityState open fun hasScreen(screenFragmentWrapper: ScreenFragmentWrapper?): Boolean = screenWrappers.contains(screenFragmentWrapper) override fun onAttachedToWindow() { super.onAttachedToWindow() isAttached = true setupFragmentManager() } /** Removes fragments from fragment manager that are attached to this container */ private fun removeMyFragments(fragmentManager: FragmentManager) { val transaction = fragmentManager.beginTransaction() var hasFragments = false for (fragment in fragmentManager.fragments) { if (fragment is ScreenFragment && fragment.screen.container === this) { transaction.remove(fragment) hasFragments = true } } if (hasFragments) { transaction.commitNowAllowingStateLoss() } } override fun onDetachedFromWindow() { // if there are pending transactions and this view is about to get detached we need to perform // them here as otherwise fragment manager will crash because it won't be able to find container // view. We also need to make sure all the fragments attached to the given container are removed // from fragment manager as in some cases fragment manager may be reused and in such case it'd // attempt to reattach previously registered fragments that are not removed fragmentManager?.let { if (!it.isDestroyed) { removeMyFragments(it) it.executePendingTransactions() } } parentScreenWrapper?.removeChildScreenContainer(this) parentScreenWrapper = null super.onDetachedFromWindow() isAttached = false // When fragment container view is detached we force all its children to be removed. // It is because children screens are controlled by their fragments, which can often have a // delayed lifecycle (due to transitions). As a result due to ongoing transitions the fragment // may choose not to remove the view despite the parent container being completely detached // from the view hierarchy until the transition is over. In such a case when the container gets // re-attached while the transition is ongoing, the child view would still be there and we'd // attempt to re-attach it to with a misconfigured fragment. This would result in a crash. To // avoid it we clear all the children here as we attach all the child fragments when the // container is reattached anyways. We don't use `removeAllViews` since it does not check if the // children are not already detached, which may lead to calling `onDetachedFromWindow` on them // twice. // We also get the size earlier, because we will be removing child views in `for` loop. for (i in childCount - 1 downTo 0) { removeViewAt(i) } } override fun onMeasure( widthMeasureSpec: Int, heightMeasureSpec: Int, ) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) for (i in 0 until childCount) { getChildAt(i).measure(widthMeasureSpec, heightMeasureSpec) } } private fun onScreenChanged() { // We schedule the update to run after the current Fabric commit has finished updating // children of the ScreenContainer. The `onUpdate` logic requires all children changes // to already be applied in order to provide proper animation for fragment transitions // in ScreenStack — without this deferral, nested ScreenContainers may detach too early // and disappear before the transition completes. // The exception to this rule is `performUpdatesNow` which is triggered by actions // not connected to React view hierarchy changes, but rather internal events. needsUpdate = true (context as ThemedReactContext).reactApplicationContext.runOnUiQueueThread { // We schedule the update here because LayoutAnimations of `react-native-reanimated` // sometimes attach/detach screens after the Fabric mount phase has completed, // and we want to update the container then too. In the other cases, // this code will do nothing since it will run after the mount phase when `needsUpdate` // will already be false. performUpdates() } } protected fun performUpdatesNow() { // We want to update immediately when the fragment manager is set, when a native back button // dismiss is dispatched, or when Screen's activityState changes, since these events are not // connected to React view hierarchy changes and will not trigger a Fabric commit or mount. needsUpdate = true performUpdates() } fun performUpdates() { if (!needsUpdate || !isAttached || fragmentManager == null || fragmentManager?.isDestroyed == true) { return } needsUpdate = false onUpdate() notifyContainerUpdate() } open fun onUpdate() { createTransaction().let { // detach screens that are no longer active val orphaned: MutableSet = HashSet( requireNotNull(fragmentManager) { "fragment manager is null when performing update in ScreenContainer" }.fragments, ) for (fragmentWrapper in screenWrappers) { if (getActivityState(fragmentWrapper) === ActivityState.INACTIVE && fragmentWrapper.fragment.isAdded ) { detachScreen(it, fragmentWrapper.fragment) } orphaned.remove(fragmentWrapper.fragment) } if (orphaned.isNotEmpty()) { val orphanedAry = orphaned.toTypedArray() for (fragment in orphanedAry) { if (fragment is ScreenFragment) { if (fragment.screen.container == null) { detachScreen(it, fragment) } } } } // if there is an "onTop" screen it means the transition has ended val transitioning = topScreen == null // attach newly activated screens var addedBefore = false val pendingFront: ArrayList = ArrayList() for (fragmentWrapper in screenWrappers) { fragmentWrapper.screen.setTransitioning(transitioning) val activityState = getActivityState(fragmentWrapper) if (activityState == ActivityState.INACTIVE) { continue } if (fragmentWrapper.fragment.isAdded) { if (addedBefore) { detachScreen(it, fragmentWrapper.fragment) pendingFront.add(fragmentWrapper) } } else { if (addedBefore) { pendingFront.add(fragmentWrapper) } else { addedBefore = true attachScreen(it, fragmentWrapper.fragment) } } } for (screenFragment in pendingFront) { attachScreen(it, screenFragment.fragment) } it.commitNowAllowingStateLoss() } } protected open fun notifyContainerUpdate() { topScreen?.fragmentWrapper?.onContainerUpdate() } }