Skip to content

Commit 0893e0d

Browse files
authored
feat(Android, Stack v5): make Transition API work (#3629)
## Description > [!note] > To support predictive back gesture on Android, we need to either use `Animator API` > or `Transition API` for screen animations. The [`Animation API` is not compatible with predictive back gesture](https://developer.android.com/guide/navigation/custom-back/support-animations#fragments). > In stack v4, with small exception for formsheets we've heavily relied on `Animation API` and we can > not reuse that code, unfortunately. This PR intends to make Transition API work on Android for us. Currently, when attempting to use it we have problems with: 1. disappearing shadows, 2. jumping content, 3. non-continuous animations (e.g. predictive back gesture), 4. native-pop missing animation. | Most of the bugs described above | | --- | | <video src="https://github.com/user-attachments/assets/7f5cf2e8-5b6a-41ec-8dc4-43e8e40578a0" alt="before" /> | | This video does not showcase issues no. 3 & 4, but trust be bro, they are there | This identifies root causes for each of those problems and introduces changes to fix them. It also adds an appropriate feature test and sets some Slide animation, so we can observe anything when testing. This is not meant to be the default animation or something. Just a placeholder animation. ## Changes I encourage reading through particular commits, as I tried to give each of them a meaningful message. Here I list most important changes: * CAUTION: bump native android library dependencies & add depencency on `transition` I've decided to bump appcompat, fragment deps. This is done mostly in hope to get rid of as many problems as possible with animations. Dependency on `transition` is added because `transition@1.5.0` is required for compatibility with predictive back gesture, so I want to have that version under control. `fragment` dependency is required to at least 1.7.0 by predictive back gesture. I've bumped other deps to ensure that they do not force lower version for `fragment` or `transition`. See: <https://developer.android.com/guide/navigation/custom-back/support-animations#use_existing_apis> * Set `isTransitionGroup` for `StackScreen` to enable Transition API usage This is crucial. This assures that whole StackScreen subtree (including the StackScreen itself) is animated together. W/o this the animation is glitchy - some of the views can disappear, some views can jump to different location mid-transition, **shadows are lost**. Setting this option fixes things. I wonder what will be impact of using this on SET & Reanimated integration. Hopefully it'll be possible to animate some views anyway. * Merge AddOp & SetPrimaryNavFragmentOp + add OnCommitCallbackFragmentOp This is kinda complex change. First, this commit removes `SetPrimaryNavFragmentOp` and merges it to `AddOp` creating new `AddAndSetAsPrimaryOp`. This is done, because previously, we've executed a `AddOp` that has been saved to back stack with e.g. some fragment A, and then we executed another transaction impacting fragment A by executing `SetPrimaryNavFragmentOp` & it has not been saved to back stack. This led to situation, where later popping A would not be interpreted as `pop` operation (explained below) by fragment manager and in consequence this would break predictive back gesture & native pop operation - after touch release animation would be completed immediately & not by smoothly animating to completion. Fragment manager interprets a batch of fragment transactions as "pop" only when last operation in a batch is "pop". Setting primary navigation fragment is not a "pop" operation. I haven't tested that in runtime, but by reading the code of fragment manager I've figured that we can not queue the `SetPrimaryNavFragmentOp` before other operations, because it'll trigger an exception that we try to make not-yet-attached fragment primary navigation fragment. However, maybe it's worth trying. Currently, I've gone with different approach. Now, I batch every add operation with set-primary-nav-fragment & add them to back stack together. *Thanks to that, we no longer manually manage the primary fragment. Fragment manager does that for us, when reverting stack transactions.* We still have a need to run a callback on transaction commit to update the top fragment (prevent native dismiss requirement), therefore exactly due to reasons explained above, I've added `OnCommitCallbackFragmentOp` and scheduled it before any other operations. It can not be batched with other operations, because a transaction with `runOnCommit` configured can NOT be added to back stack (callback can not be serialized). * Fix transitions in nested stacks Component views on new architecture receive their first layout after the view hierarchy is assembled and attached to window. Note, that in case of screen views & their sub-trees (including nested containers) this does not hold. The container is updated later, after React mounting transaction ends, therefore the views are attached to the window much later, hence their `isLaidOut` returns false, breaking transitions & animations. NOTE: Currently, screens attached even to the "top" (non-nested) container return false from `isLaidOut`! Exactly due to mechanism described above. I do not know whether it causes any problems, but potentially - yes. We should be aware of that. The fix here is to lay out the container. I need to do that synchronously, before we schedule the transitions. I'm not aware of API that allows me to achieve that, so I've introduced `StackContainerParent` interface. ## Visual documentation https://github.com/user-attachments/assets/39069381-4a99-445d-98be-9c2fa7a2c608 ## Checklist * [x] Included code example that can be used to test this change. * [x] Updated / created local changelog entries in relevant test files. * [x] For visual changes, included screenshots / GIFs / recordings documenting the change. * [x] For API changes, updated relevant public types. * [x] Ensured that CI passes
1 parent 3d45292 commit 0893e0d

16 files changed

Lines changed: 342 additions & 109 deletions

File tree

FabricExample/android/app/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ dependencies {
125125
// https://github.com/wix/Detox/issues/2848
126126
exclude group: 'com.google.android.material'
127127
}
128-
implementation "androidx.appcompat:appcompat:1.6.1" // Detox
128+
implementation "androidx.appcompat:appcompat:1.7.1" // Detox
129129

130130
// The version of react-native is set by the React Native Gradle Plugin
131131
implementation("com.facebook.react:react-android")

android/build.gradle

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,12 +216,13 @@ repositories {
216216

217217
dependencies {
218218
implementation 'com.facebook.react:react-native:+'
219-
implementation 'androidx.appcompat:appcompat:1.6.1'
220-
implementation 'androidx.fragment:fragment-ktx:1.6.1'
219+
implementation 'androidx.appcompat:appcompat:1.7.1'
220+
implementation 'androidx.fragment:fragment-ktx:1.8.9'
221+
implementation 'androidx.transition:transition-ktx:1.7.0'
221222
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0'
222223
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
223224
implementation 'com.google.android.material:material:1.13.0'
224-
implementation "androidx.core:core-ktx:1.8.0"
225+
implementation "androidx.core:core-ktx:1.17.0"
225226

226227
constraints {
227228
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") {

android/src/main/java/com/swmansion/rnscreens/ext/ViewExt.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,9 @@ internal fun View.findFragmentOrNull(): Fragment? =
4646
} catch (_: IllegalStateException) {
4747
null
4848
}
49+
50+
/**
51+
* This will fail in case the view has been measured with (0, 0) dimensions and laid out
52+
* before being attached to window.
53+
*/
54+
internal fun View.isMeasured(): Boolean = this.measuredWidth != 0 || this.measuredHeight != 0 || this.isLaidOut

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ internal sealed class FragmentOperation {
1010
)
1111
}
1212

13-
internal class AddOp(
13+
internal class AddAndSetAsPrimaryOp(
1414
val fragment: StackScreenFragment,
1515
val containerViewId: Int,
1616
val addToBackStack: Boolean,
@@ -20,7 +20,7 @@ internal class AddOp(
2020
fragmentManager: FragmentManager,
2121
executor: FragmentOperationExecutor,
2222
) {
23-
executor.executeAddOp(fragmentManager, this)
23+
executor.executeAddAndSetAsPrimaryOp(fragmentManager, this)
2424
}
2525
}
2626

@@ -57,14 +57,15 @@ internal class FlushNowOp : FragmentOperation() {
5757
}
5858
}
5959

60-
internal class SetPrimaryNavFragmentOp(
61-
val fragment: StackScreenFragment,
62-
val onCommitCallback: Runnable? = null,
60+
internal class OnCommitCallbackOp(
61+
val onCommitCallback: Runnable,
62+
val allowStateLoss: Boolean = true,
63+
val flushSync: Boolean = false,
6364
) : FragmentOperation() {
6465
override fun execute(
6566
fragmentManager: FragmentManager,
6667
executor: FragmentOperationExecutor,
6768
) {
68-
executor.executeSetPrimaryNavFragmentOp(fragmentManager, this)
69+
executor.executeOnCommitCallbackOp(fragmentManager, this)
6970
}
7071
}

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

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,16 @@ internal class FragmentOperationExecutor {
1717
}
1818
}
1919

20-
internal fun executeAddOp(
20+
internal fun executeAddAndSetAsPrimaryOp(
2121
fragmentManager: FragmentManager,
22-
op: AddOp,
22+
op: AddAndSetAsPrimaryOp,
2323
) {
2424
fragmentManager.createTransactionWithReordering().let { tx ->
25-
tx.add(op.containerViewId, op.fragment)
2625
if (op.addToBackStack) {
2726
tx.addToBackStack(op.fragment.stackScreen.screenKey)
2827
}
28+
tx.add(op.containerViewId, op.fragment)
29+
tx.setPrimaryNavigationFragment(op.fragment)
2930
commitTransaction(tx, op.allowStateLoss)
3031
}
3132
}
@@ -57,17 +58,17 @@ internal class FragmentOperationExecutor {
5758
fragmentManager.executePendingTransactions()
5859
}
5960

60-
internal fun executeSetPrimaryNavFragmentOp(
61+
internal fun executeOnCommitCallbackOp(
6162
fragmentManager: FragmentManager,
62-
op: SetPrimaryNavFragmentOp,
63+
op: OnCommitCallbackOp,
6364
) {
64-
fragmentManager.createTransactionWithReordering().let { tx ->
65-
tx.setPrimaryNavigationFragment(op.fragment)
66-
if (op.onCommitCallback != null) {
67-
tx.runOnCommit(op.onCommitCallback)
68-
}
69-
commitTransaction(tx, allowStateLoss = true, flushSync = false)
70-
}
65+
commitTransaction(
66+
fragmentManager
67+
.createTransactionWithReordering()
68+
.runOnCommit(op.onCommitCallback),
69+
allowStateLoss = op.allowStateLoss,
70+
flushSync = op.flushSync,
71+
)
7172
}
7273

7374
private fun commitTransaction(

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

Lines changed: 73 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import android.util.Log
66
import androidx.coordinatorlayout.widget.CoordinatorLayout
77
import androidx.fragment.app.Fragment
88
import androidx.fragment.app.FragmentManager
9+
import com.swmansion.rnscreens.ext.isMeasured
910
import com.swmansion.rnscreens.gamma.helpers.FragmentManagerHelper
1011
import com.swmansion.rnscreens.gamma.helpers.ViewIdGenerator
1112
import com.swmansion.rnscreens.gamma.stack.screen.StackScreen
@@ -24,6 +25,11 @@ internal class StackContainer(
2425
private fun requireFragmentManager(): FragmentManager =
2526
checkNotNull(fragmentManager) { "[RNScreens] Attempt to use nullish FragmentManager" }
2627

28+
/**
29+
* Will crash in case parent does not implement StackContainerParent interface.
30+
*/
31+
private fun containerParentOrNull(): StackContainerParent? = this.parent as StackContainerParent?
32+
2733
/**
2834
* Describes most up-to-date view of the stack. It might be different from
2935
* state kept by FragmentManager as this data structure is updated immediately,
@@ -37,6 +43,7 @@ internal class StackContainer(
3743
get() = pendingPushOperations.isNotEmpty() || pendingPopOperations.isNotEmpty()
3844

3945
private val fragmentOpExecutor: FragmentOperationExecutor = FragmentOperationExecutor()
46+
private val fragmentOps: MutableList<FragmentOperation> = arrayListOf()
4047

4148
init {
4249
id = ViewIdGenerator.generateViewId()
@@ -48,6 +55,15 @@ internal class StackContainer(
4855

4956
setupFragmentManger()
5057

58+
// Following line works with a couple of assumptions.
59+
// First, that this view is laid out by our parent view, which is a component view.
60+
// Component views on new architecture receive their first layout after the view hierarchy is
61+
// assembled and attached to window. Note, that in case of screen views & their subtrees
62+
// (including nested containers) this does not hold. The container is updated later, therefore
63+
// the views are attached to window much later ==> their isLaidOut returns false, breaking
64+
// transitions & animations.
65+
updateLaidOutFlagIfNeededAndPossible()
66+
5167
// We run container update to handle any pending updates requested before container was
5268
// attached to window.
5369
performContainerUpdateIfNeeded()
@@ -89,19 +105,39 @@ internal class StackContainer(
89105
}
90106

91107
private fun performOperations(fragmentManager: FragmentManager) {
92-
val fragmentOps = applyOperationsAndComputeFragmentManagerOperations()
108+
applyOperationsAndComputeFragmentManagerOperations()
93109
fragmentOpExecutor.executeOperations(fragmentManager, fragmentOps, flushSync = false)
94110

95111
dumpStackModel()
96112
}
97113

98-
private fun applyOperationsAndComputeFragmentManagerOperations(): List<FragmentOperation> {
99-
val fragmentOps = mutableListOf<FragmentOperation>()
114+
private fun applyOperationsAndComputeFragmentManagerOperations() {
115+
fragmentOps.clear()
100116

101117
// Handle pop operations first.
102118
// We don't care about pop/push duplicates, as long as we don't let the main loop progress
103119
// before we commit all the transactions, FragmentManager will handle that for us.
104120

121+
if (hasPendingOperations) {
122+
// Top fragment is the primary navigation fragment. If we're going to change anything
123+
// in stack model, then we also should update top fragment.
124+
//
125+
// This is added before other operations, to make sure that they are correctly classified
126+
// as pop/non-pop by fragment manager.
127+
// This relies on Fragment Manager internal behavior obviously. It classifies
128+
// whole batch of transactions as "pop" (argument later passed to `onBackStackChange` commited)
129+
// when last operation of the batch is "pop". Empty commit with only onCommit callback
130+
// attached is not a "pop" commit, therefore JS-pop commits have not been properly
131+
// recognized.
132+
fragmentOps.add(
133+
OnCommitCallbackOp(
134+
{ updateTopFragment() },
135+
allowStateLoss = true,
136+
flushSync = false,
137+
),
138+
)
139+
}
140+
105141
pendingPopOperations.forEach { operation ->
106142
val fragment =
107143
checkNotNull(stackModel.find { it.stackScreen === operation.screen }) {
@@ -121,8 +157,9 @@ internal class StackContainer(
121157

122158
pendingPushOperations.forEach { operation ->
123159
val newFragment = createFragmentForScreen(operation.screen)
160+
124161
fragmentOps.add(
125-
AddOp(
162+
AddAndSetAsPrimaryOp(
126163
newFragment,
127164
containerViewId = this.id,
128165
addToBackStack = stackModel.isNotEmpty(),
@@ -133,37 +170,20 @@ internal class StackContainer(
133170

134171
check(stackModel.isNotEmpty()) { "[RNScreens] Stack should never be empty after updates" }
135172

136-
// Top fragment is the primary navigation fragment.
137-
val topStackFragment = stackModel.last()
138-
fragmentOps.add(
139-
SetPrimaryNavFragmentOp(topStackFragment, {
140-
updateTopFragment()
141-
}),
142-
)
143-
144173
pendingPopOperations.clear()
145174
pendingPushOperations.clear()
146-
147-
return fragmentOps
148175
}
149176

150177
private fun onNativeFragmentPop(fragment: StackScreenFragment) {
151-
Log.d(TAG, "StackContainer [$id] natively removed fragment ${fragment.stackScreen.screenKey}")
152178
require(stackModel.remove(fragment)) { "[RNScreens] onNativeFragmentPop must be called with the fragment present in stack model" }
153179
check(stackModel.isNotEmpty()) { "[RNScreens] Stack model should not be empty after a native pop" }
154180

155-
val fragmentManager = requireFragmentManager()
156-
check(fragmentManager.primaryNavigationFragment === fragment) { "[RNScreens] primaryNavFragment should be the one popped" }
157-
// We need to update the primary navigation fragment, otherwise the fragment manager
158-
// will have invalid state, pointing to the dismissed fragment.
159-
fragmentOpExecutor.executeOperations(
160-
fragmentManager,
161-
listOf(
162-
SetPrimaryNavFragmentOp(stackModel.last(), {
163-
updateTopFragment()
164-
}),
165-
),
166-
)
181+
// The primary navigation fragment should be updated when popping backstack by FragmentManager
182+
// reversing the back stack record. At this point we need to just update the top fragment.
183+
check(requireFragmentManager().primaryNavigationFragment !== fragment) {
184+
"[RNScreens] Primary navigation fragment not updated by native pop"
185+
}
186+
updateTopFragment()
167187
}
168188

169189
private fun dumpStackModel() {
@@ -180,10 +200,27 @@ internal class StackContainer(
180200

181201
private fun updateTopFragment() {
182202
// We try to handle situation where other fragments might be present.
183-
val fragments = requireFragmentManager().fragments.filterIsInstance<StackScreenFragment>()
203+
val fragmentManager = requireFragmentManager()
204+
val fragments = fragmentManager.fragments.filterIsInstance<StackScreenFragment>()
184205
check(fragments.isNotEmpty()) { "[RNScreens] Empty fragment manager while attempting to update top fragment" }
185206
fragments.forEach { it.onResignTopFragment() }
186207
fragments.last().onBecomeTopFragment()
208+
209+
// This assumes that the updateTopFragment is called already after primary nav frag. is updated.
210+
// If this needs to be changed in the future, just remove this assertion.
211+
check(fragmentManager.primaryNavigationFragment === fragments.last()) {
212+
"[RNScreens] Top fragment different from primary navigation fragment"
213+
}
214+
}
215+
216+
/**
217+
* If this.isLaidOut == false, then SpecialEffectsController won't perform animations / transitions.
218+
* This function tries to ensure that the container is laid out if it already has layout information.
219+
*/
220+
private fun updateLaidOutFlagIfNeededAndPossible() {
221+
if (isAttachedToWindow && isMeasured() && !isLaidOut && !isInLayout) {
222+
containerParentOrNull()?.layoutContainerNow()
223+
}
187224
}
188225

189226
// This is called after special effects (animations) are dispatched
@@ -200,8 +237,14 @@ internal class StackContainer(
200237
Log.w(TAG, "[RNScreens] Unexpected type of fragment: ${fragment.javaClass.simpleName}")
201238
return
202239
}
203-
if (pop) {
204-
delegate.get()?.onScreenDismiss(fragment.stackScreen)
240+
241+
// This callback is called for every fragment involved in the back stack change, even
242+
// if its not added or removed, but e.g. set as a primary navigation fragment, hence
243+
// we need to check whether the fragment is actually being removed.
244+
// I avoid using `pop` parameter here, because transaction might not be classified as `pop`
245+
// and still include fragment removal operations.
246+
if (fragment.isRemoving) {
247+
delegate.get()?.onScreenDismissCommitted(fragment.stackScreen)
205248
if (stackModel.contains(fragment)) {
206249
onNativeFragmentPop(fragment)
207250
}

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 onScreenDismiss(stackScreen: StackScreen)
6+
fun onScreenDismissCommitted(stackScreen: StackScreen)
77
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.swmansion.rnscreens.gamma.stack.host
2+
3+
/**
4+
* Parent view of `StackContainer` is expected to implement this interface.
5+
*/
6+
internal interface StackContainerParent {
7+
fun layoutContainerNow()
8+
}

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ class StackHost(
1919
private val reactContext: ThemedReactContext,
2020
) : ViewGroup(reactContext),
2121
UIManagerListener,
22-
StackContainerDelegate {
22+
StackContainerDelegate,
23+
StackContainerParent {
2324
internal val renderedScreens: ArrayList<StackScreen> = arrayListOf()
2425
private val container = StackContainer(reactContext, WeakReference(this))
2526
private val containerUpdateCoordinator = StackContainerUpdateCoordinator()
@@ -88,7 +89,7 @@ class StackHost(
8889
}
8990
}
9091

91-
override fun onScreenDismiss(stackScreen: StackScreen) {
92+
override fun onScreenDismissCommitted(stackScreen: StackScreen) {
9293
if (stackScreen.activityMode == StackScreen.ActivityMode.ATTACHED) {
9394
stackScreen.isNativelyDismissed = true
9495
}
@@ -112,6 +113,16 @@ class StackHost(
112113
container.layout(l, t, r, b)
113114
}
114115

116+
override fun layoutContainerNow() {
117+
if (measuredWidth != container.measuredWidth || measuredHeight != container.measuredHeight) {
118+
container.measure(
119+
MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY),
120+
MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY),
121+
)
122+
}
123+
container.layout(left, top, right, bottom)
124+
}
125+
115126
override fun didMountItems(uiManager: UIManager) {
116127
containerUpdateCoordinator.executePendingOperationsIfNeeded(container, renderedScreens)
117128
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ class StackScreen(
2121
ATTACHED,
2222
}
2323

24+
init {
25+
// Needed when Transition API is in use to ensure that shadows do not disappear,
26+
// views do not jump around the screen and whole sub-tree is animated as a whole.
27+
isTransitionGroup = true
28+
}
29+
2430
internal var isPreventNativeDismissEnabled: Boolean by Delegates.observable(false) { _, oldValue, newValue ->
2531
if (oldValue != newValue) {
2632
preventNativeDismissChangeObserver?.preventNativeDismissChanged(newValue)

0 commit comments

Comments
 (0)