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: + + + + + + + + + + + + + + + + + + + + + + + + + + +
StateMeaning
INITIALIZEDThe screen is constructed (instance created) but has never been displayed.
CREATEDThe screen was displayed at least once.
STARTEDThe screen is in composition.
RESUMEDReady for user interaction. The screen is STARTED, visible, and all transitions are complete.
DESTROYEDThe 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()