Skip to content

Commit a319f38

Browse files
authored
Merge pull request #65 from ikarenkov/bug-resemed-first-nested-screen
Fixed resumed child when parent is not resumed
2 parents 0e8f460 + 8de56d2 commit a319f38

File tree

28 files changed

+540
-220
lines changed

28 files changed

+540
-220
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# [Modo](https://ikarenkov.github.io/Modo) - State-Based Jetpack Compose Navigation
22

3-
[![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)
3+
[![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)
44
[![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)
55
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
66

Writerside/modo-docs.tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<toc-element topic="Screen-effects.md" />
1515
<toc-element topic="Modo-and-DI.md" />
1616
<toc-element topic="Android-integrations.md" />
17+
<toc-element topic="Lifecycle.md"/>
1718
</toc-element>
1819
<toc-element topic="Community-and-contribution.md" />
1920
</instance-profile>

Writerside/topics/Android-integrations.md

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,12 @@ and `LocalSavedStateRegistryOwner`. This allows you to use functions like `viewM
66
> [Sample code](%github_code_url%sample/src/main/java/com/github/terrakok/modo/sample/screens/viewmodel/AndroidViewModelSampleScreen.kt) demonstrating
77
> Android integration inside a Modo Screen.
88
9-
## Lifecycle
10-
11-
Since Modo provides Lifecycle support, let's take a look at what specific lifecycle events mean in the context of Screen Lifecycle:
12-
13-
* `Lifecycle.Event.ON_CREATE` - called once per screen when the screen is created.
14-
* `Lifecycle.Event.ON_START` and `Lifecycle.Event.ON_RESUME` - dispatched when `Screen.Content` is composed for the first time.
15-
* `Lifecycle.Event.ON_PAUSE` and `Lifecycle.Event.ON_STOP` - dispatched when `Screen.Content` leaves the composition.
16-
* `Lifecycle.Event.ON_DESTROY` - called once per screen when it is removed from the navigation graph.
17-
18-
For correctly handling `Lifecycle.Event` from your `Screen.Content`, we recommend using the built-in [screen effects](Screen-effects.md).
19-
The `LifecycleScreenEffect` is a convenient way to subscribe to a screen's Lifecycle.
20-
21-
```kotlin
22-
LifecycleScreenEffect {
23-
LifecycleEventObserver { _, event ->
24-
logcat { "Lifecycle event $event." }
25-
}
26-
}
27-
```
28-
29-
> If you use `LaunchedEffect` or `DisposableEffect` to subscribe to the screen's Lifecycle, you won't receive `Lifecycle.Event.ON_DESTROY` because
30-
> this event happens after the screen leaves the composition.
31-
32-
{ style="warning" }
33-
349
## ViewModel
3510

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

38-
<include from="snippets.topic" element-id="under_develop_note"/>
13+
<include from="snippets.topic" element-id="under_develop_note"/>
14+
15+
## Lifecycle
16+
17+
Moved to [Lifecycle](Lifecycle.md).

Writerside/topics/Lifecycle.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Lifecycle
2+
3+
This article covers the lifetime of screen instances and their integration with the Android Lifecycle.
4+
5+
## Screen Instance Lifecycle
6+
7+
The lifetime of a screen instance is guaranteed to match the application (process) lifetime when you integrate Modo using built-in functions such as
8+
`Modo.rememberRootScreen`. Regardless of how many times a screen is recomposed, or whether the activity and/or fragment is recreated, the screen
9+
instance remains consistent. This allows you to safely inject the screen instance into your DI container.
10+
11+
## Android Lifecycle Integration
12+
13+
Modo provides seamless [integration](%github_code_url%/modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt)
14+
with a Android Lifecycle for your screens.
15+
16+
You can use `LocalLifecycleOwner` inside `Screen.Content` to access the lifecycle of a screen. This will return the nearest screen's lifecycle owner.
17+
18+
```kotlin
19+
class SampleScreen : Screen {
20+
override fun Content(modifier: Modifier) {
21+
val lifecycleOwner = LocalLifecycleOwner.current
22+
// Use lifecycleOwner to observe lifecycle events
23+
}
24+
}
25+
```
26+
27+
### Lifecycle States and Events
28+
29+
Here’s an overview of lifecycle states and their meanings in the context of the screen lifecycle:
30+
31+
| **State** | **Meaning** |
32+
|-----------------|-----------------------------------------------------------------------------------------------------------------------------|
33+
| **INITIALIZED** | The screen is constructed (instance created) but has never been displayed. |
34+
| **CREATED** | The screen is in the navigation hierarchy and can be reached from the `RootScreen` integrated with an Activity or Fragment. |
35+
| **STARTED** | `Screen.Content` is in composition. |
36+
| **RESUMED** | The screen is **STARTED**, and there are no unfinished transitions for this screen or its parent. |
37+
| **DESTROYED** | The screen is removed from the navigation graph. |
38+
39+
> `ON_CREATE` and `ON_DESTROY` are dispatched once per screen instance.
40+
41+
### Screen Transitions and Lifecycle
42+
43+
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
44+
events. Here’s a summary:
45+
46+
| **Event** | **With Transition** | **Without Transition** |
47+
|---------------|------------------------------------------------------------------------------------------------|---------------------------------------------|
48+
| **ON_RESUME** | Dispatched when there are no unfinished transitions, and the parent is in the `RESUMED` state. | Dispatched when the parent is in `RESUMED`. |
49+
| **ON_PAUSE** | Dispatched when a hiding transition starts. | Dispatched immediately before `ON_STOP`. |
50+
51+
### Parent-Child Lifecycle Propagation
52+
53+
The lifecycle of parent and child screens follows a set of rules, ensuring consistency and predictability:
54+
55+
1. A screen's `Lifecycle.State` is always less than or equal to (`<=`) its parent's state.
56+
2. A child screen is not moved to the `RESUMED` state until its parent is also in the `RESUMED` state.
57+
3. When a screen's lifecycle state is downgraded, its child screens are also moved to the same state.
58+
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`.
59+
60+
### Practical Example: Keyboard Management
61+
62+
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
63+
`ON_PAUSE` events:
64+
65+
* `ON_RESUME` indicates that the screen is ready for user input (transitions are finished)
66+
* `ON_PAUSE` indicates that the screen is not ready for user input (transitions are starting)
67+
68+
```kotlin
69+
val lifecycleOwner = LocalLifecycleOwner.current
70+
val keyboardController = LocalSoftwareKeyboardController.current
71+
DisposableEffect(this) {
72+
val observer = LifecycleEventObserver { _, event ->
73+
when (event) {
74+
Lifecycle.Event.ON_RESUME -> {
75+
focusRequester.requestFocus()
76+
}
77+
Lifecycle.Event.ON_PAUSE -> {
78+
focusRequester.freeFocus()
79+
keyboardController?.hide()
80+
}
81+
else -> {}
82+
}
83+
}
84+
lifecycleOwner.lifecycle.addObserver(observer)
85+
onDispose {
86+
lifecycleOwner.lifecycle.removeObserver(observer)
87+
}
88+
}
89+
TextField(text, setText, modifier = Modifier.focusRequester(focusRequester))
90+
```

build-logic/convention/src/main/kotlin/com/github/terrakok/DetektPlugin.kt

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -26,49 +26,57 @@ class DetektPlugin : Plugin<Project> {
2626

2727
fun Project.setupDetektTask() {
2828
tasks.register<Detekt>("detektAll") {
29-
reports {
30-
sarif.required = true
31-
}
32-
// The directories where detekt looks for source files.
33-
// Defaults to `files("src/main/java", "src/test/java", "src/main/kotlin", "src/test/kotlin")`.
34-
setSource(projectDir)
29+
configureDetekt(this)
30+
}
31+
tasks.register<Detekt>("detektFormat") {
32+
configureDetekt(this)
33+
autoCorrect = true
34+
}
35+
}
3536

36-
include("**/*.kt")
37-
include("**/*.kts")
38-
exclude("**/resources/")
39-
exclude("**/build/")
40-
exclude("Writerside/codeSnippets/")
37+
private fun Project.configureDetekt(detekt: Detekt) {
38+
detekt.reports {
39+
sarif.required = true
40+
}
41+
// The directories where detekt looks for source files.
42+
// Defaults to `files("src/main/java", "src/test/java", "src/main/kotlin", "src/test/kotlin")`.
43+
detekt.setSource(project.projectDir)
44+
45+
detekt.include("**/*.kt")
46+
detekt.include("**/*.kts")
47+
detekt.exclude("**/resources/")
48+
detekt.exclude("**/build/")
49+
detekt.exclude("Writerside/codeSnippets/")
4150

42-
// Builds the AST in parallel. Rules are always executed in parallel.
43-
// Can lead to speedups in larger projects. `false` by default.
44-
parallel = true
51+
// Builds the AST in parallel. Rules are always executed in parallel.
52+
// Can lead to speedups in larger projects. `false` by default.
53+
detekt.parallel = true
4554

46-
// Define the detekt configuration(s) you want to use.
47-
// Defaults to the default detekt configuration.
48-
config.setFrom("config/detekt/detekt.yml")
55+
// Define the detekt configuration(s) you want to use.
56+
// Defaults to the default detekt configuration.
57+
detekt.config.setFrom("config/detekt/detekt.yml")
4958

50-
// Applies the config files on top of detekt's default config file. `false` by default.
51-
buildUponDefaultConfig = false
59+
// Applies the config files on top of detekt's default config file. `false` by default.
60+
detekt.buildUponDefaultConfig = false
5261

53-
// Turns on all the rules. `false` by default.
54-
allRules = false
62+
// Turns on all the rules. `false` by default.
63+
detekt.allRules = false
5564

56-
// Specifying a baseline file. All findings stored in this file in subsequent runs of detekt.
65+
// Specifying a baseline file. All findings stored in this file in subsequent runs of detekt.
5766
// baseline = file("path/to/baseline.xml")
5867

59-
// Disables all default detekt rulesets and will only run detekt with custom rules
60-
// defined in plugins passed in with `detektPlugins` configuration. `false` by default.
61-
disableDefaultRuleSets = false
68+
// Disables all default detekt rulesets and will only run detekt with custom rules
69+
// defined in plugins passed in with `detektPlugins` configuration. `false` by default.
70+
detekt.disableDefaultRuleSets = false
6271

63-
// Adds debug output during task execution. `false` by default.
64-
debug = false
72+
// Adds debug output during task execution. `false` by default.
73+
detekt.debug = false
6574

66-
// If set to `true` the build does not fail when the
67-
// maxIssues count was reached. Defaults to `false`.
68-
ignoreFailures = true
75+
// If set to `true` the build does not fail when the
76+
// maxIssues count was reached. Defaults to `false`.
77+
detekt.ignoreFailures = true
6978

70-
// Specify the base path for file paths in the formatted reports.
71-
// If not set, all file paths reported will be absolute file path.
72-
basePath = rootProject.projectDir.absolutePath
73-
}
79+
// Specify the base path for file paths in the formatted reports.
80+
// If not set, all file paths reported will be absolute file path.
81+
detekt.basePath = project.rootProject.projectDir.absolutePath
7482
}

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[versions]
22
composeWheelPicker = "1.0.0-beta05"
33
leakcanaryAndroid = "2.14"
4-
modo = "0.10.0"
4+
modo = "0.10.1"
55
androidGradlePlugin = "8.4.0"
66
detektComposeVersion = "0.3.20"
77
detektVersion = "1.23.6"

modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ private val LocalAfterScreenContentOnDispose = staticCompositionLocalOf<() -> Un
5050
* 3. Handles lifecycle of [Screen] by adding [DisposableEffect] before and after content, in order to notify [ComposeRenderer]
5151
* when [Screen.Content] is about to leave composition and when it has left composition.
5252
* @param modifier is a modifier that will be passed into [Screen.Content]
53-
* @param manualResumePause define whenever we are going to manually call [LifecycleDependency.onResume] and [LifecycleDependency.onPause]
53+
* @param manualResumePause define whenever we are going to manually call [LifecycleDependency.showTransitionFinished] and [LifecycleDependency.hideTransitionStarted]
5454
* to emmit [ON_RESUME] and [ON_PAUSE]. Otherwise, [ON_RESUME] will be called straight after [ON_START] and [ON_PAUSE] will be called straight
5555
* before [ON_STOP].
5656
*

modo-compose/src/main/java/com/github/terrakok/modo/android/ModoScreenAndroidAdapter.kt

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,15 @@ import com.github.terrakok.modo.util.getApplication
5050
import java.util.concurrent.atomic.AtomicReference
5151

5252
/**
53-
* Adapter for Screem, to support android-related features using Modo, such as:
54-
* 1. ViewModel support
55-
* 2. Lifecycle support
56-
* 3. SavedState support
53+
* Adapter for Screen that provides android-related features support using Modo, such as:
54+
* 1. ViewModel
55+
* 2. Lifecycle
56+
* 3. SavedState
5757
*
5858
* It the single instance of [ModoScreenAndroidAdapter] per Screen.
5959
*/
6060
class ModoScreenAndroidAdapter private constructor(
61-
// just for debug purpose.
61+
// For debugging purposes
6262
internal val screen: Screen
6363
) :
6464
LifecycleOwner,
@@ -100,6 +100,13 @@ class ModoScreenAndroidAdapter private constructor(
100100
private val atomicParentLifecycleOwner = AtomicReference<LifecycleOwner>()
101101
private val application: Application? get() = atomicContext.get()?.applicationContext?.getApplication()
102102

103+
/**
104+
* Holding transition state of the screen to be able to handle lifecycle events from parent properly.
105+
* Check out [needPropagateLifecycleEventFromParent] for more details.
106+
*/
107+
@Volatile
108+
private var screenTransitionState: ScreenTransitionState = ScreenTransitionState.HIDDEN
109+
103110
init {
104111
controller.performAttach()
105112
enableSavedStateHandles()
@@ -130,12 +137,20 @@ class ModoScreenAndroidAdapter private constructor(
130137
safeHandleLifecycleEvent(ON_DESTROY)
131138
}
132139

133-
override fun onPause() {
140+
override fun hideTransitionStarted() {
141+
screenTransitionState = ScreenTransitionState.HIDING
134142
safeHandleLifecycleEvent(ON_PAUSE)
135143
}
136144

137-
override fun onResume() {
138-
safeHandleLifecycleEvent(ON_RESUME)
145+
override fun showTransitionFinished() {
146+
screenTransitionState = ScreenTransitionState.SHOWN
147+
val parentState = atomicParentLifecycleOwner.get()?.lifecycle?.currentState
148+
// It's crucial to check parent state, because our state can't be greater than a parent state.
149+
// If this condition is not met, resuming will be done when parent will be resumed and screenTransitionState == ScreenTransitionState.SHOWN.
150+
// It can happen when we display this screen as a first screen inside container, that is animating.
151+
if (parentState != null && parentState == Lifecycle.State.RESUMED) {
152+
safeHandleLifecycleEvent(ON_RESUME)
153+
}
139154
}
140155

141156
override fun toString(): String = "${ModoScreenAndroidAdapter::class.simpleName}, screenKey: ${screen.screenKey}"
@@ -239,6 +254,7 @@ class ModoScreenAndroidAdapter private constructor(
239254
if (
240255
needPropagateLifecycleEventFromParent(
241256
event,
257+
screenTransitionState = screenTransitionState,
242258
isActivityFinishing = activity?.isFinishing,
243259
isChangingConfigurations = activity?.isChangingConfigurations
244260
)
@@ -266,10 +282,31 @@ class ModoScreenAndroidAdapter private constructor(
266282
val skippEvent = needSkipEvent(lifecycle.currentState, event)
267283
if (!skippEvent) {
268284
// Log.d("ModoScreenAndroidAdapter", "${screen.screenKey} handleLifecycleEvent $event")
285+
screenTransitionState = when {
286+
// Whenever we receive ON_RESUME event, we need to move screen to SHOWN state to indicate that there is no transition of this screen.
287+
event == Lifecycle.Event.ON_RESUME -> ScreenTransitionState.SHOWN
288+
// Whe need to check transition state to distinguish between
289+
// 1. finishing screen hiding transition. In this case, we need to move screen to hidden state.
290+
// 2. hiding screen, because of lifecycle event. In this case we don't need to change animation state.
291+
event == Lifecycle.Event.ON_STOP && screenTransitionState == ScreenTransitionState.HIDING -> ScreenTransitionState.HIDDEN
292+
// Pause by itself doesn't mean that screen is hidden, it can be visible, but not active. F.e. when system dialog is shown.
293+
else -> screenTransitionState
294+
}
269295
lifecycle.handleLifecycleEvent(event)
270296
}
271297
}
272298

299+
/**
300+
* Enum that represents
301+
*/
302+
private enum class ScreenTransitionState {
303+
HIDING,
304+
HIDDEN,
305+
SHOWN
306+
// There is no SHOWING state, because we cannot distinguish it by using lifecycle events and
307+
// [hideTransitionStarted] and [showTransitionFinished] methods.
308+
}
309+
273310
companion object {
274311

275312
private val moveLifecycleStateUpEvents = setOf(
@@ -296,8 +333,9 @@ class ModoScreenAndroidAdapter private constructor(
296333
) { ModoScreenAndroidAdapter(screen) }
297334

298335
@JvmStatic
299-
fun needPropagateLifecycleEventFromParent(
336+
private fun needPropagateLifecycleEventFromParent(
300337
event: Lifecycle.Event,
338+
screenTransitionState: ScreenTransitionState,
301339
isActivityFinishing: Boolean?,
302340
isChangingConfigurations: Boolean?
303341
) =
@@ -312,11 +350,18 @@ class ModoScreenAndroidAdapter private constructor(
312350
*
313351
* In the case of Fragments, we unsubscribe before ON_DESTROY event, so there is no problem with this.
314352
*/
315-
if (event == ON_DESTROY && (isActivityFinishing == false || isChangingConfigurations == true)) {
316-
false
317-
} else {
318-
// Parent can only move lifecycle state down. Because parent cant be already resumed, but child is not, because of running animation.
319-
event !in moveLifecycleStateUpEvents
353+
when {
354+
event == ON_DESTROY && (isActivityFinishing == false || isChangingConfigurations == true) -> {
355+
false
356+
}
357+
screenTransitionState == ScreenTransitionState.SHOWN && event == ON_RESUME -> {
358+
true
359+
}
360+
else -> {
361+
// Except previous condition, parent can only move lifecycle state down.
362+
// Because parent cant be already resumed, but child is not, because of running animation.
363+
event in moveLifecycleStateDownEvents
364+
}
320365
}
321366

322367
@JvmStatic

0 commit comments

Comments
 (0)