Skip to content

Fixed resumed child when parent is not resumed #65

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Nov 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# [Modo](https://ikarenkov.github.io/Modo) - State-Based Jetpack Compose Navigation

[![Maven Central latest stable](https://img.shields.io/maven-central/v/com.github.terrakok/modo-compose?versionSuffix=0.10.0&label=Latest%20stable&color=28A745)](https://central.sonatype.com/artifact/com.github.terrakok/modo-compose/0.10.0/overview)
[![Maven Central latest stable](https://img.shields.io/maven-central/v/com.github.terrakok/modo-compose?versionSuffix=0.10.1&label=Latest%20stable&color=28A745)](https://central.sonatype.com/artifact/com.github.terrakok/modo-compose/0.10.1/overview)
[![Maven Central latest](https://img.shields.io/maven-central/v/com.github.terrakok/modo-compose?label=Latest&color=FFA500)](https://central.sonatype.com/artifact/com.github.terrakok/modo-compose)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

Expand Down
1 change: 1 addition & 0 deletions Writerside/modo-docs.tree
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<toc-element topic="Screen-effects.md" />
<toc-element topic="Modo-and-DI.md" />
<toc-element topic="Android-integrations.md" />
<toc-element topic="Lifecycle.md"/>
</toc-element>
<toc-element topic="Community-and-contribution.md" />
</instance-profile>
31 changes: 5 additions & 26 deletions Writerside/topics/Android-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,12 @@ and `LocalSavedStateRegistryOwner`. This allows you to use functions like `viewM
> [Sample code](%github_code_url%sample/src/main/java/com/github/terrakok/modo/sample/screens/viewmodel/AndroidViewModelSampleScreen.kt) demonstrating
> Android integration inside a Modo Screen.

## Lifecycle

Since Modo provides Lifecycle support, let's take a look at what specific lifecycle events mean in the context of Screen Lifecycle:

* `Lifecycle.Event.ON_CREATE` - called once per screen when the screen is created.
* `Lifecycle.Event.ON_START` and `Lifecycle.Event.ON_RESUME` - dispatched when `Screen.Content` is composed for the first time.
* `Lifecycle.Event.ON_PAUSE` and `Lifecycle.Event.ON_STOP` - dispatched when `Screen.Content` leaves the composition.
* `Lifecycle.Event.ON_DESTROY` - called once per screen when it is removed from the navigation graph.

For correctly handling `Lifecycle.Event` from your `Screen.Content`, we recommend using the built-in [screen effects](Screen-effects.md).
The `LifecycleScreenEffect` is a convenient way to subscribe to a screen's Lifecycle.

```kotlin
LifecycleScreenEffect {
LifecycleEventObserver { _, event ->
logcat { "Lifecycle event $event." }
}
}
```

> If you use `LaunchedEffect` or `DisposableEffect` to subscribe to the screen's Lifecycle, you won't receive `Lifecycle.Event.ON_DESTROY` because
> this event happens after the screen leaves the composition.

{ style="warning" }

## ViewModel

You can use functions provided by Jetpack Compose to get a `ViewModel`.

<include from="snippets.topic" element-id="under_develop_note"/>
<include from="snippets.topic" element-id="under_develop_note"/>

## Lifecycle

Moved to [Lifecycle](Lifecycle.md).
90 changes: 90 additions & 0 deletions Writerside/topics/Lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# 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.

## Android Lifecycle Integration

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.

You can use `LocalLifecycleOwner` inside `Screen.Content` to access the lifecycle of a screen. This will return the nearest screen's lifecycle owner.

```kotlin
class SampleScreen : Screen {
override fun Content(modifier: Modifier) {
val lifecycleOwner = LocalLifecycleOwner.current
// Use lifecycleOwner to observe lifecycle events
}
}
```

### Lifecycle States and Events

Here’s an overview of lifecycle states and their meanings in the context of the screen lifecycle:

| **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. |

> `ON_CREATE` and `ON_DESTROY` are dispatched once per screen instance.

### Screen Transitions and Lifecycle

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:

| **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`. |

### Parent-Child Lifecycle Propagation

The lifecycle of parent and child screens follows a set of rules, ensuring consistency and predictability:

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`.

### Practical Example: Keyboard Management

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:

* `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)

```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()
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
TextField(text, setText, modifier = Modifier.focusRequester(focusRequester))
```
Original file line number Diff line number Diff line change
Expand Up @@ -26,49 +26,57 @@ class DetektPlugin : Plugin<Project> {

fun Project.setupDetektTask() {
tasks.register<Detekt>("detektAll") {
reports {
sarif.required = true
}
// The directories where detekt looks for source files.
// Defaults to `files("src/main/java", "src/test/java", "src/main/kotlin", "src/test/kotlin")`.
setSource(projectDir)
configureDetekt(this)
}
tasks.register<Detekt>("detektFormat") {
configureDetekt(this)
autoCorrect = true
}
}

include("**/*.kt")
include("**/*.kts")
exclude("**/resources/")
exclude("**/build/")
exclude("Writerside/codeSnippets/")
private fun Project.configureDetekt(detekt: Detekt) {
detekt.reports {
sarif.required = true
}
// The directories where detekt looks for source files.
// Defaults to `files("src/main/java", "src/test/java", "src/main/kotlin", "src/test/kotlin")`.
detekt.setSource(project.projectDir)

detekt.include("**/*.kt")
detekt.include("**/*.kts")
detekt.exclude("**/resources/")
detekt.exclude("**/build/")
detekt.exclude("Writerside/codeSnippets/")

// Builds the AST in parallel. Rules are always executed in parallel.
// Can lead to speedups in larger projects. `false` by default.
parallel = true
// Builds the AST in parallel. Rules are always executed in parallel.
// Can lead to speedups in larger projects. `false` by default.
detekt.parallel = true

// Define the detekt configuration(s) you want to use.
// Defaults to the default detekt configuration.
config.setFrom("config/detekt/detekt.yml")
// Define the detekt configuration(s) you want to use.
// Defaults to the default detekt configuration.
detekt.config.setFrom("config/detekt/detekt.yml")

// Applies the config files on top of detekt's default config file. `false` by default.
buildUponDefaultConfig = false
// Applies the config files on top of detekt's default config file. `false` by default.
detekt.buildUponDefaultConfig = false

// Turns on all the rules. `false` by default.
allRules = false
// Turns on all the rules. `false` by default.
detekt.allRules = false

// Specifying a baseline file. All findings stored in this file in subsequent runs of detekt.
// Specifying a baseline file. All findings stored in this file in subsequent runs of detekt.
// baseline = file("path/to/baseline.xml")

// Disables all default detekt rulesets and will only run detekt with custom rules
// defined in plugins passed in with `detektPlugins` configuration. `false` by default.
disableDefaultRuleSets = false
// Disables all default detekt rulesets and will only run detekt with custom rules
// defined in plugins passed in with `detektPlugins` configuration. `false` by default.
detekt.disableDefaultRuleSets = false

// Adds debug output during task execution. `false` by default.
debug = false
// Adds debug output during task execution. `false` by default.
detekt.debug = false

// If set to `true` the build does not fail when the
// maxIssues count was reached. Defaults to `false`.
ignoreFailures = true
// If set to `true` the build does not fail when the
// maxIssues count was reached. Defaults to `false`.
detekt.ignoreFailures = true

// Specify the base path for file paths in the formatted reports.
// If not set, all file paths reported will be absolute file path.
basePath = rootProject.projectDir.absolutePath
}
// Specify the base path for file paths in the formatted reports.
// If not set, all file paths reported will be absolute file path.
detekt.basePath = project.rootProject.projectDir.absolutePath
}
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[versions]
composeWheelPicker = "1.0.0-beta05"
leakcanaryAndroid = "2.14"
modo = "0.10.0"
modo = "0.10.1"
androidGradlePlugin = "8.4.0"
detektComposeVersion = "0.3.20"
detektVersion = "1.23.6"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ private val LocalAfterScreenContentOnDispose = staticCompositionLocalOf<() -> Un
* 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.onResume] and [LifecycleDependency.onPause]
* @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].
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ import com.github.terrakok.modo.util.getApplication
import java.util.concurrent.atomic.AtomicReference

/**
* Adapter for Screem, to support android-related features using Modo, such as:
* 1. ViewModel support
* 2. Lifecycle support
* 3. SavedState support
* Adapter for Screen that provides android-related features support using Modo, such as:
* 1. ViewModel
* 2. Lifecycle
* 3. SavedState
*
* It the single instance of [ModoScreenAndroidAdapter] per Screen.
*/
class ModoScreenAndroidAdapter private constructor(
// just for debug purpose.
// For debugging purposes
internal val screen: Screen
) :
LifecycleOwner,
Expand Down Expand Up @@ -100,6 +100,13 @@ class ModoScreenAndroidAdapter private constructor(
private val atomicParentLifecycleOwner = AtomicReference<LifecycleOwner>()
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()
Expand Down Expand Up @@ -130,12 +137,20 @@ class ModoScreenAndroidAdapter private constructor(
safeHandleLifecycleEvent(ON_DESTROY)
}

override fun onPause() {
override fun hideTransitionStarted() {
screenTransitionState = ScreenTransitionState.HIDING
safeHandleLifecycleEvent(ON_PAUSE)
}

override fun onResume() {
safeHandleLifecycleEvent(ON_RESUME)
override fun showTransitionFinished() {
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)
}
}

override fun toString(): String = "${ModoScreenAndroidAdapter::class.simpleName}, screenKey: ${screen.screenKey}"
Expand Down Expand Up @@ -239,6 +254,7 @@ class ModoScreenAndroidAdapter private constructor(
if (
needPropagateLifecycleEventFromParent(
event,
screenTransitionState = screenTransitionState,
isActivityFinishing = activity?.isFinishing,
isChangingConfigurations = activity?.isChangingConfigurations
)
Expand Down Expand Up @@ -266,10 +282,31 @@ class ModoScreenAndroidAdapter private constructor(
val skippEvent = needSkipEvent(lifecycle.currentState, event)
if (!skippEvent) {
// Log.d("ModoScreenAndroidAdapter", "${screen.screenKey} handleLifecycleEvent $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 == Lifecycle.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 == Lifecycle.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
}
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(
Expand All @@ -296,8 +333,9 @@ class ModoScreenAndroidAdapter private constructor(
) { ModoScreenAndroidAdapter(screen) }

@JvmStatic
fun needPropagateLifecycleEventFromParent(
private fun needPropagateLifecycleEventFromParent(
event: Lifecycle.Event,
screenTransitionState: ScreenTransitionState,
isActivityFinishing: Boolean?,
isChangingConfigurations: Boolean?
) =
Expand All @@ -312,11 +350,18 @@ class ModoScreenAndroidAdapter private constructor(
*
* In the case of Fragments, we unsubscribe before ON_DESTROY event, so there is no problem with this.
*/
if (event == ON_DESTROY && (isActivityFinishing == false || isChangingConfigurations == true)) {
false
} else {
// Parent can only move lifecycle state down. Because parent cant be already resumed, but child is not, because of running animation.
event !in moveLifecycleStateUpEvents
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
Expand Down
Loading