Skip to content

Commit ed43883

Browse files
authored
feat(Android, Stack v5): prevent native dismiss support (#3620)
## Description **Add initial implementation of prevent native dismiss on Android** This commit adds support for "prevent native dismiss" behavior on Android for stack screens. The added interface consists of prop on `StackScreen`: `preventNativeDismiss` and event `onNativeDismissPrevented`, which lets the programmer react to the native dismiss attempt. This implementation follows [RFC-0717](https://github.com/software-mansion/react-native-screens-labs/blob/main-issue-tracker/rfcs/0717-stack-prevent-dismiss.md). **It does not handle JS-side of the mechanism.** Closes software-mansion/react-native-screens-labs#881 ## Changes This is achieved by leveraging a couple of native mechanisms. First, I lean into usage of `OnBackPressedCallback` AndroidX API, which lets us handle this very gracefully & hopefully correct on many SDK levels. > [!note] > TODO: The behavior on lower SDK levels has not been tested yet. Now, for each `StackScreenFragment` we add a dedicated callback, which is enabled or disabled depending on the value. I do not add/remove the callbacks because their order in the `OnBackPressedDispatcher` matter, and we can add a callback only to the end of the list. > [!caution] > This implementation assumes that the fragments will move to `created` > state in the order they are in the fragment manager (stack order). > If this ever turns out to not be the case, we'll need to modify this > implementation. Beside above ☝🏻 the added code also introduces "top-fragment" mechanism. It is necessary to prevent non-top-fragment from blocking native dismiss. Likely, when we introduce proper attach/detach mechanism for "non visible" fragments we will be able to get rid of this new mechanism, but currently it is necessary. Please see commit description: 1212f36 **Add missing "exported custom direct event type constants"** I don't know how our events have worked so far. I thought that overriding this method is obligatory. Apparently not. Maybe its just hint for the React machinery. I'm not sure right now, but I've verified that this code is indeed called & React gathers this information. ## Visual documentation https://github.com/user-attachments/assets/cfd8ab97-55c6-4d81-b3ec-1b50b4e46580 ## Test plan These do not test "dynamic" case. It requires a bit more setup in `StackContainer` code that is too much outside of the scope of this PR. I'll work on it separately. `prevent-native-dismiss-single-stack` `prevent-native-dismiss-nested-stack` ## 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 61a7d8e commit ed43883

21 files changed

Lines changed: 629 additions & 12 deletions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ internal class FlushNowOp : FragmentOperation() {
5959

6060
internal class SetPrimaryNavFragmentOp(
6161
val fragment: StackScreenFragment,
62+
val onCommitCallback: Runnable? = null,
6263
) : FragmentOperation() {
6364
override fun execute(
6465
fragmentManager: FragmentManager,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ internal class FragmentOperationExecutor {
6363
) {
6464
fragmentManager.createTransactionWithReordering().let { tx ->
6565
tx.setPrimaryNavigationFragment(op.fragment)
66+
if (op.onCommitCallback != null) {
67+
tx.runOnCommit(op.onCommitCallback)
68+
}
6669
commitTransaction(tx, allowStateLoss = true, flushSync = false)
6770
}
6871
}

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

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,12 @@ internal class StackContainer(
134134
check(stackModel.isNotEmpty()) { "[RNScreens] Stack should never be empty after updates" }
135135

136136
// Top fragment is the primary navigation fragment.
137-
fragmentOps.add(SetPrimaryNavFragmentOp(stackModel.last()))
137+
val topStackFragment = stackModel.last()
138+
fragmentOps.add(
139+
SetPrimaryNavFragmentOp(topStackFragment, {
140+
updateTopFragment()
141+
}),
142+
)
138143

139144
pendingPopOperations.clear()
140145
pendingPushOperations.clear()
@@ -148,14 +153,17 @@ internal class StackContainer(
148153
check(stackModel.isNotEmpty()) { "[RNScreens] Stack model should not be empty after a native pop" }
149154

150155
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())),
157-
)
158-
}
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+
)
159167
}
160168

161169
private fun dumpStackModel() {
@@ -170,6 +178,14 @@ internal class StackContainer(
170178
Log.d(TAG, "Created Fragment $it for screen ${screen.screenKey}")
171179
}
172180

181+
private fun updateTopFragment() {
182+
// We try to handle situation where other fragments might be present.
183+
val fragments = requireFragmentManager().fragments.filterIsInstance<StackScreenFragment>()
184+
check(fragments.isNotEmpty()) { "[RNScreens] Empty fragment manager while attempting to update top fragment" }
185+
fragments.forEach { it.onResignTopFragment() }
186+
fragments.last().onBecomeTopFragment()
187+
}
188+
173189
// This is called after special effects (animations) are dispatched
174190
override fun onBackStackChanged() = Unit
175191

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.swmansion.rnscreens.gamma.stack.screen
2+
3+
import android.util.Log
4+
import androidx.activity.OnBackPressedCallback
5+
import androidx.lifecycle.Lifecycle
6+
import androidx.lifecycle.LifecycleEventObserver
7+
import androidx.lifecycle.LifecycleOwner
8+
9+
internal class PreventNativeDismissCallback(
10+
lifecycleOwner: LifecycleOwner,
11+
private val screen: StackScreen,
12+
canBeEnabled: Boolean,
13+
) : OnBackPressedCallback(false),
14+
LifecycleEventObserver,
15+
PreventNativeDismissChangeObserver {
16+
/**
17+
* A kill-switch whether this callback can even be enabled or not. If set to false,
18+
* no matter other conditions this callback won't be enabled.
19+
*/
20+
internal var canBeEnabled: Boolean = canBeEnabled
21+
set(value) {
22+
field = value
23+
determineEnabledStatus()
24+
}
25+
26+
private val shouldBeEnabled
27+
get() = canBeEnabled && screen.isPreventNativeDismissEnabled
28+
29+
init {
30+
lifecycleOwner.lifecycle.addObserver(this)
31+
}
32+
33+
override fun handleOnBackPressed() {
34+
Log.i("RNScreens", "PreventNativeDismissCallback called for screen ${screen.screenKey}")
35+
screen.onNativeDismissPrevented()
36+
}
37+
38+
override fun onStateChanged(
39+
source: LifecycleOwner,
40+
event: Lifecycle.Event,
41+
) {
42+
when (event) {
43+
Lifecycle.Event.ON_CREATE -> {
44+
screen.preventNativeDismissChangeObserver = this
45+
}
46+
47+
Lifecycle.Event.ON_START -> {
48+
determineEnabledStatus()
49+
}
50+
51+
Lifecycle.Event.ON_STOP -> {
52+
this.isEnabled = false
53+
}
54+
55+
Lifecycle.Event.ON_DESTROY -> {
56+
source.lifecycle.removeObserver(this)
57+
screen.preventNativeDismissChangeObserver = null
58+
}
59+
60+
else -> {
61+
}
62+
}
63+
}
64+
65+
override fun preventNativeDismissChanged(newValue: Boolean) {
66+
determineEnabledStatus()
67+
}
68+
69+
private fun determineEnabledStatus() {
70+
this.isEnabled = shouldBeEnabled
71+
}
72+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.swmansion.rnscreens.gamma.stack.screen
2+
3+
internal interface PreventNativeDismissChangeObserver {
4+
fun preventNativeDismissChanged(newValue: Boolean)
5+
}

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

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

24+
internal var isPreventNativeDismissEnabled: Boolean by Delegates.observable(false) { _, oldValue, newValue ->
25+
if (oldValue != newValue) {
26+
preventNativeDismissChangeObserver?.preventNativeDismissChanged(newValue)
27+
}
28+
}
29+
2430
internal var isNativelyDismissed = false
2531
set(value) {
2632
require(value) {
@@ -46,6 +52,11 @@ class StackScreen(
4652

4753
internal lateinit var eventEmitter: StackScreenEventEmitter
4854

55+
/**
56+
* Use this to set/unset the observer.
57+
*/
58+
internal var preventNativeDismissChangeObserver: PreventNativeDismissChangeObserver? = null
59+
4960
internal fun onViewManagerAddEventEmitters() {
5061
// When this is called from View Manager the view tag is already set
5162
check(id != NO_ID) { "[RNScreens] StackScreen must have its tag set when registering event emitters" }
@@ -62,6 +73,10 @@ class StackScreen(
6273
eventEmitter.emitOnDismiss(isNativelyDismissed)
6374
}
6475

76+
internal fun onNativeDismissPrevented() {
77+
eventEmitter.emitOnNativeDismissPrevented()
78+
}
79+
6580
override fun onLayout(
6681
changed: Boolean,
6782
l: Int,

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.swmansion.rnscreens.gamma.common.event.ViewAppearanceEventEmitter
66
import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidAppearEvent
77
import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidDisappearEvent
88
import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDismissEvent
9+
import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenNativeDismissPreventedEvent
910
import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenWillAppearEvent
1011
import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenWillDisappearEvent
1112

@@ -40,6 +41,15 @@ internal class StackScreenEventEmitter(
4041
)
4142
}
4243

44+
internal fun emitOnNativeDismissPrevented() {
45+
reactEventDispatcher.dispatchEvent(
46+
StackScreenNativeDismissPreventedEvent(
47+
surfaceId,
48+
viewTag,
49+
),
50+
)
51+
}
52+
4353
companion object {
4454
const val TAG = "StackScreenEventEmitter"
4555
}

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,24 @@ internal class StackScreenFragment(
1212
) : Fragment() {
1313
private var screenLifecycleEventEmitter: StackScreenAppearanceEventsEmitter? = null
1414

15+
/**
16+
* This holds the screen strongly for now. Beware of retain cycle.
17+
*
18+
* Since each StackScreenFragment owns a PreventNativeDismissCallback & adds it to the
19+
* OnBackPressedDispatcher the callback should be enabled only when the top fragment is this fragment.
20+
*/
21+
private val preventNativeDismissBackPressedCallback =
22+
PreventNativeDismissCallback(this, stackScreen, canBeEnabled = true)
23+
24+
private var isTopFragment: Boolean = false
25+
26+
override fun onCreate(savedInstanceState: Bundle?) {
27+
super.onCreate(savedInstanceState)
28+
requireActivity().onBackPressedDispatcher.addCallback(
29+
preventNativeDismissBackPressedCallback,
30+
)
31+
}
32+
1533
override fun onCreateView(
1634
inflater: LayoutInflater,
1735
container: ViewGroup?,
@@ -35,5 +53,30 @@ internal class StackScreenFragment(
3553
super.onDestroy()
3654
Log.i("StackScreenFragment", "onDestroy")
3755
stackScreen.onDismiss()
56+
preventNativeDismissBackPressedCallback.remove()
57+
}
58+
59+
/**
60+
* Notifies this fragment that it has become "top fragment" in its fragment manager.
61+
*
62+
* This function should be idempotent.
63+
*/
64+
internal fun onBecomeTopFragment() {
65+
if (isTopFragment) return
66+
67+
isTopFragment = true
68+
preventNativeDismissBackPressedCallback.canBeEnabled = true
69+
}
70+
71+
/**
72+
* Notifies this fragment that it is not longer the "top fragment" in its fragment manager.
73+
*
74+
* This function should be idempotent.
75+
*/
76+
internal fun onResignTopFragment() {
77+
if (!isTopFragment) return
78+
79+
isTopFragment = false
80+
preventNativeDismissBackPressedCallback.canBeEnabled = false
3881
}
3982
}

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import com.facebook.react.uimanager.ViewGroupManager
77
import com.facebook.react.uimanager.ViewManagerDelegate
88
import com.facebook.react.viewmanagers.RNSStackScreenManagerDelegate
99
import com.facebook.react.viewmanagers.RNSStackScreenManagerInterface
10+
import com.swmansion.rnscreens.gamma.helpers.makeEventRegistrationInfo
11+
import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidAppearEvent
12+
import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDidDisappearEvent
13+
import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenDismissEvent
14+
import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenNativeDismissPreventedEvent
15+
import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenWillAppearEvent
16+
import com.swmansion.rnscreens.gamma.stack.screen.event.StackScreenWillDisappearEvent
1017

1118
@ReactModule(name = StackScreenViewManager.REACT_CLASS)
1219
class StackScreenViewManager :
@@ -28,6 +35,16 @@ class StackScreenViewManager :
2835
view.onViewManagerAddEventEmitters()
2936
}
3037

38+
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any> =
39+
mutableMapOf(
40+
makeEventRegistrationInfo(StackScreenWillAppearEvent),
41+
makeEventRegistrationInfo(StackScreenWillDisappearEvent),
42+
makeEventRegistrationInfo(StackScreenDidAppearEvent),
43+
makeEventRegistrationInfo(StackScreenDidDisappearEvent),
44+
makeEventRegistrationInfo(StackScreenDismissEvent),
45+
makeEventRegistrationInfo(StackScreenNativeDismissPreventedEvent),
46+
)
47+
3148
override fun setActivityMode(
3249
view: StackScreen,
3350
value: String?,
@@ -49,6 +66,13 @@ class StackScreenViewManager :
4966
view.screenKey = value
5067
}
5168

69+
override fun setPreventNativeDismiss(
70+
view: StackScreen,
71+
value: Boolean,
72+
) {
73+
view.isPreventNativeDismissEnabled = value
74+
}
75+
5276
companion object {
5377
const val REACT_CLASS = "RNSStackScreen"
5478
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.swmansion.rnscreens.gamma.stack.screen.event
2+
3+
import com.swmansion.rnscreens.gamma.common.event.NamingAwareEventType
4+
5+
internal class StackScreenNativeDismissPreventedEvent(
6+
surfaceId: Int,
7+
viewId: Int,
8+
) : StackScreenLifecycleEvent<StackScreenNativeDismissPreventedEvent>(
9+
surfaceId,
10+
viewId,
11+
EVENT_NAME,
12+
EVENT_REGISTRATION_NAME,
13+
) {
14+
companion object : NamingAwareEventType {
15+
const val EVENT_NAME = "topNativeDismissPrevented"
16+
const val EVENT_REGISTRATION_NAME = "onNativeDismissPrevented"
17+
18+
override fun getEventName() = EVENT_NAME
19+
20+
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
21+
}
22+
}

0 commit comments

Comments
 (0)