diff --git a/.gitignore b/.gitignore
index cb518114..aba67d8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ build/
# Properties
local.properties
+local.env
settings.xml
# Idea
diff --git a/Writerside/cfg/buildprofiles.xml b/Writerside/cfg/buildprofiles.xml
index 43c4b333..c930b0fb 100644
--- a/Writerside/cfg/buildprofiles.xml
+++ b/Writerside/cfg/buildprofiles.xml
@@ -1,19 +1,22 @@
-
+
- false
- https://github.com/ikarenkov/Modo
+
+ false
- true
- https://github.com/ikarenkov/Modo/blob/dev/Writerside/
+
+ https://github.com/ikarenkov/Modo
- true
- GitHub
- %github_url%
-
+
+ true
+ https://github.com/ikarenkov/Modo/edit/dev/Writerside/
+
+ true
+ GitHub
+ %github_url%
diff --git a/Writerside/topics/Lifecycle.md b/Writerside/topics/Lifecycle.md
index ba61bb7f..aa133895 100644
--- a/Writerside/topics/Lifecycle.md
+++ b/Writerside/topics/Lifecycle.md
@@ -1,90 +1,263 @@
# Lifecycle
+
+
This article covers the lifetime of screen instances and their integration with the Android Lifecycle.
## Screen Instance Lifecycle
-The lifetime of a screen instance is guaranteed to match the application (process) lifetime when you integrate Modo using built-in functions such as
-`Modo.rememberRootScreen`. Regardless of how many times a screen is recomposed, or whether the activity and/or fragment is recreated, the screen
-instance remains consistent. This allows you to safely inject the screen instance into your DI container.
+Screen instances in Modo have a long lifetime:
-## Android Lifecycle Integration
+- Screen instances live as long as your app process (not tied to Activity or Fragment lifecycle)
+- The same instance survives recomposition and configuration changes (rotation, language change, etc.)
+- Safe to inject into your DI container if its lifetime is shorter or equal to the screen
-Modo provides seamless [integration](%github_code_url%/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt)
-with a Android Lifecycle for your screens.
+This is guaranteed when using `Modo.rememberRootScreen()` and similar built-in functions.
-You can use `LocalLifecycleOwner` inside `Screen.Content` to access the lifecycle of a screen. This will return the nearest screen's lifecycle owner.
+## Lifecycle Basics
+
+Modo provides seamless [integration](%github_code_url%/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt)
+with Android Lifecycle. Each screen gets its own `LifecycleOwner` that can be retrieved by using `LocalLifecycleOwner`:
```kotlin
-class SampleScreen : Screen {
- override fun Content(modifier: Modifier) {
- val lifecycleOwner = LocalLifecycleOwner.current
- // Use lifecycleOwner to observe lifecycle events
+// Access via LocalLifecycleOwner (standard Compose API)
+val lifecycleOwner = LocalLifecycleOwner.current
+val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState()
+
+// Observe lifecycle events
+DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_RESUME -> {
+ // Screen is ready for interactions
+ }
+ Lifecycle.Event.ON_PAUSE -> {
+ // Screen is hiding
+ }
+ else -> {}
+ }
}
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
```
-### Lifecycle States and Events
+{ collapsible="true" collapsed-title="How to access lifecycle in your screen" }
+
+Screen lifecycle is controlled by three main factors:
+
+- **Composition state**: Lifecycle responds to entering/leaving composition
+- **Parent lifecycle**: Child state never exceeds parent state and can be updated by a parent (see
+ [Parent-Child Lifecycle Coordination](#parent-child-lifecycle-coordination))
+- **Transitions**: Screen can be resumed only after animations are complete (see [Transition Lifecycle Control](#transition-lifecycle-control))
+
+Each screen progresses through the standard Android Lifecycle states:
+
+
+
+ | State |
+ Meaning |
+
+
+ | INITIALIZED |
+ The screen is constructed (instance created) but has never been displayed. |
+
+
+ | CREATED |
+ The screen was displayed at least once. |
+
+
+ | STARTED |
+ The screen is in composition. |
+
+
+ | RESUMED |
+ Ready for user interaction. The screen is STARTED, visible, and all transitions are complete. |
+
+
+ | DESTROYED |
+ The screen is removed from the navigation graph, and all resources are cleaned up. |
+
+
+
+Screens move through states sequentially. Each transition happens when something changes in the screen's lifecycle:
+
+```mermaid
+sequenceDiagram
+ participant DESTROYED
+ participant INITIALIZED
+ participant CREATED
+ participant STARTED
+ participant RESUMED
+
+ INITIALIZED->>CREATED: First composition
+ CREATED->>STARTED: Entering composition
+ STARTED->>RESUMED: Show animation completes
(immediate if no animation)
+ RESUMED->>STARTED: Hide transition starts
+ STARTED->>CREATED: Leaving composition
+ CREATED->>DESTROYED: Removed from navigation
+```
+
+> **Note**: Parent lifecycle can propagate events and limit child states.
+> See [Parent-Child Lifecycle Coordination](#parent-child-lifecycle-coordination) for details.
+
+## Parent-Child Lifecycle Coordination
+
+The lifecycle of parent and child screens follows strict rules to ensure consistency:
+
+### Rules
+
+**Rule**: A child's lifecycle state never exceeds its parent's state.
+
+```
+Parent: RESUMED → Child can reach: RESUMED
+Parent: STARTED → Child can reach: STARTED (blocked from RESUMED)
+Parent: CREATED → Child can reach: CREATED (blocked from STARTED)
+```
+
+**Event Propagation from Parent**:
+
+> **Note**: When a screen enters composition, it subscribes to its parent's lifecycle. Events are propagated from parent to child while the
+> subscription is active (screen is in composition).
+
+| **Parent Event** | **Propagation Behavior** |
+|------------------|------------------------------------------------------------------------------------------------------------|
+| `ON_CREATE` | Never propagated (child subscribes after its own creation) |
+| `ON_START` | Always propagated → Child moves to STARTED |
+| `ON_RESUME` | Propagated but gated by transitions and activation → Child moves to RESUMED only if all conditions are met |
+| `ON_PAUSE` | Always propagated → Child immediately moves to STARTED |
+| `ON_STOP` | Always propagated → Child immediately moves to CREATED |
+| `ON_DESTROY` | Conditionally propagated (blocked during config changes to preserve SavedStateRegistry) |
+
+### Examples
+
+#### Parent with transition, child without transition { collapsible="true" }
+
+```mermaid
+sequenceDiagram
+ participant Parent
+ participant Child
+
+ Note over Parent: CREATED
+ Note over Parent: Entering composition
+ Note over Parent: STARTED
+ Note over Parent: Animating...
+ Parent->>Child: Child enters composition
+ Note over Child: CREATED → STARTED
+ Note over Child: Waiting for parent...
+ Note over Parent: Animation completes
+ Note over Parent: RESUMED
+ Parent->>Child: Parent resumed
+ Note over Child: RESUMED
+```
+
+Even though the child has no animation, it waits at STARTED until the parent reaches RESUMED.
-Here’s an overview of lifecycle states and their meanings in the context of the screen lifecycle:
+#### Screen Rotation (Configuration Change) { collapsible="true" }
-| **State** | **Meaning** |
-|-----------------|-----------------------------------------------------------------------------------------------------------------------------|
-| **INITIALIZED** | The screen is constructed (instance created) but has never been displayed. |
-| **CREATED** | The screen is in the navigation hierarchy and can be reached from the `RootScreen` integrated with an Activity or Fragment. |
-| **STARTED** | `Screen.Content` is in composition. |
-| **RESUMED** | The screen is **STARTED**, and there are no unfinished transitions for this screen or its parent. |
-| **DESTROYED** | The screen is removed from the navigation graph. |
+```mermaid
+sequenceDiagram
+ participant Activity
+ participant StackScreen
+ participant Screen
-> `ON_CREATE` and `ON_DESTROY` are dispatched once per screen instance.
+ Note over Activity,Screen: All RESUMED
-### Screen Transitions and Lifecycle
+ Note right of Activity: Rotation starts
+ Note over Activity: STARTED
+ Activity->>StackScreen: ON_PAUSE
+ Note over StackScreen: STARTED
+ StackScreen->>Screen: ON_PAUSE
+ Note over Screen: STARTED
-Modo provides a convenient way to track when screen transitions start and finish. These events are tied to the `ON_RESUME` and `ON_PAUSE` lifecycle
-events. Here’s a summary:
+ Note over Activity: CREATED
+ Activity->>StackScreen: ON_STOP
+ Note over StackScreen: CREATED
+ StackScreen->>Screen: ON_STOP
+ Note over Screen: CREATED
-| **Event** | **With Transition** | **Without Transition** |
-|---------------|------------------------------------------------------------------------------------------------|---------------------------------------------|
-| **ON_RESUME** | Dispatched when there are no unfinished transitions, and the parent is in the `RESUMED` state. | Dispatched when the parent is in `RESUMED`. |
-| **ON_PAUSE** | Dispatched when a hiding transition starts. | Dispatched immediately before `ON_STOP`. |
+ Note over Activity: DESTROYED
+ Note right of Activity: ON_DESTROY NOT propagated
(screens preserved)
-### Parent-Child Lifecycle Propagation
+ Note right of Activity: Activity recreated
+ Note over Activity: CREATED
-The lifecycle of parent and child screens follows a set of rules, ensuring consistency and predictability:
+ Note over Activity: STARTED
+ Activity->>StackScreen: ON_START
+ Note over StackScreen: STARTED
+ StackScreen->>Screen: ON_START
+ Note over Screen: STARTED
+
+ Note over Activity: RESUMED
+ Activity->>StackScreen: ON_RESUME
+ Note over StackScreen: RESUMED
+ StackScreen->>Screen: ON_RESUME
+ Note over Screen: RESUMED
+```
-1. A screen's `Lifecycle.State` is always less than or equal to (`<=`) its parent's state.
-2. A child screen is not moved to the `RESUMED` state until its parent is also in the `RESUMED` state.
-3. When a screen's lifecycle state is downgraded, its child screens are also moved to the same state.
-4. When a screen reaches the `RESUMED` state and its child screens are ready to resume, the children's lifecycles are also moved to `RESUMED`.
+During configuration changes, screens are preserved (not destroyed) and reattach to the new Activity instance.
-### Practical Example: Keyboard Management
+## Transition Lifecycle Control
-A practical use case for these lifecycle events is managing the keyboard. For example, you can show and hide the keyboard using `ON_RESUME` and
-`ON_PAUSE` events:
+Screens animated by `ScreenTransition` cannot reach RESUMED state until the animation completes:
-* `ON_RESUME` indicates that the screen is ready for user input (transitions are finished)
-* `ON_PAUSE` indicates that the screen is not ready for user input (transitions are starting)
+- **Without transition**: STARTED → RESUMED (immediate)
+- **With transition**: STARTED → (animation playing) → RESUMED (after animation completes)
+
+This indirectly affects nested screens because of [parent-child lifecycle propagation](#parent-child-lifecycle-coordination).
+
+## Practical Examples
+
+### Managing Keyboard
+
+Show/hide keyboard based on screen visibility:
```kotlin
-val lifecycleOwner = LocalLifecycleOwner.current
-val keyboardController = LocalSoftwareKeyboardController.current
-DisposableEffect(this) {
- val observer = LifecycleEventObserver { _, event ->
- when (event) {
- Lifecycle.Event.ON_RESUME -> {
- focusRequester.requestFocus()
- }
- Lifecycle.Event.ON_PAUSE -> {
- focusRequester.freeFocus()
- keyboardController?.hide()
+@Composable
+fun LoginScreenContent(modifier: Modifier) {
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val focusRequester = remember { FocusRequester() }
+
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_RESUME -> {
+ // Screen is fully visible, focus input and show keyboard
+ focusRequester.requestFocus()
+ }
+ Lifecycle.Event.ON_PAUSE -> {
+ // Screen is hiding, clear focus and hide keyboard
+ focusRequester.freeFocus()
+ keyboardController?.hide()
+ }
+ else -> {}
}
- else -> {}
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
}
}
- lifecycleOwner.lifecycle.addObserver(observer)
- onDispose {
- lifecycleOwner.lifecycle.removeObserver(observer)
- }
+
+ TextField(
+ value = text,
+ onValueChange = { text = it },
+ modifier = Modifier.focusRequester(focusRequester)
+ )
+}
+```
+
+## Debugging Screens Lifecycle
+
+Enable logging to see lifecycle events:
+
+```kotlin
+ModoDevOptions.onScreenPreDisposeListener = { screen ->
+ Log.d("Modo", "Screen pre-dispose: ${screen.screenKey}")
+}
+
+ModoDevOptions.onScreenDisposeListener = { screen ->
+ Log.d("Modo", "Screen disposed: ${screen.screenKey}")
}
-TextField(text, setText, modifier = Modifier.focusRequester(focusRequester))
```
\ No newline at end of file
diff --git a/Writerside/v.list b/Writerside/v.list
index c7cc6ff5..90f3bd1b 100644
--- a/Writerside/v.list
+++ b/Writerside/v.list
@@ -1,9 +1,9 @@
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 621c1275..9e786be5 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -48,6 +48,7 @@ detekt-composeRules = { module = "io.nlopez.compose.rules:detekt", version.ref =
detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detektVersion" }
leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" }
test-junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version = "5.10.1" }
+test-androidx-arch-core = { group = "androidx.arch.core", name = "core-testing", version = "2.2.0" }
debug-logcat = { group = "com.squareup.logcat", name = "logcat", version = "0.1" }
diff --git a/modo-compose/build.gradle.kts b/modo-compose/build.gradle.kts
index 30dc892f..7aec9f6e 100644
--- a/modo-compose/build.gradle.kts
+++ b/modo-compose/build.gradle.kts
@@ -13,6 +13,12 @@ dependencyGuard {
android {
namespace = "com.github.terrakok.modo.android.compose"
+
+ testOptions {
+ unitTests {
+ isReturnDefaultValues = true
+ }
+ }
}
dependencies {
@@ -31,6 +37,7 @@ dependencies {
testImplementation(libs.test.junit.jupiter)
testImplementation(kotlin("test"))
+ testImplementation(libs.test.androidx.arch.core)
}
tasks.withType(Test::class) {
diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt
index 13c92941..60089b68 100644
--- a/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt
+++ b/modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt
@@ -13,10 +13,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
-import androidx.lifecycle.Lifecycle.Event.ON_PAUSE
-import androidx.lifecycle.Lifecycle.Event.ON_RESUME
-import androidx.lifecycle.Lifecycle.Event.ON_START
-import androidx.lifecycle.Lifecycle.Event.ON_STOP
import com.github.terrakok.modo.android.ModoScreenAndroidAdapter
import com.github.terrakok.modo.android.overlaySaveableStateKey
import com.github.terrakok.modo.animation.ScreenTransition
@@ -47,6 +43,16 @@ private val LocalPreDispose = staticCompositionLocalOf<() -> Unit> {
private const val TAG = "ComposeRenderer"
+/**
+ * Internal CompositionLocal that signals whether a screen is being rendered within a transition context.
+ * - `true`: Screen lifecycle should be manually controlled by transition (paused/resumed based on animation state)
+ * - `false`: Screen lifecycle auto-resumes immediately (no animation control needed)
+ *
+ * This is automatically provided by [ScreenTransition] and consumed by [SaveableContent].
+ * Each [SaveableContent] call resets this to `false` for its children to prevent false positives.
+ */
+internal val LocalInTransitionContext = staticCompositionLocalOf { false }
+
internal inline val Screen.saveableStateKey: String get() = screenKey.value
/**
@@ -55,24 +61,23 @@ internal inline val Screen.saveableStateKey: String get() = screenKey.value
* 2. Adds support of Android-related features, such as ViewModel, LifeCycle and SavedStateHandle.
* 3. Handles lifecycle of [Screen] by adding [DisposableEffect] before and after content, in order to notify [ComposeRenderer]
* when [Screen.Content] is about to leave composition and when it has left composition.
- * @param modifier is a modifier that will be passed into [Screen.Content]
- * @param manualResumePause define whenever we are going to manually call [LifecycleDependency.showTransitionFinished] and [LifecycleDependency.hideTransitionStarted]
- * to emmit [ON_RESUME] and [ON_PAUSE]. Otherwise, [ON_RESUME] will be called straight after [ON_START] and [ON_PAUSE] will be called straight
- * before [ON_STOP].
*
- * F.e. it is used by [ScreenTransition]:
- * + [ON_RESUME] emitted when animation of showing screen is finished
- * + [ON_PAUSE] emitted when animation of hiding screen is started
+ * @param modifier is a modifier that will be passed into [Screen.Content]
*/
@Composable
fun Screen.SaveableContent(
- modifier: Modifier = Modifier,
- manualResumePause: Boolean = false
+ modifier: Modifier = Modifier
) {
- LocalSaveableStateHolder.currentOrThrow.SaveableStateProvider(key = saveableStateKey) {
- SetupScreenCleanup()
- ModoScreenAndroidAdapter.get(this).ProvideAndroidIntegration(manualResumePause) {
- Content(modifier)
+ // Read transition context from parent before resetting for children
+ val usesTransitionLifecycle = LocalInTransitionContext.current
+
+ // Reset for children to prevent propagation beyond this screen
+ CompositionLocalProvider(LocalInTransitionContext provides false) {
+ LocalSaveableStateHolder.currentOrThrow.SaveableStateProvider(key = saveableStateKey) {
+ SetupScreenCleanup()
+ ModoScreenAndroidAdapter.get(this).ProvideAndroidIntegration(usesTransitionLifecycle) {
+ Content(modifier)
+ }
}
}
}
diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/ModoDevOptions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/ModoDevOptions.kt
index 79a78cbc..733878dc 100644
--- a/modo-compose/src/main/java/com/github/terrakok/modo/ModoDevOptions.kt
+++ b/modo-compose/src/main/java/com/github/terrakok/modo/ModoDevOptions.kt
@@ -13,6 +13,10 @@ object ModoDevOptions {
Log.e("Modo", "Modo internal error", throwable)
}
+ var onIllegalLifecycleUpdate: ValidationFailedStrategy = ValidationFailedStrategy { throwable ->
+ Log.e("Modo", "Modo internal error", throwable)
+ }
+
var onScreenDisposeListener: ((Screen) -> Unit)? = null
var onScreenPreDisposeListener: ((Screen) -> Unit)? = null
diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt
index 1fc863b4..0b0ddfdb 100644
--- a/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt
+++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt
@@ -3,6 +3,7 @@ package com.github.terrakok.modo.android
import android.app.Application
import android.content.Context
import android.os.Bundle
+import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocal
import androidx.compose.runtime.CompositionLocalProvider
@@ -15,16 +16,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.HasDefaultViewModelProviderFactory
import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.Lifecycle.Event.ON_CREATE
-import androidx.lifecycle.Lifecycle.Event.ON_DESTROY
-import androidx.lifecycle.Lifecycle.Event.ON_PAUSE
-import androidx.lifecycle.Lifecycle.Event.ON_RESUME
-import androidx.lifecycle.Lifecycle.Event.ON_START
import androidx.lifecycle.Lifecycle.Event.ON_STOP
-import androidx.lifecycle.LifecycleEventObserver
-import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY
import androidx.lifecycle.SavedStateViewModelFactory
import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY
@@ -43,7 +36,6 @@ import androidx.savedstate.compose.LocalSavedStateRegistryOwner
import com.github.terrakok.modo.ModoDevOptions
import com.github.terrakok.modo.Screen
import com.github.terrakok.modo.SetupPreDispose
-import com.github.terrakok.modo.android.ModoScreenAndroidAdapter.Companion.needPropagateLifecycleEventFromParent
import com.github.terrakok.modo.lifecycle.LifecycleDependency
import com.github.terrakok.modo.logs.devLogD
import com.github.terrakok.modo.logs.devLogI
@@ -72,7 +64,10 @@ class ModoScreenAndroidAdapter private constructor(
HasDefaultViewModelProviderFactory,
LifecycleDependency {
- override val lifecycle: LifecycleRegistry = LifecycleRegistry(this)
+ @VisibleForTesting
+ internal val lifecycleManager = ScreenLifecycleManager(this)
+
+ override val lifecycle get() = lifecycleManager.lifecycle
override val viewModelStore: ViewModelStore = ViewModelStore()
@@ -102,16 +97,8 @@ class ModoScreenAndroidAdapter private constructor(
// Atomic references for cases when we unable take it directly from the composition.
private val atomicContext = AtomicReference()
- private val atomicParentLifecycleOwner = AtomicReference()
private val application: Application? get() = atomicContext.get()?.applicationContext?.getApplication()
- /**
- * Holding transition state of the screen to be able to handle lifecycle events from parent properly.
- * Check out [needPropagateLifecycleEventFromParent] for more details.
- */
- @Volatile
- private var screenTransitionState: ScreenTransitionState = ScreenTransitionState.HIDDEN
-
init {
controller.performAttach()
enableSavedStateHandles()
@@ -119,14 +106,14 @@ class ModoScreenAndroidAdapter private constructor(
@Composable
fun ProvideAndroidIntegration(
- manualResumePause: Boolean = false,
+ usesTransitionLifecycle: Boolean = false,
content: @Composable () -> Unit,
) {
val context: Context = LocalContext.current
val parentLifecycleOwner = LocalLifecycleOwner.current
- LifecycleDisposableEffect(context, parentLifecycleOwner, manualResumePause) {
- DisposableAtomicReference(LocalContext, atomicContext)
- DisposableAtomicReference(LocalLifecycleOwner, atomicParentLifecycleOwner)
+ DisposableAtomicReference(LocalContext, atomicContext)
+ DisposableAtomicReference(LocalLifecycleOwner, lifecycleManager.parentLifecycleOwner)
+ LifecycleDisposableEffect(context, parentLifecycleOwner, usesTransitionLifecycle) {
ProvideCompositionLocals(content)
}
}
@@ -138,25 +125,17 @@ class ModoScreenAndroidAdapter private constructor(
*/
override fun onPreDispose() {
ModoDevOptions.onScreenPreDisposeListener?.invoke(screen)
- safeHandleLifecycleEvent(ON_DESTROY)
+ lifecycleManager.updateLifecycleIfNeeded(Lifecycle.Event.ON_DESTROY)
}
override fun hideTransitionStarted() {
screen.devLogD(TAG) { "hideTransitionStarted ${lifecycle.currentState}" }
- screenTransitionState = ScreenTransitionState.HIDING
- safeHandleLifecycleEvent(ON_PAUSE)
+ lifecycleManager.hideTransitionStarted()
}
override fun showTransitionFinished() {
screen.devLogD(TAG) { "showTransitionFinished ${lifecycle.currentState}" }
- screenTransitionState = ScreenTransitionState.SHOWN
- val parentState = atomicParentLifecycleOwner.get()?.lifecycle?.currentState
- // It's crucial to check parent state, because our state can't be greater than a parent state.
- // If this condition is not met, resuming will be done when parent will be resumed and screenTransitionState == ScreenTransitionState.SHOWN.
- // It can happen when we display this screen as a first screen inside container, that is animating.
- if (parentState != null && parentState == Lifecycle.State.RESUMED) {
- safeHandleLifecycleEvent(ON_RESUME)
- }
+ lifecycleManager.showTransitionFinished()
}
override fun toString(): String = "${ModoScreenAndroidAdapter::class.simpleName}, screenKey: ${screen.screenKey}"
@@ -166,11 +145,12 @@ class ModoScreenAndroidAdapter private constructor(
viewModelStore.clear()
}
- private fun onCreate(savedState: Bundle?) {
+ @VisibleForTesting
+ internal fun onCreate(savedState: Bundle?) {
check(!isCreated) { "onCreate already called" }
isCreated = true
controller.performRestore(savedState)
- safeHandleLifecycleEvent(ON_CREATE)
+ lifecycleManager.updateLifecycleIfNeeded(Lifecycle.Event.ON_CREATE)
}
private fun performSave(outState: Bundle) {
@@ -211,30 +191,11 @@ class ModoScreenAndroidAdapter private constructor(
}
}
- /**
- * Returns a unregister callback
- */
- private fun registerParentLifecycleListener(
- lifecycleOwner: LifecycleOwner?,
- observerFactory: () -> LifecycleObserver
- ): () -> Unit {
- if (lifecycleOwner != null) {
- val parentLifecycleObserver = observerFactory()
- val lifecycle = lifecycleOwner.lifecycle
- lifecycle.addObserver(parentLifecycleObserver)
- return {
- lifecycle.removeObserver(parentLifecycleObserver)
- }
- } else {
- return { }
- }
- }
-
@Composable
private fun LifecycleDisposableEffect(
context: Context,
parentLifecycleOwner: LifecycleOwner,
- manualResumePause: Boolean,
+ usesTransitionLifecycle: Boolean,
content: @Composable () -> Unit
) {
val activity = remember(context) {
@@ -242,14 +203,11 @@ class ModoScreenAndroidAdapter private constructor(
}
val savedState = rememberSaveable { Bundle() }
if (!isCreated) {
- onCreate(savedState) // do this in the UI thread to force it to be called before anything else
+ onCreate(savedState)
}
DisposableEffect(this) {
- safeHandleLifecycleEvent(ON_START)
- if (!manualResumePause) {
- safeHandleLifecycleEvent(ON_RESUME)
- }
+ lifecycleManager.handleCompositionEnter(usesTransitionLifecycle)
onDispose { }
}
@@ -260,82 +218,42 @@ class ModoScreenAndroidAdapter private constructor(
DisposableEffect(this) {
screen.devLogV(TAG) { "LifecycleDisposableEffect parentLifecycleOwner: $parentLifecycleOwner" }
- val unregisterLifecycle = registerParentLifecycleListener(parentLifecycleOwner) {
- LifecycleEventObserver { _, event ->
- // when the Application goes to background, perform save
- if (event == ON_STOP) {
- performSave(savedState)
- }
- if (
- needPropagateLifecycleEventFromParent(
- event,
- screenTransitionState = screenTransitionState,
- isActivityFinishing = activity?.isFinishing,
- isChangingConfigurations = activity?.isChangingConfigurations
- )
- ) {
- safeHandleLifecycleEvent(event)
- }
- }
- }
+ val unregisterLifecycle = subscribeToParentLifecycle(
+ parentLifecycleOwner = parentLifecycleOwner,
+ savedState = savedState,
+ isActivityFinishing = { activity?.isFinishing ?: false },
+ isChangingConfigurations = { activity?.isChangingConfigurations ?: false }
+ )
onDispose {
screen.devLogD(TAG) { "LifecycleDisposableEffect after content DisposableEffect.onDispose ${lifecycle.currentState}" }
unregisterLifecycle()
- // when the screen goes to stack, perform save
performSave(savedState)
- // notify lifecycle screen listeners
- if (!manualResumePause) {
- safeHandleLifecycleEvent(ON_PAUSE)
- }
- safeHandleLifecycleEvent(ON_STOP)
+ lifecycleManager.handleCompositionExit()
}
}
}
- private fun safeHandleLifecycleEvent(event: Lifecycle.Event) {
- val skippEvent = needSkipEvent(lifecycle.currentState, event)
- if (!skippEvent) {
- screen.devLogD(TAG) { "safeHandleLifecycleEvent send $event" }
- screenTransitionState = when {
- // Whenever we receive ON_RESUME event, we need to move screen to SHOWN state to indicate that there is no transition of this screen.
- event == ON_RESUME -> ScreenTransitionState.SHOWN
- // Whe need to check transition state to distinguish between
- // 1. finishing screen hiding transition. In this case, we need to move screen to hidden state.
- // 2. hiding screen, because of lifecycle event. In this case we don't need to change animation state.
- event == ON_STOP && screenTransitionState == ScreenTransitionState.HIDING -> ScreenTransitionState.HIDDEN
- // Pause by itself doesn't mean that screen is hidden, it can be visible, but not active. F.e. when system dialog is shown.
- else -> screenTransitionState
+ @VisibleForTesting
+ internal fun subscribeToParentLifecycle(
+ parentLifecycleOwner: LifecycleOwner,
+ savedState: Bundle,
+ isActivityFinishing: () -> Boolean,
+ isChangingConfigurations: () -> Boolean
+ ): () -> Unit = lifecycleManager.subscribeToParentLifecycle(
+ parentLifecycleOwner = parentLifecycleOwner,
+ isActivityFinishing = isActivityFinishing,
+ isChangingConfigurations = isChangingConfigurations,
+ onEventBeforePropagation = { event ->
+ // Handle SavedState side-effect (adapter's responsibility)
+ if (event == ON_STOP) {
+ performSave(savedState)
}
- lifecycle.handleLifecycleEvent(event)
}
- }
-
- /**
- * Enum that represents
- */
- private enum class ScreenTransitionState {
- HIDING,
- HIDDEN,
- SHOWN
- // There is no SHOWING state, because we cannot distinguish it by using lifecycle events and
- // [hideTransitionStarted] and [showTransitionFinished] methods.
- }
+ )
companion object {
- private val moveLifecycleStateUpEvents = setOf(
- ON_CREATE,
- ON_START,
- ON_RESUME
- )
-
- private val moveLifecycleStateDownEvents = setOf(
- ON_STOP,
- ON_PAUSE,
- ON_DESTROY
- )
-
private val TAG = ModoScreenAndroidAdapter::class.simpleName
/**
@@ -355,45 +273,5 @@ class ModoScreenAndroidAdapter private constructor(
screen = screen,
name = LifecycleDependency.KEY,
)
-
- @JvmStatic
- private fun needPropagateLifecycleEventFromParent(
- event: Lifecycle.Event,
- screenTransitionState: ScreenTransitionState,
- isActivityFinishing: Boolean?,
- isChangingConfigurations: Boolean?
- ) =
- /*
- * Instance of the screen isn't recreated during config changes so skip this event
- * to avoid crash while accessing to ViewModel with SavedStateHandle, because after
- * ON_DESTROY, [androidx.lifecycle.SavedStateHandleController] is marked as not
- * attached and next call of [registerSavedStateProvider] after recreating Activity
- * on the same instance causing the crash.
- *
- * Also, when activity is destroyed, but not finished, screen is not destroyed.
- *
- * In the case of Fragments, we unsubscribe before ON_DESTROY event, so there is no problem with this.
- */
- when {
- event == ON_DESTROY && (isActivityFinishing == false || isChangingConfigurations == true) -> {
- false
- }
- screenTransitionState == ScreenTransitionState.SHOWN && event == ON_RESUME -> {
- true
- }
- else -> {
- // Except previous condition, parent can only move lifecycle state down.
- // Because parent cant be already resumed, but child is not, because of running animation.
- event in moveLifecycleStateDownEvents
- }
- }
-
- @JvmStatic
- internal fun needSkipEvent(currentState: Lifecycle.State, event: Lifecycle.Event) =
- !currentState.isAtLeast(Lifecycle.State.INITIALIZED) ||
- // Skipping events that moves lifecycle state up, but this state is already reached.
- (event in moveLifecycleStateUpEvents && event.targetState <= currentState) ||
- // Skipping events that moves lifecycle state down, but this state is already reached.
- (event in moveLifecycleStateDownEvents && event.targetState >= currentState)
}
}
\ No newline at end of file
diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt
new file mode 100644
index 00000000..27af4a9b
--- /dev/null
+++ b/modo-compose/src/main/java/com/github/terrakok/modo/android/ScreenLifecycleManager.kt
@@ -0,0 +1,192 @@
+package com.github.terrakok.modo.android
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.Lifecycle.Event.ON_CREATE
+import androidx.lifecycle.Lifecycle.Event.ON_DESTROY
+import androidx.lifecycle.Lifecycle.Event.ON_PAUSE
+import androidx.lifecycle.Lifecycle.Event.ON_RESUME
+import androidx.lifecycle.Lifecycle.Event.ON_START
+import androidx.lifecycle.Lifecycle.Event.ON_STOP
+import androidx.lifecycle.Lifecycle.State.CREATED
+import androidx.lifecycle.Lifecycle.State.DESTROYED
+import androidx.lifecycle.Lifecycle.State.RESUMED
+import androidx.lifecycle.Lifecycle.State.STARTED
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import com.github.terrakok.modo.ModoDevOptions
+import java.util.concurrent.atomic.AtomicReference
+import kotlin.math.abs
+
+/**
+ * Manages screen lifecycle with coordination between parent lifecycle, composition state, and screen transitions.
+ *
+ * ## Core Contracts
+ *
+ * **Parent-Child Coordination**
+ * - Child state never exceeds parent state (e.g., screen can't be [RESUMED] if parent is [STARTED])
+ * - **Event propagation from parent:**
+ * - [ON_CREATE]: Never propagated (subscription happens after screen creation)
+ * - [ON_START]: Always propagated (parent going up brings child up)
+ * - [ON_RESUME]: Propagated but gated by [canResumeAfterTransition] (requires visible screen with completed transitions)
+ * - [ON_PAUSE]/[ON_STOP]: Always propagated (parent going down forces child down immediately)
+ * - [ON_DESTROY]: Conditionally propagated (blocked during config changes to preserve SavedStateRegistry)
+ *
+ * **Transition Readiness ([canResumeAfterTransition])**
+ * - [ON_RESUME] blocked until show transition completes via [showTransitionFinished]
+ * - Hide transition via [hideTransitionStarted] triggers immediate pause
+ * - Prevents screen from being [RESUMED] during hide animations
+ *
+ * **Sequential Progression**
+ * - Lifecycle events must be called in sequential order ([CREATED] -> [STARTED] -> [RESUMED])
+ * - Single-step transitions enforced (except [CREATED] -> [DESTROYED])
+ * - Redundant events skipped automatically (e.g., [ON_START] when already [RESUMED])
+ *
+ * **No Resurrection**
+ * - Once [DESTROYED], no further lifecycle events accepted
+ * - Ensures proper cleanup and prevents use-after-destroy bugs
+ *
+ * ## Usage
+ *
+ * Typical lifecycle flow:
+ * 1. Screen created: `updateLifecycleIfNeeded(`[ON_CREATE]`)`
+ * 2. Enters composition: `handleCompositionEnter(usesTransitionLifecycle)`
+ * 3. Subscribe to parent: `subscribeToParentLifecycle(...)`
+ * 4. Transition complete: `showTransitionFinished()` -> moves to [RESUMED]
+ * 5. Exits composition: `handleCompositionExit()`
+ * 6. Screen destroyed: `updateLifecycleIfNeeded(`[ON_DESTROY]`)`
+ */
+internal class ScreenLifecycleManager(
+ lifecycleOwner: LifecycleOwner
+) {
+ val lifecycle: LifecycleRegistry = LifecycleRegistry(lifecycleOwner)
+
+ internal val parentLifecycleOwner = AtomicReference()
+
+ /**
+ * `true` - when related screen is not animating and visible
+ * `false` - when related screen animating or idle but after hide transition
+ */
+ @Volatile
+ private var canResumeAfterTransition: Boolean = false
+
+ private val currentParentState: Lifecycle.State?
+ get() = parentLifecycleOwner.get()?.lifecycle?.currentState
+
+ fun handleCompositionEnter(usesTransitionLifecycle: Boolean) {
+ updateLifecycleIfNeeded(ON_START)
+ if (!usesTransitionLifecycle) {
+ canResumeAfterTransition = true
+ updateLifecycleIfNeeded(ON_RESUME)
+ }
+ }
+
+ fun handleCompositionExit() {
+ // sending pause anyway, it will be ignored if it is already handled
+ updateLifecycleIfNeeded(ON_PAUSE)
+ updateLifecycleIfNeeded(ON_STOP)
+ }
+
+ fun showTransitionFinished() {
+ canResumeAfterTransition = true
+ if (lifecycle.currentState == STARTED) {
+ updateLifecycleIfNeeded(ON_RESUME)
+ }
+ }
+
+ fun hideTransitionStarted() {
+ canResumeAfterTransition = false
+ updateLifecycleIfNeeded(ON_PAUSE)
+ }
+
+ /**
+ * @param onEventBeforePropagation Callback for side-effects (e.g., SavedState persistence) before lifecycle propagation
+ * @return Unregister callback
+ */
+ fun subscribeToParentLifecycle(
+ parentLifecycleOwner: LifecycleOwner,
+ isActivityFinishing: () -> Boolean = { false },
+ isChangingConfigurations: () -> Boolean = { false },
+ onEventBeforePropagation: ((Lifecycle.Event) -> Unit)? = null
+ ): () -> Unit {
+ val observer = LifecycleEventObserver { _, event ->
+ // Allow adapter to handle side-effects (like SavedState)
+ onEventBeforePropagation?.invoke(event)
+
+ // Propagate lifecycle events using manager's logic
+ // [ON_DESTROY] blocked during config changes to preserve SavedStateRegistry.
+ if (event != ON_DESTROY || (isActivityFinishing() && !isChangingConfigurations())) {
+ updateLifecycleIfNeeded(event)
+ }
+ }
+
+ parentLifecycleOwner.lifecycle.addObserver(observer)
+ return {
+ parentLifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+
+ /**
+ * Enforces lifecycle rules:
+ * - No resurrection after [DESTROYED]
+ * - [ON_RESUME] blocked until [canResumeAfterTransition]
+ * - Child state never exceeds parent state
+ * - Single-step transitions only (except [CREATED] -> [DESTROYED])
+ */
+ fun updateLifecycleIfNeeded(event: Lifecycle.Event) {
+ @Suppress("ComplexCondition")
+ if (
+ lifecycle.currentState != DESTROYED &&
+ !stateAlreadyReached(lifecycle.currentState, event) &&
+ (event != ON_RESUME || canResumeAfterTransition) &&
+ parentStateAllowsTransition(currentParentState, event)
+ ) {
+ validateLifecycleUpdateOneStep(lifecycle.currentState, event)
+ lifecycle.handleLifecycleEvent(event)
+ }
+ }
+
+ companion object {
+ private val MOVE_LIFECYCLE_STATE_UP_EVENTS = setOf(
+ ON_CREATE,
+ ON_START,
+ ON_RESUME
+ )
+
+ private val MOVE_LIFECYCLE_STATE_DOWN_EVENTS = setOf(
+ ON_STOP,
+ ON_PAUSE,
+ ON_DESTROY
+ )
+
+ /**
+ * Skips redundant events (e.g., [ON_START] when already [RESUMED], or [ON_PAUSE] when already [CREATED]).
+ */
+ internal fun stateAlreadyReached(currentState: Lifecycle.State, event: Lifecycle.Event): Boolean =
+ // Skipping events that move the lifecycle state up, but this state is already reached.
+ (event in MOVE_LIFECYCLE_STATE_UP_EVENTS && event.targetState <= currentState) ||
+ // Skipping events that move the lifecycle state down, but this state is already reached.
+ (event in MOVE_LIFECYCLE_STATE_DOWN_EVENTS && event.targetState >= currentState)
+
+ internal fun parentStateAllowsTransition(
+ parentState: Lifecycle.State?,
+ event: Lifecycle.Event,
+ ): Boolean =
+ event in MOVE_LIFECYCLE_STATE_DOWN_EVENTS ||
+ event.targetState <= CREATED ||
+ parentState != null && parentState >= event.targetState
+
+ internal fun validateLifecycleUpdateOneStep(currentState: Lifecycle.State, event: Lifecycle.Event) {
+ val isValidUpdate = abs(currentState.ordinal - event.targetState.ordinal) == 1 ||
+ currentState == CREATED && event == ON_DESTROY
+ if (!isValidUpdate) {
+ ModoDevOptions.onIllegalLifecycleUpdate.validationFailed(
+ IllegalStateException(
+ "Lifecycle state transition must be one step, but was $currentState -> $event. " +
+ "Please report an issue at ${ModoDevOptions.REPORT_ISSUE_URL}"
+ )
+ )
+ }
+ }
+ }
+}
diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/animation/MultiScreenTransitions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/animation/MultiScreenTransitions.kt
index 6ce9fa7d..abe45d9f 100644
--- a/modo-compose/src/main/java/com/github/terrakok/modo/animation/MultiScreenTransitions.kt
+++ b/modo-compose/src/main/java/com/github/terrakok/modo/animation/MultiScreenTransitions.kt
@@ -22,7 +22,7 @@ fun ComposeRendererScope.SlideTransition(
screenModifier: Modifier = Modifier,
slideAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700),
fadeAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700),
- content: ScreenTransitionContent = { it.SaveableContent(screenModifier, manualResumePause = true) }
+ content: ScreenTransitionContent = { it.SaveableContent(screenModifier) }
) {
ScreenTransition(
modifier = modifier,
diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/animation/ScreenTransitions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/animation/ScreenTransitions.kt
index a362d405..def4d4f9 100644
--- a/modo-compose/src/main/java/com/github/terrakok/modo/animation/ScreenTransitions.kt
+++ b/modo-compose/src/main/java/com/github/terrakok/modo/animation/ScreenTransitions.kt
@@ -11,10 +11,12 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.ui.Modifier
import com.github.terrakok.modo.ComposeRendererScope
+import com.github.terrakok.modo.LocalInTransitionContext
import com.github.terrakok.modo.SaveableContent
import com.github.terrakok.modo.Screen
import com.github.terrakok.modo.model.lifecycleDependency
@@ -34,9 +36,8 @@ typealias ScreenTransitionContent = @Composable AnimatedVisibilityScope.(Screen)
* @param modifier - the modifier for the [AnimatedContent].
* @param screenModifier - the modifier for the [Screen.Content].
* @param transitionSpec - the transition spec for the [AnimatedContent].
- * @param content - the content that is going to be placed inside [AnimatedContent].
- * You can use it to decorate or customize the content,
- * but you must apparently use [SaveableContent] with [manualResumePause] = true to guarantee that the lifecycle will be paused and resumed correctly.
+ * @param content - the content that is going to be placed inside [AnimatedContent]. You can use it to decorate or customize the content.
+ * Screens rendered by this transition will automatically have their lifecycle controlled by the animation.
*/
@Suppress("MagicNumber")
@Composable
@@ -48,31 +49,27 @@ fun ComposeRendererScope<*>.ScreenTransition(
scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) togetherWith
fadeOut(animationSpec = tween(90))
},
- content: ScreenTransitionContent = { it.SaveableContent(screenModifier, manualResumePause = true) }
+ content: ScreenTransitionContent = { it.SaveableContent(screenModifier) }
) {
val transition = updateTransition(targetState = screen, label = "ScreenTransition")
- transition.AnimatedContent(
- transitionSpec = transitionSpec,
- modifier = modifier,
- contentKey = { it.screenKey },
- ) { screen ->
- content(screen)
- DisposableEffect(transition.currentState, transition.targetState) {
-// Log.d(
-// "LifecycleDebug",
-// "target = ${screen.screenKey}, " +
-// "transition.currentState = ${transition.currentState.screenKey}," +
-// "transition.targetState = ${transition.targetState.screenKey}"
-// )
- if (screen == transition.currentState && screen != transition.targetState) {
- // Start of animation that hides this screen, so we should pause lifecycle
- screen.lifecycleDependency()?.hideTransitionStarted()
+ CompositionLocalProvider(LocalInTransitionContext provides true) {
+ transition.AnimatedContent(
+ transitionSpec = transitionSpec,
+ modifier = modifier,
+ contentKey = { it.screenKey },
+ ) { screen ->
+ content(screen)
+ DisposableEffect(transition.currentState, transition.targetState) {
+ if (screen == transition.currentState && screen != transition.targetState) {
+ // Start of animation that hides this screen, so we should pause lifecycle
+ screen.lifecycleDependency()?.hideTransitionStarted()
+ }
+ if (transition.currentState == transition.targetState && screen == transition.currentState) {
+ // Finish of animation that shows this screen, so we should resume lifecycle
+ screen.lifecycleDependency()?.showTransitionFinished()
+ }
+ onDispose { }
}
- if (transition.currentState == transition.targetState && screen == transition.currentState) {
- // Finish of animation that shows this screen, so we should resume lifecycle
- screen.lifecycleDependency()?.showTransitionFinished()
- }
- onDispose { }
}
}
}
\ No newline at end of file
diff --git a/modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt b/modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt
index 90b095ed..88cbcf1e 100644
--- a/modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt
+++ b/modo-compose/src/main/java/com/github/terrakok/modo/animation/StackScreenTransitions.kt
@@ -30,7 +30,7 @@ fun ComposeRendererScope.SlideTransition(
popDirection: AnimatedContentTransitionScope.SlideDirection = pushDirection.opposite(),
slideAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700),
fadeAnimationSpec: FiniteAnimationSpec = tween(durationMillis = 700),
- content: ScreenTransitionContent = { it.SaveableContent(screenModifier, manualResumePause = true) }
+ content: ScreenTransitionContent = { it.SaveableContent(screenModifier) }
) {
ScreenTransition(
modifier = modifier,
diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterBasicTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterBasicTest.kt
new file mode 100644
index 00000000..e5e40f73
--- /dev/null
+++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterBasicTest.kt
@@ -0,0 +1,53 @@
+package com.github.terrakok.modo.android
+
+import androidx.lifecycle.Lifecycle
+import com.github.terrakok.modo.MockScreen
+import com.github.terrakok.modo.ScreenKey
+import com.github.terrakok.modo.android.ModoScreenAndroidAdapterTestUtils.cleanupArchTaskExecutor
+import com.github.terrakok.modo.android.ModoScreenAndroidAdapterTestUtils.cleanupScreenModelStore
+import com.github.terrakok.modo.android.ModoScreenAndroidAdapterTestUtils.setupArchTaskExecutor
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertDoesNotThrow
+import kotlin.test.assertEquals
+
+class ModoScreenAndroidAdapterBasicTest {
+
+ private lateinit var screen: MockScreen
+ private lateinit var adapter: ModoScreenAndroidAdapter
+
+ @BeforeEach
+ fun setup() {
+ setupArchTaskExecutor()
+ cleanupScreenModelStore()
+ screen = MockScreen(ScreenKey("test-screen"))
+ adapter = ModoScreenAndroidAdapter.get(screen)
+ }
+
+ @AfterEach
+ fun tearDown() {
+ cleanupArchTaskExecutor()
+ cleanupScreenModelStore()
+ }
+
+ @Test
+ fun `When adapter is created - Then lifecycle is in INITIALIZED state`() {
+ assertEquals(Lifecycle.State.INITIALIZED, adapter.lifecycle.currentState)
+ }
+
+ @Test
+ fun `When adapter is created - Then viewModelStore is available`() {
+ assertDoesNotThrow { adapter.viewModelStore }
+ }
+
+ @Test
+ fun `When adapter is created - Then savedStateRegistry is available`() {
+ assertDoesNotThrow { adapter.savedStateRegistry }
+ }
+
+ @Test
+ fun `When adapter is created - Then defaultViewModelProviderFactory is available`() {
+ assertDoesNotThrow { adapter.defaultViewModelProviderFactory }
+ }
+}
diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt
new file mode 100644
index 00000000..4ad7cf46
--- /dev/null
+++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterParentPropagationTest.kt
@@ -0,0 +1,325 @@
+package com.github.terrakok.modo.android
+
+import androidx.lifecycle.Lifecycle.State.CREATED
+import androidx.lifecycle.Lifecycle.State.DESTROYED
+import androidx.lifecycle.Lifecycle.State.RESUMED
+import androidx.lifecycle.Lifecycle.State.STARTED
+import com.github.terrakok.modo.MockScreen
+import com.github.terrakok.modo.ScreenKey
+import com.github.terrakok.modo.android.ModoScreenAndroidAdapterTestUtils.cleanupArchTaskExecutor
+import com.github.terrakok.modo.android.ModoScreenAndroidAdapterTestUtils.cleanupScreenModelStore
+import com.github.terrakok.modo.android.ModoScreenAndroidAdapterTestUtils.setupArchTaskExecutor
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+
+class ModoScreenAndroidAdapterParentPropagationTest {
+
+ private lateinit var screen: MockScreen
+ private lateinit var adapter: ModoScreenAndroidAdapter
+ private lateinit var parent: TestLifecycleOwner
+ private lateinit var emulator: CompositionLifecycleEmulator
+
+ @BeforeEach
+ fun setup() {
+ setupArchTaskExecutor()
+ cleanupScreenModelStore()
+ screen = MockScreen(ScreenKey("test-screen"))
+ adapter = ModoScreenAndroidAdapter.get(screen)
+ parent = TestLifecycleOwner()
+ emulator = CompositionLifecycleEmulator(adapter, parent)
+ }
+
+ @AfterEach
+ fun tearDown() {
+ cleanupArchTaskExecutor()
+ cleanupScreenModelStore()
+ }
+
+ // region Parent RESUMED, screen enter composition
+
+ @Test
+ fun `Given parent RESUMED and no transition When enter composition - Then screen RESUMED`() {
+ parent.lifecycleState = RESUMED
+ emulator.enterComposition()
+
+ assertEquals(RESUMED, emulator.lifecycleState)
+ }
+
+ @Test
+ fun `Given parent RESUMED and transition lifecycle When enter composition - Then screen STARTED`() {
+ parent.lifecycleState = RESUMED
+ emulator.enterComposition(usesTransitionLifecycle = true)
+
+ assertEquals(STARTED, emulator.lifecycleState)
+ }
+
+ // endregion
+
+ // region Parent STARTED (animation in progress), screen enter composition
+
+ @Test
+ fun `Given parent STARTED and no transition When enter composition - Then screen STARTED`() {
+ parent.lifecycleState = STARTED
+
+ emulator.enterComposition()
+ assertEquals(STARTED, emulator.lifecycleState)
+ }
+
+ @Test
+ fun `Given parent STARTED and transition lifecycle When enter composition - Then screen STARTED`() {
+ parent.lifecycleState = STARTED
+
+ emulator.enterComposition(usesTransitionLifecycle = true)
+ assertEquals(STARTED, emulator.lifecycleState)
+ }
+
+ // endregion
+
+ // region lifecycle propagation
+
+ // region Screen is not in transition, parent changes state
+
+ @Test
+ fun `Given no transition When parent moves RESUMED to STARTED to RESUMED - Then adapter follows parent`() {
+ parent.lifecycleState = RESUMED
+
+ emulator.enterComposition()
+ assertEquals(RESUMED, emulator.lifecycleState)
+
+ parent.lifecycleState = STARTED
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ parent.lifecycleState = RESUMED
+ assertEquals(RESUMED, emulator.lifecycleState)
+ }
+
+ @Test
+ fun `Given transition lifecycle When parent moves RESUMED to STARTED to RESUMED - Then adapter follows parent`() {
+ parent.lifecycleState = RESUMED
+
+ emulator.enterComposition(usesTransitionLifecycle = true)
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ emulator.showTransitionFinished()
+ assertEquals(RESUMED, emulator.lifecycleState)
+
+ parent.lifecycleState = STARTED
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ parent.lifecycleState = RESUMED
+ assertEquals(RESUMED, emulator.lifecycleState)
+ }
+
+ @Test
+ fun `Given no transition When parent moves from RESUMED to CREATED to RESUMED - Then adapter follows parent`() {
+ parent.lifecycleState = RESUMED
+
+ emulator.enterComposition()
+ assertEquals(RESUMED, emulator.lifecycleState)
+
+ parent.lifecycleState = CREATED
+ assertEquals(CREATED, emulator.lifecycleState)
+
+ parent.lifecycleState = RESUMED
+ assertEquals(RESUMED, emulator.lifecycleState)
+ }
+
+ // endregion
+
+ @Test
+ fun `Given transition lifecycle and parent STARTED When parent moves to RESUMED then transition finishes - Then screen RESUMED`() {
+ parent.lifecycleState = STARTED
+
+ emulator.enterComposition(usesTransitionLifecycle = true)
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ parent.lifecycleState = RESUMED
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ adapter.showTransitionFinished()
+ assertEquals(RESUMED, emulator.lifecycleState)
+ }
+
+ @Test
+ fun `Given transition lifecycle and parent STARTED When transition finishes first - Then stays STARTED until parent RESUMED`() {
+ parent.lifecycleState = STARTED
+
+ emulator.enterComposition(usesTransitionLifecycle = true)
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ adapter.showTransitionFinished()
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ parent.lifecycleState = RESUMED
+ assertEquals(RESUMED, emulator.lifecycleState)
+ }
+
+ // endregion
+
+ // Parent resumed all time
+ // Child animating in and animating out
+ @Test
+ fun `Given parent RESUMED and transition lifecycle When hide starts after show - Then screen moves to STARTED`() {
+ parent.lifecycleState = RESUMED
+
+ emulator.enterComposition(usesTransitionLifecycle = true)
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ adapter.showTransitionFinished()
+ assertEquals(RESUMED, emulator.lifecycleState)
+
+ adapter.hideTransitionStarted()
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ emulator.exitComposition()
+ assertEquals(CREATED, emulator.lifecycleState)
+ }
+
+ // Parent animating (it is STARTED)
+ // Child idle and then animating out during parent animation
+ // Parent animation finishes
+ // Child animation finishes
+ @Test
+ fun `Given transition lifecycle and parent STARTED When show transition finishes then hide transition starts - Then screen stays STARTED`() {
+ parent.lifecycleState = STARTED
+
+ emulator.enterComposition(usesTransitionLifecycle = true)
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ adapter.showTransitionFinished()
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ adapter.hideTransitionStarted()
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ parent.lifecycleState = RESUMED
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ emulator.exitComposition()
+ assertEquals(CREATED, emulator.lifecycleState)
+ }
+
+ @Test
+ fun `Given in composition When exit composition - Then adapter moves to CREATED`() {
+ parent.lifecycleState = RESUMED
+
+ emulator.enterComposition()
+ assertEquals(RESUMED, emulator.lifecycleState)
+
+ emulator.exitComposition()
+ assertEquals(CREATED, emulator.lifecycleState)
+ }
+
+ @Test
+ fun `When screen is not in composition - Then parent lifecycle changes has no effect`() {
+ parent.lifecycleState = RESUMED
+ assertEquals(CREATED, emulator.lifecycleState)
+
+ parent.lifecycleState = STARTED
+ assertEquals(CREATED, emulator.lifecycleState)
+
+ parent.lifecycleState = RESUMED
+ assertEquals(CREATED, emulator.lifecycleState)
+
+ parent.lifecycleState = CREATED
+ assertEquals(CREATED, emulator.lifecycleState)
+
+ parent.lifecycleState = DESTROYED
+ assertEquals(CREATED, emulator.lifecycleState)
+ }
+
+ @Test
+ fun `Given parent STARTED When entering composition and finish transition - Then screen STARTED`() {
+ parent.lifecycleState = STARTED
+ emulator.enterComposition(usesTransitionLifecycle = true)
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ emulator.showTransitionFinished()
+ assertEquals(STARTED, emulator.lifecycleState)
+ }
+
+ @Test
+ fun `Given parent CREATED When entering composition and finish transition - Then screen stays CREATED`() {
+ parent.lifecycleState = CREATED
+ emulator.enterComposition(usesTransitionLifecycle = true)
+ assertEquals(CREATED, emulator.lifecycleState)
+
+ emulator.showTransitionFinished()
+ assertEquals(CREATED, emulator.lifecycleState)
+ }
+
+ // region Edge cases
+
+ @Test
+ fun `When showTransitionFinished called before enterComposition - Then it has no effect`() {
+ parent.lifecycleState = RESUMED
+ // Simulate a race: showTransitionFinished fires before composition enters
+ adapter.showTransitionFinished()
+ assertEquals(CREATED, emulator.lifecycleState)
+
+ // Normal composition entry afterward still works correctly (no transition lifecycle)
+ emulator.enterComposition(usesTransitionLifecycle = false)
+ assertEquals(RESUMED, emulator.lifecycleState)
+ }
+
+ // endregion
+
+ // region Parent ON_DESTROY propagation - Activity lifecycle scenarios
+
+ @Test
+ fun `When activity finishes normally - Then screen propagates ON_DESTROY and moves to DESTROYED`() {
+ parent.lifecycleState = RESUMED
+ emulator.enterComposition(
+ isActivityFinishing = { true },
+ isChangingConfigurations = { false }
+ )
+ assertEquals(RESUMED, emulator.lifecycleState)
+
+ parent.lifecycleState = DESTROYED
+ assertEquals(DESTROYED, emulator.lifecycleState)
+ }
+
+ @Test
+ fun `When activity killed by system but not finishing - Then screen skips ON_DESTROY and persists in CREATED`() {
+ parent.lifecycleState = RESUMED
+ emulator.enterComposition(
+ isActivityFinishing = { false },
+ isChangingConfigurations = { false }
+ )
+ assertEquals(RESUMED, emulator.lifecycleState)
+
+ parent.lifecycleState = DESTROYED
+ assertEquals(CREATED, emulator.lifecycleState)
+ }
+
+ @Test
+ fun `When activity recreating due to config change - Then screen skips ON_DESTROY to avoid SavedStateHandle crash`() {
+ parent.lifecycleState = RESUMED
+ emulator.enterComposition(
+ isActivityFinishing = { false },
+ isChangingConfigurations = { true }
+ )
+ assertEquals(RESUMED, emulator.lifecycleState)
+
+ parent.lifecycleState = DESTROYED
+ assertEquals(CREATED, emulator.lifecycleState)
+ }
+
+ @Test
+ fun `When activity killed by system from STARTED state - Then screen skips ON_DESTROY and stays in CREATED`() {
+ parent.lifecycleState = STARTED
+ emulator.enterComposition(
+ isActivityFinishing = { false },
+ isChangingConfigurations = { false }
+ )
+ assertEquals(STARTED, emulator.lifecycleState)
+
+ parent.lifecycleState = DESTROYED
+ assertEquals(CREATED, emulator.lifecycleState)
+ }
+
+ // endregion
+
+}
diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt
new file mode 100644
index 00000000..cde44e6d
--- /dev/null
+++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterStateAlreadyReachedTest.kt
@@ -0,0 +1,71 @@
+package com.github.terrakok.modo.android
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.Lifecycle.Event.ON_CREATE
+import androidx.lifecycle.Lifecycle.Event.ON_DESTROY
+import androidx.lifecycle.Lifecycle.Event.ON_PAUSE
+import androidx.lifecycle.Lifecycle.Event.ON_RESUME
+import androidx.lifecycle.Lifecycle.Event.ON_START
+import androidx.lifecycle.Lifecycle.Event.ON_STOP
+import androidx.lifecycle.Lifecycle.State.CREATED
+import androidx.lifecycle.Lifecycle.State.DESTROYED
+import androidx.lifecycle.Lifecycle.State.INITIALIZED
+import androidx.lifecycle.Lifecycle.State.RESUMED
+import androidx.lifecycle.Lifecycle.State.STARTED
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.Arguments
+import org.junit.jupiter.params.provider.MethodSource
+import kotlin.test.assertEquals
+
+class ModoScreenAndroidAdapterStateAlreadyReachedTest {
+
+ @ParameterizedTest(name = "State={0}, Event={1} -> shouldSkip={2}")
+ @MethodSource("testCases")
+ fun `stateAlreadyReached returns correct result`(
+ state: Lifecycle.State,
+ event: Lifecycle.Event,
+ shouldSkip: Boolean
+ ) {
+ assertEquals(
+ shouldSkip,
+ ScreenLifecycleManager.stateAlreadyReached(state, event),
+ "State=$state, Event=$event"
+ )
+ }
+
+ companion object {
+ @JvmStatic
+ fun testCases() = listOf(
+ Arguments.of(INITIALIZED, ON_CREATE, false),
+ Arguments.of(INITIALIZED, ON_START, false),
+ Arguments.of(INITIALIZED, ON_RESUME, false),
+ Arguments.of(INITIALIZED, ON_PAUSE, true),
+ Arguments.of(INITIALIZED, ON_STOP, true),
+ Arguments.of(INITIALIZED, ON_DESTROY, false),
+ Arguments.of(CREATED, ON_CREATE, true),
+ Arguments.of(CREATED, ON_START, false),
+ Arguments.of(CREATED, ON_RESUME, false),
+ Arguments.of(CREATED, ON_PAUSE, true),
+ Arguments.of(CREATED, ON_STOP, true),
+ Arguments.of(CREATED, ON_DESTROY, false),
+ Arguments.of(STARTED, ON_CREATE, true),
+ Arguments.of(STARTED, ON_START, true),
+ Arguments.of(STARTED, ON_RESUME, false),
+ Arguments.of(STARTED, ON_PAUSE, true),
+ Arguments.of(STARTED, ON_STOP, false),
+ Arguments.of(STARTED, ON_DESTROY, false),
+ Arguments.of(RESUMED, ON_CREATE, true),
+ Arguments.of(RESUMED, ON_START, true),
+ Arguments.of(RESUMED, ON_RESUME, true),
+ Arguments.of(RESUMED, ON_PAUSE, false),
+ Arguments.of(RESUMED, ON_STOP, false),
+ Arguments.of(RESUMED, ON_DESTROY, false),
+ Arguments.of(DESTROYED, ON_CREATE, false),
+ Arguments.of(DESTROYED, ON_START, false),
+ Arguments.of(DESTROYED, ON_RESUME, false),
+ Arguments.of(DESTROYED, ON_PAUSE, true),
+ Arguments.of(DESTROYED, ON_STOP, true),
+ Arguments.of(DESTROYED, ON_DESTROY, true),
+ )
+ }
+}
diff --git a/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt
new file mode 100644
index 00000000..26d88d76
--- /dev/null
+++ b/modo-compose/src/test/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapterTestUtils.kt
@@ -0,0 +1,108 @@
+package com.github.terrakok.modo.android
+
+import android.os.Bundle
+import androidx.arch.core.executor.ArchTaskExecutor
+import androidx.arch.core.executor.TaskExecutor
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import com.github.terrakok.modo.model.ScreenModelStore
+
+object ModoScreenAndroidAdapterTestUtils {
+
+ fun setupArchTaskExecutor() {
+ ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
+ override fun executeOnDiskIO(runnable: Runnable) = runnable.run()
+ override fun postToMainThread(runnable: Runnable) = runnable.run()
+ override fun isMainThread() = true
+ })
+ }
+
+ fun cleanupArchTaskExecutor() {
+ ArchTaskExecutor.getInstance().setDelegate(null)
+ }
+
+ fun cleanupScreenModelStore() {
+ ScreenModelStore.removedScreenKeys.clear()
+ ScreenModelStore.screenModels.clear()
+ ScreenModelStore.dependencies.clear()
+ }
+}
+
+class TestLifecycleOwner : LifecycleOwner {
+ private val lifecycleRegistry = LifecycleRegistry(this)
+ override val lifecycle: Lifecycle get() = lifecycleRegistry
+
+ var lifecycleState: Lifecycle.State
+ set(value) {
+ lifecycleRegistry.currentState = value
+ }
+ get() = lifecycleRegistry.currentState
+
+}
+
+/**
+ * Test helper that emulates the behavior of LifecycleDisposableEffect composable.
+ * Allows manual control over composition lifecycle and parent state for testing.
+ */
+class CompositionLifecycleEmulator(
+ private val adapter: ModoScreenAndroidAdapter,
+ private val parentLifecycleOwner: TestLifecycleOwner,
+) {
+
+ private var usesTransitionLifecycle: Boolean = false
+ private val savedState = Bundle()
+ private var unsubscribeFromParent: (() -> Unit)? = null
+ private var isInComposition = false
+
+ init {
+ initializeAdapter()
+ adapter.lifecycleManager.parentLifecycleOwner.set(parentLifecycleOwner)
+ }
+
+ fun enterComposition(
+ usesTransitionLifecycle: Boolean = false,
+ isActivityFinishing: () -> Boolean = { false },
+ isChangingConfigurations: () -> Boolean = { false }
+ ) {
+ check(!isInComposition) { "Already in composition" }
+ isInComposition = true
+
+ this.usesTransitionLifecycle = usesTransitionLifecycle
+
+ adapter.lifecycleManager.handleCompositionEnter(usesTransitionLifecycle)
+
+ unsubscribeFromParent = adapter.subscribeToParentLifecycle(
+ parentLifecycleOwner = parentLifecycleOwner,
+ savedState = savedState,
+ isActivityFinishing = isActivityFinishing,
+ isChangingConfigurations = isChangingConfigurations
+ )
+ }
+
+ fun exitComposition() {
+ check(isInComposition) { "Not in composition" }
+ isInComposition = false
+
+ unsubscribeFromParent?.invoke()
+ adapter.lifecycleManager.handleCompositionExit()
+ }
+
+ fun showTransitionFinished() {
+ adapter.showTransitionFinished()
+ }
+
+ fun hideTransitionStarted() {
+ adapter.hideTransitionStarted()
+ }
+
+ val lifecycleState: Lifecycle.State
+ get() = adapter.lifecycle.currentState
+
+ val parentState: Lifecycle.State
+ get() = parentLifecycleOwner.lifecycle.currentState
+
+ private fun initializeAdapter() {
+ adapter.onCreate(savedState)
+ }
+}
diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt
index 0feb531b..a79bce02 100644
--- a/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt
+++ b/sample/src/main/java/com/github/terrakok/modo/sample/ModoSampleApplication.kt
@@ -17,6 +17,9 @@ class ModoSampleApplication : Application() {
ModoDevOptions.onIllegalClearState = ModoDevOptions.ValidationFailedStrategy { throwable ->
throw throwable
}
+ ModoDevOptions.onIllegalLifecycleUpdate = ModoDevOptions.ValidationFailedStrategy { throwable ->
+ throw throwable
+ }
ModoDevOptions.onScreenDisposeListener = {
it.logcat { "Screen disposed" }
}
diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt
index daf68e53..0ab33f9e 100644
--- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt
+++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/MainScreen.kt
@@ -3,6 +3,7 @@ package com.github.terrakok.modo.sample.screens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -94,6 +95,8 @@ internal fun Screen.MainScreenContent(
modifier: Modifier = Modifier,
canOpenFragment: Boolean = false,
) {
+ val counterState = remember { mutableIntStateOf(0) }
+ counterState.intValue = counter
ButtonsScreenContent(
screenIndex = screenIndex,
screenName = "MainScreen",
@@ -104,7 +107,8 @@ internal fun Screen.MainScreenContent(
i = screenIndex,
canOpenFragment = canOpenFragment
),
- counter = counter,
+ counterState = counterState,
+ enableCounter = true,
modifier = modifier
)
}
@@ -141,6 +145,7 @@ private fun rememberButtons(
ModoButtonSpec("Stack actions") { navigation?.forward(StackActionsScreen(i + 1)) },
ModoButtonSpec("HorizontalPager") { navigation?.forward(HorizontalPagerScreen()) },
ModoButtonSpec("Custom Stack") { navigation?.forward(CustomStackSample(i + 1)) },
+ ModoButtonSpec("No animation Stack") { navigation?.forward(CustomStackSample(i + 1, hasAnimation = false)) },
ModoButtonSpec("Stacks in LazyColumn") { navigation?.forward(StackInLazyColumnScreen()) },
ModoButtonSpec("Dialogs & BottomSheets") { navigation?.forward(DialogsPlayground(i + 1)) },
ModoButtonSpec("Multiscreen") { navigation?.forward(SampleMultiScreen()) },
diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt
index e78b413d..a3ca27e8 100644
--- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt
+++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/base/ButtonsScreenContent.kt
@@ -22,18 +22,25 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.IntState
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.currentStateAsState
import com.github.terrakok.modo.ExperimentalModoApi
import com.github.terrakok.modo.Screen
import com.github.terrakok.modo.ScreenKey
@@ -67,11 +74,12 @@ internal fun Screen.ButtonsScreenContent(
if (logLifecycle) {
LogLifecycle()
}
- val counter by rememberCounterState()
+ val counterState = rememberCounterState()
ButtonsScreenContent(
screenIndex = screenIndex,
screenName = screenName,
- counter = if (enableCounter) counter else 0,
+ counterState = counterState,
+ enableCounter = enableCounter,
screenKey = screenKey,
state = state,
topRightButtonSlot = topRightButtonSlot,
@@ -98,7 +106,8 @@ fun rememberCounterState(): IntState {
internal fun ButtonsScreenContent(
screenIndex: Int,
screenName: String,
- counter: Int,
+ counterState: IntState,
+ enableCounter: Boolean,
screenKey: ScreenKey,
state: GroupedButtonsState,
modifier: Modifier = Modifier,
@@ -109,7 +118,8 @@ internal fun ButtonsScreenContent(
SampleScreenContent(
screenIndex = screenIndex,
screenName = screenName,
- counter = counter,
+ counterState = counterState,
+ enableCounter = enableCounter,
screenKey = screenKey,
topRightButtonSlot = topRightButtonSlot,
windowInsets = windowInsets,
@@ -132,11 +142,12 @@ internal fun Screen.SampleScreenContent(
content: @Composable ColumnScope.() -> Unit
) {
LogLifecycle()
- val counter by rememberCounterState()
+ val counterState = rememberCounterState()
SampleScreenContent(
screenIndex = screenIndex,
screenName = screenName,
- counter = counter,
+ counterState = counterState,
+ enableCounter = true,
screenKey = screenKey,
modifier = modifier,
windowInsets = windowInsets,
@@ -144,6 +155,20 @@ internal fun Screen.SampleScreenContent(
)
}
+@Composable
+private fun CounterText(
+ counterState: IntState,
+ enableCounter: Boolean,
+ modifier: Modifier = Modifier
+) {
+ if (enableCounter) {
+ Text(
+ text = counterState.intValue.toString(),
+ modifier = modifier
+ )
+ }
+}
+
@OptIn(ExperimentalModoApi::class)
@Composable
fun Screen.LogLifecycle(prefix: String = "") {
@@ -172,16 +197,37 @@ fun Screen.LogLifecycle(prefix: String = "") {
internal fun SampleScreenContent(
screenIndex: Int,
screenName: String,
- counter: Int,
+ counterState: IntState,
+ enableCounter: Boolean,
screenKey: ScreenKey,
modifier: Modifier = Modifier,
windowInsets: WindowInsets = WindowInsets.systemBars,
topRightButtonSlot: @Composable () -> Unit = {},
content: @Composable ColumnScope.() -> Unit
) {
+ val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateAsState()
+ val topLineColor by remember {
+ derivedStateOf {
+ when (lifecycleState) {
+ Lifecycle.State.RESUMED -> Color.Green
+ Lifecycle.State.STARTED -> Color.Yellow
+ Lifecycle.State.CREATED,
+ Lifecycle.State.INITIALIZED,
+ Lifecycle.State.DESTROYED -> Color.Black
+ }
+ }
+ }
Box(
modifier = modifier
.randomBackground()
+ .drawWithContent {
+ val strokeWidth = 8.dp.toPx()
+ drawRect(
+ color = topLineColor,
+ size = Size(width = size.width, height = strokeWidth)
+ )
+ drawContent()
+ }
.windowInsetsPadding(windowInsets),
) {
Column(Modifier.padding(8.dp)) {
@@ -192,8 +238,9 @@ internal fun SampleScreenContent(
BackButton(
onClick = { stackNavigation?.back() },
)
- Text(
- text = counter.toString(),
+ CounterText(
+ counterState = counterState,
+ enableCounter = enableCounter,
modifier = Modifier.weight(1f)
)
topRightButtonSlot()
@@ -220,9 +267,11 @@ internal fun SampleScreenContent(
@Preview
@Composable
private fun ButtonsPreview() {
+ val counterState = remember { mutableIntStateOf(666) }
ButtonsScreenContent(
screenIndex = 0,
- counter = 666,
+ counterState = counterState,
+ enableCounter = true,
screenName = "ButtonsPreview",
screenKey = ScreenKey("ScreenKey"),
state = ButtonsState(
diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/CustomStackSample.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/CustomStackSample.kt
index ec89a4fe..677ae6a6 100644
--- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/CustomStackSample.kt
+++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/containers/CustomStackSample.kt
@@ -14,6 +14,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.github.terrakok.modo.ExperimentalModoApi
import com.github.terrakok.modo.NavModel
+import com.github.terrakok.modo.SaveableContent
import com.github.terrakok.modo.lifecycle.OnScreenRemoved
import com.github.terrakok.modo.sample.SlideTransition
import com.github.terrakok.modo.sample.components.CancelButton
@@ -32,13 +33,19 @@ import logcat.logcat
@Parcelize
class CustomStackSample(
private val i: Int,
- private val navModel: StackNavModel
+ private val navModel: StackNavModel,
+ private val hasAnimation: Boolean = true,
) : SampleStack(navModel) {
constructor(
i: Int,
+ hasAnimation: Boolean = true,
sampleNavigationState: StackState = StackState(MainScreen(1))
- ) : this(i, NavModel(sampleNavigationState))
+ ) : this(
+ i = i,
+ hasAnimation = hasAnimation,
+ navModel = NavModel(sampleNavigationState)
+ )
@Suppress("ModifierNotUsedAtRoot")
@OptIn(ExperimentalModoApi::class)
@@ -60,7 +67,11 @@ class CustomStackSample(
.fillMaxSize()
.clip(RoundedCornerShape(16.dp)),
) { modifier ->
- SlideTransition(modifier)
+ if (hasAnimation) {
+ SlideTransition(modifier)
+ } else {
+ screen.SaveableContent(modifier)
+ }
}
}
}
diff --git a/sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/LifecycleSampleScreen.kt b/sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/LifecycleSampleScreen.kt
index ed60c2fd..45bee343 100644
--- a/sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/LifecycleSampleScreen.kt
+++ b/sample/src/main/java/com/github/terrakok/modo/sample/screens/lifecycle/LifecycleSampleScreen.kt
@@ -46,7 +46,8 @@ class LifecycleSampleScreen(
@OptIn(ExperimentalModoApi::class, ExperimentalStdlibApi::class)
@Composable
override fun Content(modifier: Modifier) {
- val counter by rememberCounterState()
+ val counterState = rememberCounterState()
+ val counter by counterState
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(this) {
@@ -87,7 +88,8 @@ class LifecycleSampleScreen(
screenIndex = screenIndex,
screenName = "ScreenEffectsSampleScreen",
screenKey = screenKey,
- counter = counter,
+ counterState = counterState,
+ enableCounter = true,
modifier = modifier,
) {
GroupedButtonsList(
diff --git a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopApp.kt b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopApp.kt
index 81c8eae0..584c6903 100644
--- a/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopApp.kt
+++ b/workshop-app/src/main/kotlin/io/github/ikarenkov/workshop/WorkshopApp.kt
@@ -21,6 +21,9 @@ class WorkshopApp : Application() {
ModoDevOptions.onIllegalClearState = ModoDevOptions.ValidationFailedStrategy { throwable ->
logcat(priority = LogPriority.ERROR) { "Cleaning state of composable, which still can be visible for user." }
}
+ ModoDevOptions.onIllegalLifecycleUpdate = ModoDevOptions.ValidationFailedStrategy { throwable ->
+ throw throwable
+ }
startKoin {
androidLogger()