Skip to content

Commit 82ce103

Browse files
authored
feat(Android, Stack v5): allow for native navigation in nested stacks (software-mansion#3601)
## Description This commit refactors *StackContainer* code. Achieved functional effect boils down to support for native-pop in nested stack, and in outer stack after closing the nested one. Closes software-mansion/react-native-screens-labs#821 ## Changes I've added abstraction over *FragmentManager* operations. This is done mostly to structure and better manage container update code complexity. The *FragmentManager* facing code is now moved into `FragmentOperationExecutor` class. Now *FragmentManager* reference is cleaned up in `onDetachedFromWindow`. The *StackContainer* is now `OnBackStackChangedListener`. This is done to better time & detect that a screen has been dismissed. This callback is invoked mid-transaction execution by fragment manager, not after all animations finish & the UI disappears. It seems like much more appropriate place to keep the *StackContainer* possibly up-to-date. Please note, that *StackScreen* still emits `onDismiss` event much later, after the UI hides & fragment is destroyed. I've changed it a bit here. Earlier it has been emitted in `onDestroyView`, now I emit it in `onDestroy`. I've changed that to prevent incorrect emitting when fragment moves to *STARTED* lifecycle state but is not detached from fragment manager - such situation might happen when we use `FragmentTransaction.detach`, which is planned. This commit also adds basic *primaryNavigationFragment* management. This is necessary for native navigation in nested stacks to work. If we do not set it correctly, then *childFragmentManager* (used by nested stack) won't handle `backPressed` at all. This responsibility will be delegated to the *supportFragmentManager* and whole nested stack will be popped immediately, instead of only single screen. Important thing to note here is that we need to run additional fragment manager operations when we detect that a fragment has been natively popped. `FragmentManager.primaryNavigationFragment` state seems to not be updated automatically once written to, hence unless we update it, we will encounter a crash where *FragmentManager* attempts to delegate back handling to already detached fragment. ## Visual documentation Push, Pop, Native pop work nicely, even when popping nested stack. The same action via JS popping should not work yet & will be handled separately. https://github.com/user-attachments/assets/43f6f3e4-35f7-4bfc-bd23-674d97ceca76 There is no "before" video. This is initial implementation. ## Test plan TestStackNesting ## Checklist - [ ] Included code example that can be used to test this change. - [ ] Updated / created local changelog entries in relevant test files. - [ ] For visual changes, included screenshots / GIFs / recordings documenting the change. - [ ] For API changes, updated relevant public types. - [ ] Ensured that CI passes
1 parent c3f7cb1 commit 82ce103

File tree

6 files changed

+309
-62
lines changed

6 files changed

+309
-62
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package com.swmansion.rnscreens.gamma.stack.host
2+
3+
import androidx.fragment.app.FragmentManager
4+
import com.swmansion.rnscreens.gamma.stack.screen.StackScreenFragment
5+
6+
internal sealed class FragmentOperation {
7+
internal abstract fun execute(
8+
fragmentManager: FragmentManager,
9+
executor: FragmentOperationExecutor,
10+
)
11+
}
12+
13+
internal class AddOp(
14+
val fragment: StackScreenFragment,
15+
val containerViewId: Int,
16+
val addToBackStack: Boolean,
17+
val allowStateLoss: Boolean = true,
18+
) : FragmentOperation() {
19+
override fun execute(
20+
fragmentManager: FragmentManager,
21+
executor: FragmentOperationExecutor,
22+
) {
23+
executor.executeAddOp(fragmentManager, this)
24+
}
25+
}
26+
27+
internal class PopBackStackOp(
28+
val fragment: StackScreenFragment,
29+
) : FragmentOperation() {
30+
override fun execute(
31+
fragmentManager: FragmentManager,
32+
executor: FragmentOperationExecutor,
33+
) {
34+
executor.executePopBackStackOp(fragmentManager, this)
35+
}
36+
}
37+
38+
internal class RemoveOp(
39+
val fragment: StackScreenFragment,
40+
val allowStateLoss: Boolean = true,
41+
val flushSync: Boolean = false,
42+
) : FragmentOperation() {
43+
override fun execute(
44+
fragmentManager: FragmentManager,
45+
executor: FragmentOperationExecutor,
46+
) {
47+
executor.executeRemoveOp(fragmentManager, this)
48+
}
49+
}
50+
51+
internal class FlushNowOp : FragmentOperation() {
52+
override fun execute(
53+
fragmentManager: FragmentManager,
54+
executor: FragmentOperationExecutor,
55+
) {
56+
executor.executeFlushOp(fragmentManager, this)
57+
}
58+
}
59+
60+
internal class SetPrimaryNavFragmentOp(
61+
val fragment: StackScreenFragment,
62+
) : FragmentOperation() {
63+
override fun execute(
64+
fragmentManager: FragmentManager,
65+
executor: FragmentOperationExecutor,
66+
) {
67+
executor.executeSetPrimaryNavFragmentOp(fragmentManager, this)
68+
}
69+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.swmansion.rnscreens.gamma.stack.host
2+
3+
import androidx.fragment.app.FragmentManager
4+
import androidx.fragment.app.FragmentTransaction
5+
import com.swmansion.rnscreens.gamma.helpers.createTransactionWithReordering
6+
7+
internal class FragmentOperationExecutor {
8+
internal fun executeOperations(
9+
fragmentManager: FragmentManager,
10+
ops: List<FragmentOperation>,
11+
flushSync: Boolean = false
12+
) {
13+
ops.forEach { it.execute(fragmentManager, this) }
14+
15+
if (flushSync) {
16+
fragmentManager.executePendingTransactions()
17+
}
18+
}
19+
20+
internal fun executeAddOp(fragmentManager: FragmentManager, op: AddOp) {
21+
fragmentManager.createTransactionWithReordering().let { tx ->
22+
tx.add(op.containerViewId, op.fragment)
23+
if (op.addToBackStack) {
24+
tx.addToBackStack(op.fragment.stackScreen.screenKey)
25+
}
26+
commitTransaction(tx, op.allowStateLoss)
27+
}
28+
}
29+
30+
internal fun executePopBackStackOp(fragmentManager: FragmentManager, op: PopBackStackOp) {
31+
fragmentManager.popBackStack(
32+
op.fragment.stackScreen.screenKey,
33+
FragmentManager.POP_BACK_STACK_INCLUSIVE
34+
)
35+
}
36+
37+
internal fun executeRemoveOp(fragmentManager: FragmentManager, op: RemoveOp) {
38+
fragmentManager.createTransactionWithReordering().let { tx ->
39+
tx.remove(op.fragment)
40+
commitTransaction(tx, op.allowStateLoss, op.flushSync)
41+
}
42+
}
43+
44+
internal fun executeFlushOp(fragmentManager: FragmentManager, op: FlushNowOp) {
45+
fragmentManager.executePendingTransactions()
46+
}
47+
48+
internal fun executeSetPrimaryNavFragmentOp(fragmentManager: FragmentManager, op: SetPrimaryNavFragmentOp) {
49+
fragmentManager.createTransactionWithReordering().let { tx ->
50+
tx.setPrimaryNavigationFragment(op.fragment)
51+
commitTransaction(tx, allowStateLoss = true, flushSync = false)
52+
}
53+
}
54+
55+
private fun commitTransaction(
56+
tx: FragmentTransaction,
57+
allowStateLoss: Boolean,
58+
flushSync: Boolean = false
59+
) {
60+
if (flushSync) {
61+
commitSync(tx, allowStateLoss)
62+
} else {
63+
commitAsync(tx, allowStateLoss)
64+
}
65+
}
66+
67+
private fun commitAsync(tx: FragmentTransaction, allowStateLoss: Boolean) {
68+
if (allowStateLoss) {
69+
tx.commitAllowingStateLoss()
70+
} else {
71+
tx.commit()
72+
}
73+
}
74+
75+
private fun commitSync(tx: FragmentTransaction, allowStateLoss: Boolean) {
76+
if (allowStateLoss) {
77+
tx.commitNowAllowingStateLoss()
78+
} else {
79+
tx.commitNow()
80+
}
81+
}
82+
}

android/src/main/java/com/swmansion/rnscreens/gamma/stack/host/StackContainer.kt

Lines changed: 110 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import android.annotation.SuppressLint
44
import android.content.Context
55
import android.util.Log
66
import androidx.coordinatorlayout.widget.CoordinatorLayout
7+
import androidx.fragment.app.Fragment
78
import androidx.fragment.app.FragmentManager
89
import com.swmansion.rnscreens.gamma.helpers.FragmentManagerHelper
910
import com.swmansion.rnscreens.gamma.helpers.ViewIdGenerator
10-
import com.swmansion.rnscreens.gamma.helpers.createTransactionWithReordering
1111
import com.swmansion.rnscreens.gamma.stack.screen.StackScreen
1212
import com.swmansion.rnscreens.gamma.stack.screen.StackScreenFragment
1313
import com.swmansion.rnscreens.utils.RNSLog
@@ -17,15 +17,17 @@ import java.lang.ref.WeakReference
1717
internal class StackContainer(
1818
context: Context,
1919
private val delegate: WeakReference<StackContainerDelegate>,
20-
) : CoordinatorLayout(context) {
20+
) : CoordinatorLayout(context),
21+
FragmentManager.OnBackStackChangedListener {
2122
private var fragmentManager: FragmentManager? = null
2223

24+
private fun requireFragmentManager(): FragmentManager =
25+
checkNotNull(fragmentManager) { "[RNScreens] Attempt to use nullish FragmentManager" }
26+
2327
/**
2428
* Describes most up-to-date view of the stack. It might be different from
2529
* state kept by FragmentManager as this data structure is updated immediately,
2630
* while operations on fragment manager are scheduled.
27-
*
28-
* FIXME: In case of native-pop, this might be out of date!
2931
*/
3032
private val stackModel: MutableList<StackScreenFragment> = arrayListOf()
3133

@@ -34,6 +36,8 @@ internal class StackContainer(
3436
private val hasPendingOperations: Boolean
3537
get() = pendingPushOperations.isNotEmpty() || pendingPopOperations.isNotEmpty()
3638

39+
private val fragmentOpExecutor: FragmentOperationExecutor = FragmentOperationExecutor()
40+
3741
init {
3842
id = ViewIdGenerator.generateViewId()
3943
}
@@ -42,16 +46,28 @@ internal class StackContainer(
4246
RNSLog.d(TAG, "StackContainer [$id] attached to window")
4347
super.onAttachedToWindow()
4448

45-
fragmentManager =
46-
checkNotNull(FragmentManagerHelper.findFragmentManagerForView(this)) {
47-
"[RNScreens] Nullish fragment manager - can't run container operations"
48-
}
49+
setupFragmentManger()
4950

5051
// We run container update to handle any pending updates requested before container was
5152
// attached to window.
5253
performContainerUpdateIfNeeded()
5354
}
5455

56+
override fun onDetachedFromWindow() {
57+
super.onDetachedFromWindow()
58+
requireFragmentManager().removeOnBackStackChangedListener(this)
59+
fragmentManager = null
60+
}
61+
62+
internal fun setupFragmentManger() {
63+
fragmentManager =
64+
checkNotNull(FragmentManagerHelper.findFragmentManagerForView(this)) {
65+
"[RNScreens] Nullish fragment manager - can't run container operations"
66+
}.also {
67+
it.addOnBackStackChangedListener(this)
68+
}
69+
}
70+
5571
/**
5672
* Call this function to trigger container update
5773
*/
@@ -60,9 +76,7 @@ internal class StackContainer(
6076
// the call because we don't have valid fragmentManager yet.
6177
// Update will be eventually executed in onAttachedToWindow().
6278
if (hasPendingOperations && isAttachedToWindow) {
63-
val fragmentManager =
64-
checkNotNull(fragmentManager) { "[RNScreens] Fragment manager was null during stack container update" }
65-
performOperations(fragmentManager)
79+
performOperations(requireFragmentManager())
6680
}
6781
}
6882

@@ -75,64 +89,107 @@ internal class StackContainer(
7589
}
7690

7791
private fun performOperations(fragmentManager: FragmentManager) {
78-
// TODO: Handle case when we have pop & push of the same screen in single batch.
92+
val fragmentOps = applyOperationsAndComputeFragmentManagerOperations()
93+
fragmentOpExecutor.executeOperations(fragmentManager, fragmentOps, flushSync = false)
7994

80-
pendingPopOperations.forEach { performPopOperation(fragmentManager, it) }
81-
pendingPushOperations.forEach { performPushOperation(fragmentManager, it) }
82-
83-
pendingPopOperations.clear()
84-
pendingPushOperations.clear()
95+
dumpStackModel()
8596
}
8697

87-
private fun performPushOperation(
88-
fragmentManager: FragmentManager,
89-
operation: PushOperation,
90-
) {
91-
val transaction = fragmentManager.createTransactionWithReordering()
98+
private fun applyOperationsAndComputeFragmentManagerOperations(): List<FragmentOperation> {
99+
val fragmentOps = mutableListOf<FragmentOperation>()
92100

93-
val associatedFragment = StackScreenFragment(WeakReference(this), operation.screen)
94-
stackModel.add(associatedFragment)
101+
// Handle pop operations first.
102+
// We don't care about pop/push duplicates, as long as we don't let the main loop progress
103+
// before we commit all the transactions, FragmentManager will handle that for us.
95104

96-
transaction.add(this.id, associatedFragment)
105+
pendingPopOperations.forEach { operation ->
106+
val fragment =
107+
checkNotNull(stackModel.find { it.stackScreen === operation.screen }) {
108+
"[RNScreens] Unable to find a fragment to pop"
109+
}
97110

98-
// Don't add root screen to back stack to handle exiting from app.
99-
if (fragmentManager.fragments.isNotEmpty()) {
100-
transaction.addToBackStack(operation.screen.screenKey)
101-
}
111+
check(stackModel.size > 1) {
112+
"[RNScreens] Attempt to pop last screen from the stack"
113+
}
102114

103-
transaction.commitAllowingStateLoss()
104-
}
115+
fragmentOps.add(PopBackStackOp(fragment))
105116

106-
private fun performPopOperation(
107-
fragmentManager: FragmentManager,
108-
operation: PopOperation,
109-
) {
110-
val associatedFragment = stackModel.find { it.stackScreen === operation.screen }
111-
require(associatedFragment != null) {
112-
"[RNScreens] Unable to find a fragment to pop."
117+
check(stackModel.removeAt(stackModel.lastIndex) === fragment) {
118+
"[RNScreens] Attempt to pop non-top screen"
119+
}
120+
}
121+
122+
pendingPushOperations.forEach { operation ->
123+
val newFragment = createFragmentForScreen(operation.screen)
124+
fragmentOps.add(
125+
AddOp(
126+
newFragment,
127+
containerViewId = this.id,
128+
addToBackStack = stackModel.isNotEmpty(),
129+
),
130+
)
131+
stackModel.add(newFragment)
113132
}
114133

115-
val backStackEntryCount = fragmentManager.backStackEntryCount
116-
if (backStackEntryCount > 0) {
117-
fragmentManager.popBackStack(
118-
operation.screen.screenKey,
119-
FragmentManager.POP_BACK_STACK_INCLUSIVE,
134+
check(stackModel.isNotEmpty()) { "[RNScreens] Stack should never be empty after updates" }
135+
136+
// Top fragment is the primary navigation fragment.
137+
fragmentOps.add(SetPrimaryNavFragmentOp(stackModel.last()))
138+
139+
pendingPopOperations.clear()
140+
pendingPushOperations.clear()
141+
142+
return fragmentOps
143+
}
144+
145+
private fun onNativeFragmentPop(fragment: StackScreenFragment) {
146+
Log.d(TAG, "StackContainer [$id] natively removed fragment ${fragment.stackScreen.screenKey}")
147+
require(stackModel.remove(fragment)) { "[RNScreens] onNativeFragmentPop must be called with the fragment present in stack model" }
148+
check(stackModel.isNotEmpty()) { "[RNScreens] Stack model should not be empty after a native pop" }
149+
150+
val fragmentManager = requireFragmentManager()
151+
if (fragmentManager.primaryNavigationFragment === fragment) {
152+
// We need to update the primary navigation fragment, otherwise the fragment manager
153+
// will have invalid state, pointing to the dismissed fragment.
154+
fragmentOpExecutor.executeOperations(
155+
fragmentManager,
156+
listOf(SetPrimaryNavFragmentOp(stackModel.last())),
120157
)
121-
} else {
122-
// When fast refresh is used on root screen, we need to remove the screen manually.
123-
val transaction = fragmentManager.createTransactionWithReordering()
124-
transaction.remove(associatedFragment)
125-
transaction.commitNowAllowingStateLoss()
126158
}
159+
}
127160

128-
stackModel.remove(associatedFragment)
161+
private fun dumpStackModel() {
162+
Log.d(TAG, "StackContainer [$id] MODEL BEGIN")
163+
stackModel.forEach {
164+
Log.d(TAG, "${it.stackScreen.screenKey}")
165+
}
129166
}
130167

131-
internal fun onFragmentDestroyView(fragment: StackScreenFragment) {
132-
if (stackModel.remove(fragment) && !fragment.stackScreen.isNativelyDismissed) {
133-
Log.e(TAG, "[RNScreens] StackContainer natively popped a screen that was not in model!")
168+
private fun createFragmentForScreen(screen: StackScreen): StackScreenFragment =
169+
StackScreenFragment(screen).also {
170+
Log.d(TAG, "Created Fragment $it for screen ${screen.screenKey}")
171+
}
172+
173+
// This is called after special effects (animations) are dispatched
174+
override fun onBackStackChanged() = Unit
175+
176+
// This is called before the special effects (animations) are dispatched, however mid transaction!
177+
// Therefore make sure to not execute any action that might cause synchronous transaction synchronously
178+
// from this callback.
179+
override fun onBackStackChangeCommitted(
180+
fragment: Fragment,
181+
pop: Boolean,
182+
) {
183+
if (fragment !is StackScreenFragment) {
184+
Log.w(TAG, "[RNScreens] Unexpected type of fragment: ${fragment.javaClass.simpleName}")
185+
return
186+
}
187+
if (pop) {
188+
delegate.get()?.onScreenDismiss(fragment.stackScreen)
189+
if (stackModel.contains(fragment)) {
190+
onNativeFragmentPop(fragment)
191+
}
134192
}
135-
delegate.get()?.onScreenDismiss(fragment.stackScreen)
136193
}
137194

138195
companion object {

0 commit comments

Comments
 (0)