Skip to content

Commit ce44512

Browse files
kligarskikkafar
andauthored
feat(Android, Stack v5): handle preloading screens (#3576)
## Description Adds support for preloading screens. Allows for multiple operations in one React transition. Closes software-mansion/react-native-screens-labs#819. ### Details #### Preload As described in the description of #3531, previous approach to handling detached screens did not work due to how `react-native` works when children views are reordered and inserted again. To mitigate this problem, we decided to change the approach. Now, `StackScreen`s will be rendered inside `StackHost` in the following order: 1. First, all attached screens are rendered in the order that reflects current stack state (so ABC always matches to stack with A at the bottom and C at the top) 2. Then, all detached screens are rendered. This change has 2 main advantages: 1. We will never move a screen in `attached` state therefore we will avoid React removing the screen from parent view when trying to reinsert it. 2. In result of the above, there will never be a situation where an attached screen will be popped and pushed in one transaction (unless someone explicitly does so) therefore we don't need to implement a mechanism for cancelling out those operations. There is one drawback: this approach will not help with fixing the inspector, it needs to be handled separately. #### Multiple operations in one React transaction. When multiple operations are performed in one transaction, we can't rely on the order of updates from React. For example, if we have `ABCdefg` state (lowercase means detached state) and we perform at the same time: ``` pop(C) pop(B) push(G) push(E) ``` we end up with `AGEbcdf` state. This information is passed to native side via those operations: ``` remove(G) remove(E) insert(G, 1) insert(E, 2) update(B, 'detached') update(C, 'detached') update(E, 'attached') update(G, 'attached') ``` If we tried to translate those operations directly without any reordering, we would get: ``` pop(B) pop(C) push(E) push(G) ``` 1. Order of pops is incorrect - we need to pop the top screen (C) first. 2. Order of pushes is incorrect - we would get `AEG...` instead of desired `AGE...`. Therefore we need a mechanism to synchronize the order of operations with the state of the stack. We can use `StackHost`'s children to determine this - if we know that our children are `AGEbcdf`, we know that C needs to be popped before B and G needs to be pushed before E. ## Changes - add `StackContainerUpdateCoordinator` to ensure correct order of push & pop operations - add separate push & pop queues to `StackContainer` to ensure order (first pops, then pushes) + in the future, allow for simple detection of necessity of using replace animation - add batch action to `StackContainer` - create `Test3576` to test batch operations in new stack implementation ## Before & after - visual documentation ### Preload on TestScreenStack | Before | After | | --- | --- | | <video src="https://github.com/user-attachments/assets/52ab058b-7bec-4141-b1d7-939e0ba0619c" /> | <video src="https://github.com/user-attachments/assets/75641233-0bf7-4e2b-a430-be3d71371c90" /> | ### Multiple operations in one transaction https://github.com/user-attachments/assets/00c61bde-3277-4953-9a06-222953c320bb ## Test plan 1. Run `TestScreenStack`. Preload B, push A, push B. Hierarchy should contain screens ABA. 4. Run `Test3576`. Run scenario 1. ## Checklist - [x] Included code example that can be used to test this change. - [x] For visual changes, included screenshots / GIFs / recordings documenting the change. - [ ] Ensured that CI passes --------- Co-authored-by: Kacper Kafara <kacperkafara@gmail.com>
1 parent 0577b68 commit ce44512

34 files changed

Lines changed: 491 additions & 142 deletions

android/src/main/java/com/swmansion/rnscreens/gamma/common/BaseEventEmitter.kt renamed to android/src/main/java/com/swmansion/rnscreens/gamma/common/event/BaseEventEmitter.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.swmansion.rnscreens.gamma.common
1+
package com.swmansion.rnscreens.gamma.common.event
22

33
import com.facebook.react.bridge.ReactContext
44
import com.facebook.react.uimanager.UIManagerHelper

android/src/main/java/com/swmansion/rnscreens/gamma/common/NamingAwareEventType.kt renamed to android/src/main/java/com/swmansion/rnscreens/gamma/common/event/NamingAwareEventType.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.swmansion.rnscreens.gamma.common
1+
package com.swmansion.rnscreens.gamma.common.event
22

33
internal interface NamingAwareEventType {
44
/**
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.swmansion.rnscreens.gamma.common.event
2+
3+
internal interface ViewAppearanceEventEmitter {
4+
fun emitOnWillAppear()
5+
6+
fun emitOnDidAppear()
7+
8+
fun emitOnWillDisappear()
9+
10+
fun emitOnDidDisappear()
11+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.swmansion.rnscreens.gamma.helpers
22

3-
import com.swmansion.rnscreens.gamma.common.NamingAwareEventType
3+
import com.swmansion.rnscreens.gamma.common.event.NamingAwareEventType
44

55
internal fun makeEventRegistrationInfo(event: NamingAwareEventType): Pair<String, HashMap<String, String>> =
66
event.getEventName() to hashMapOf("registrationName" to event.getEventRegistrationName())

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

Lines changed: 44 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
44
import android.content.Context
55
import androidx.coordinatorlayout.widget.CoordinatorLayout
66
import androidx.fragment.app.FragmentManager
7-
import androidx.fragment.app.FragmentTransaction
87
import com.swmansion.rnscreens.gamma.helpers.FragmentManagerHelper
98
import com.swmansion.rnscreens.gamma.helpers.ViewIdGenerator
109
import com.swmansion.rnscreens.gamma.helpers.createTransactionWithReordering
@@ -13,16 +12,6 @@ import com.swmansion.rnscreens.gamma.stack.screen.StackScreenFragment
1312
import com.swmansion.rnscreens.utils.RNSLog
1413
import java.lang.ref.WeakReference
1514

16-
internal sealed class StackOperation
17-
18-
internal class AddOperation(
19-
val screen: StackScreen,
20-
) : StackOperation()
21-
22-
internal class PopOperation(
23-
val screen: StackScreen,
24-
) : StackOperation()
25-
2615
@SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated.
2716
internal class StackContainer(
2817
context: Context,
@@ -31,7 +20,11 @@ internal class StackContainer(
3120
private var fragmentManager: FragmentManager? = null
3221

3322
private val stackScreenFragments: MutableList<StackScreenFragment> = arrayListOf()
34-
private val pendingOperationQueue: MutableList<StackOperation> = arrayListOf()
23+
24+
private val pendingPopOperations: MutableList<PopOperation> = arrayListOf()
25+
private val pendingPushOperations: MutableList<PushOperation> = arrayListOf()
26+
private val hasPendingOperations: Boolean
27+
get() = pendingPushOperations.isNotEmpty() || pendingPopOperations.isNotEmpty()
3528

3629
init {
3730
id = ViewIdGenerator.generateViewId()
@@ -58,91 +51,75 @@ internal class StackContainer(
5851
// If container update is requested before container is attached to window, we ignore
5952
// the call because we don't have valid fragmentManager yet.
6053
// Update will be eventually executed in onAttachedToWindow().
61-
if (pendingOperationQueue.isNotEmpty() && isAttachedToWindow) {
54+
if (hasPendingOperations && isAttachedToWindow) {
6255
val fragmentManager =
6356
checkNotNull(fragmentManager) { "[RNScreens] Fragment manager was null during stack container update" }
64-
performOperations(fragmentManager, false)
57+
performOperations(fragmentManager)
6558
}
6659
}
6760

68-
internal fun enqueueAddOperation(stackScreen: StackScreen) {
69-
pendingOperationQueue.add(AddOperation(stackScreen))
61+
internal fun enqueuePushOperation(stackScreen: StackScreen) {
62+
pendingPushOperations.add(PushOperation(stackScreen))
7063
}
7164

7265
internal fun enqueuePopOperation(stackScreen: StackScreen) {
73-
pendingOperationQueue.add(PopOperation(stackScreen))
66+
pendingPopOperations.add(PopOperation(stackScreen))
7467
}
7568

76-
private fun performOperations(
77-
fragmentManager: FragmentManager,
78-
commitSync: Boolean = false,
79-
) {
80-
val transaction = fragmentManager.createTransactionWithReordering()
81-
pendingOperationQueue.forEach { operation -> performOperation(fragmentManager, transaction, operation) }
69+
private fun performOperations(fragmentManager: FragmentManager) {
70+
pendingPopOperations.forEach { performPopOperation(fragmentManager, it) }
71+
pendingPushOperations.forEach { performPushOperation(fragmentManager, it) }
8272

83-
// TODO: refactor + should every push be added as separate back stack entry to maintain history?
84-
val lastPushScreenKey =
85-
pendingOperationQueue
86-
.asReversed()
87-
.filter { it is AddOperation }
88-
.map { operation -> (operation as AddOperation).screen.screenKey }
89-
.firstOrNull()
90-
91-
pendingOperationQueue.clear()
92-
93-
// Pop operation does not use transaction
94-
if (!transaction.isEmpty) {
95-
require(lastPushScreenKey != null) { "[RNScreens] Expected non-null screenKey for back stack entry." }
96-
97-
// don't add root to back stack to handle exiting from app.
98-
if (fragmentManager.fragments.isNotEmpty()) {
99-
transaction.addToBackStack(lastPushScreenKey)
100-
}
101-
102-
if (commitSync) {
103-
// TODO: will not work with back stack
104-
transaction.commitNowAllowingStateLoss()
105-
} else {
106-
transaction.commitAllowingStateLoss()
107-
}
108-
}
73+
pendingPopOperations.clear()
74+
pendingPushOperations.clear()
10975
}
11076

111-
private fun performOperation(
77+
private fun performPushOperation(
11278
fragmentManager: FragmentManager,
113-
transaction: FragmentTransaction,
114-
operation: StackOperation,
79+
operation: PushOperation,
11580
) {
116-
when (operation) {
117-
is AddOperation -> performAddOperation(transaction, operation)
118-
is PopOperation -> performPopOperation(fragmentManager, operation)
119-
}
120-
}
81+
val transaction = fragmentManager.createTransactionWithReordering()
12182

122-
private fun performAddOperation(
123-
transaction: FragmentTransaction,
124-
operation: AddOperation,
125-
) {
12683
val associatedFragment = StackScreenFragment(WeakReference(this), operation.screen)
12784
stackScreenFragments.add(associatedFragment)
85+
12886
transaction.add(this.id, associatedFragment)
87+
88+
// Don't add root screen to back stack to handle exiting from app.
89+
if (fragmentManager.fragments.isNotEmpty()) {
90+
transaction.addToBackStack(operation.screen.screenKey)
91+
}
92+
93+
transaction.commitAllowingStateLoss()
12994
}
13095

13196
private fun performPopOperation(
13297
fragmentManager: FragmentManager,
13398
operation: PopOperation,
13499
) {
135-
val backStackEntryCount = fragmentManager.backStackEntryCount
136-
require(backStackEntryCount > 0) { "[RNScreens] Back stack must not be empty." }
100+
val associatedFragment = stackScreenFragments.find { it.stackScreen === operation.screen }
101+
require(associatedFragment != null) {
102+
"[RNScreens] Unable to find a fragment to pop."
103+
}
137104

138-
val lastBackStackEntry = fragmentManager.getBackStackEntryAt(backStackEntryCount - 1)
139-
require(lastBackStackEntry.name == operation.screen.screenKey) { "[RNScreens] Popping is supported only for top screen." }
105+
val backStackEntryCount = fragmentManager.backStackEntryCount
106+
if (backStackEntryCount > 0) {
107+
fragmentManager.popBackStack(
108+
operation.screen.screenKey,
109+
FragmentManager.POP_BACK_STACK_INCLUSIVE,
110+
)
111+
} else {
112+
// When fast refresh is used on root screen, we need to remove the screen manually.
113+
val transaction = fragmentManager.createTransactionWithReordering()
114+
transaction.remove(associatedFragment)
115+
transaction.commitNowAllowingStateLoss()
116+
}
140117

141-
fragmentManager.popBackStack(lastBackStackEntry.name, FragmentManager.POP_BACK_STACK_INCLUSIVE)
118+
stackScreenFragments.remove(associatedFragment)
142119
}
143120

144121
internal fun onFragmentDestroyView(fragment: StackScreenFragment) {
145-
delegate.get()?.onDismiss(fragment.stackScreen)
122+
delegate.get()?.onScreenDismiss(fragment.stackScreen)
146123
}
147124

148125
companion object {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ package com.swmansion.rnscreens.gamma.stack.host
33
import com.swmansion.rnscreens.gamma.stack.screen.StackScreen
44

55
internal interface StackContainerDelegate {
6-
fun onDismiss(stackScreen: StackScreen)
6+
fun onScreenDismiss(stackScreen: StackScreen)
77
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.swmansion.rnscreens.gamma.stack.host
2+
3+
import com.swmansion.rnscreens.gamma.stack.screen.StackScreen
4+
5+
internal class StackContainerUpdateCoordinator {
6+
private val pendingPushOperations: MutableList<PushOperation> = arrayListOf()
7+
private val pendingPopOperations: MutableList<PopOperation> = arrayListOf()
8+
private val hasPendingOperations: Boolean
9+
get() = pendingPushOperations.isNotEmpty() || pendingPopOperations.isNotEmpty()
10+
11+
internal fun addPushOperation(stackScreen: StackScreen) {
12+
pendingPushOperations.add(PushOperation(stackScreen))
13+
}
14+
15+
internal fun addPopOperation(stackScreen: StackScreen) {
16+
pendingPopOperations.add(PopOperation(stackScreen))
17+
}
18+
19+
internal fun executePendingOperationsIfNeeded(
20+
container: StackContainer,
21+
renderedScreens: List<StackScreen>,
22+
) {
23+
if (!hasPendingOperations) {
24+
return
25+
}
26+
27+
pendingPopOperations
28+
.map { Pair(renderedScreens.indexOf(it.screen), it) }
29+
.sortedBy { it.first }
30+
.asReversed()
31+
.forEach { (_, operation) -> container.enqueuePopOperation(operation.screen) }
32+
33+
pendingPushOperations
34+
.map { Pair(renderedScreens.indexOf(it.screen), it) }
35+
.sortedBy { it.first }
36+
.forEach { (_, operation) -> container.enqueuePushOperation(operation.screen) }
37+
38+
container.performContainerUpdateIfNeeded()
39+
40+
pendingPopOperations.clear()
41+
pendingPushOperations.clear()
42+
}
43+
}

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

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class StackHost(
2121
StackContainerDelegate {
2222
internal val renderedScreens: ArrayList<StackScreen> = arrayListOf()
2323
private val container = StackContainer(reactContext, WeakReference(this))
24+
private val containerUpdateCoordinator = StackContainerUpdateCoordinator()
2425

2526
init {
2627
addView(container)
@@ -44,50 +45,47 @@ class StackHost(
4445
) {
4546
renderedScreens.add(index, stackScreen)
4647
stackScreen.stackHost = WeakReference(this)
47-
enqueueAddOperationToContainerIfNeeded(stackScreen)
48+
addPushOperationIfNeeded(stackScreen)
4849
}
4950

5051
internal fun unmountReactSubviewAt(index: Int) {
5152
val removedScreen = renderedScreens.removeAt(index)
52-
enqueuePopOperationToContainerIfNeeded(removedScreen)
53+
addPopOperationIfNeeded(removedScreen)
5354
}
5455

5556
internal fun unmountReactSubview(reactSubview: StackScreen) {
5657
renderedScreens.remove(reactSubview)
57-
enqueuePopOperationToContainerIfNeeded(reactSubview)
58+
addPopOperationIfNeeded(reactSubview)
5859
}
5960

6061
internal fun unmountAllReactSubviews() {
6162
renderedScreens.asReversed().forEach {
62-
enqueuePopOperationToContainerIfNeeded(it)
63+
addPopOperationIfNeeded(it)
6364
}
6465
renderedScreens.clear()
6566
}
6667

67-
private fun enqueueAddOperationToContainerIfNeeded(stackScreen: StackScreen) {
68+
private fun addPushOperationIfNeeded(stackScreen: StackScreen) {
6869
if (stackScreen.activityMode == StackScreen.ActivityMode.ATTACHED) {
69-
container.enqueueAddOperation(stackScreen)
70+
containerUpdateCoordinator.addPushOperation(stackScreen)
7071
}
7172
}
7273

73-
private fun enqueuePopOperationToContainerIfNeeded(stackScreen: StackScreen) {
74+
private fun addPopOperationIfNeeded(stackScreen: StackScreen) {
7475
if (stackScreen.activityMode == StackScreen.ActivityMode.ATTACHED && !stackScreen.isNativelyDismissed) {
75-
container.enqueuePopOperation(stackScreen)
76+
// This shouldn't happen in typical scenarios but it can happen with fast-refresh.
77+
containerUpdateCoordinator.addPopOperation(stackScreen)
7678
}
7779
}
7880

7981
internal fun stackScreenChangedActivityMode(stackScreen: StackScreen) {
8082
when (stackScreen.activityMode) {
81-
StackScreen.ActivityMode.DETACHED -> container.enqueuePopOperation(stackScreen)
82-
StackScreen.ActivityMode.ATTACHED -> container.enqueueAddOperation(stackScreen)
83+
StackScreen.ActivityMode.DETACHED -> containerUpdateCoordinator.addPopOperation(stackScreen)
84+
StackScreen.ActivityMode.ATTACHED -> containerUpdateCoordinator.addPushOperation(stackScreen)
8385
}
8486
}
8587

86-
override fun onDismiss(stackScreen: StackScreen) {
87-
if (stackScreen.activityMode == StackScreen.ActivityMode.ATTACHED) {
88-
stackScreen.isNativelyDismissed = true
89-
}
90-
}
88+
override fun onScreenDismiss(stackScreen: StackScreen) = Unit
9189

9290
override fun onMeasure(
9391
widthMeasureSpec: Int,
@@ -108,7 +106,7 @@ class StackHost(
108106
}
109107

110108
override fun didMountItems(uiManager: UIManager) {
111-
container.performContainerUpdateIfNeeded()
109+
containerUpdateCoordinator.executePendingOperationsIfNeeded(container, renderedScreens)
112110
}
113111

114112
override fun willDispatchViewUpdates(uiManager: UIManager) = Unit
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.swmansion.rnscreens.gamma.stack.host
2+
3+
import com.swmansion.rnscreens.gamma.stack.screen.StackScreen
4+
5+
internal sealed class StackOperation
6+
7+
internal class PushOperation(
8+
val screen: StackScreen,
9+
) : StackOperation()
10+
11+
internal class PopOperation(
12+
val screen: StackScreen,
13+
) : StackOperation()

android/src/main/java/com/swmansion/rnscreens/gamma/stack/screen/StackScreen.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.swmansion.rnscreens.gamma.stack.screen
22

33
import android.annotation.SuppressLint
44
import android.view.ViewGroup
5+
import androidx.lifecycle.LifecycleOwner
56
import com.facebook.react.uimanager.ThemedReactContext
67
import com.swmansion.rnscreens.gamma.stack.host.StackHost
78
import java.lang.ref.WeakReference
@@ -47,6 +48,16 @@ class StackScreen(
4748
eventEmitter = StackScreenEventEmitter(reactContext, id)
4849
}
4950

51+
internal fun createAppearanceEventsEmitter(viewLifecycleOwner: LifecycleOwner) =
52+
StackScreenAppearanceEventsEmitter(viewLifecycleOwner.lifecycle, eventEmitter)
53+
54+
internal fun onDismiss() {
55+
if (activityMode == ActivityMode.ATTACHED) {
56+
isNativelyDismissed = true
57+
}
58+
eventEmitter.emitOnDismiss(isNativelyDismissed)
59+
}
60+
5061
override fun onLayout(
5162
changed: Boolean,
5263
l: Int,

0 commit comments

Comments
 (0)