Skip to content

Commit

Permalink
Merge pull request #64 from ikarenkov/releases/0.10.0
Browse files Browse the repository at this point in the history
Releases/0.10.0
  • Loading branch information
ikarenkov authored Oct 11, 2024
2 parents 88a6691 + 5c98776 commit 6dff8e7
Show file tree
Hide file tree
Showing 87 changed files with 3,800 additions and 333 deletions.
1 change: 1 addition & 0 deletions .github/workflows/pr_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
pull_request:
branches:
- "dev"
- "releases/**"
jobs:
static-analysis-check:
name: Static code analysis
Expand Down
26 changes: 26 additions & 0 deletions TestInstructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Lifecycle testing

When you do any changes in the internal logic, that can affect the lifecycle of the screen, you should manually test correctness of the lifecycle in
certain cases:

* Simple forward, back, replace commands
* Adding and removing container screens with nested screens
* Adding container screen with 1 screen and removing it
* Adding container screen with multiple screens and removing it
* Check same in `LazyList`
* Check correctness, with animation interruption. F.e. 2 fast back clicks
* Check pause, stop. dispose - 2 fast back or replace
* Check create, start, resume - 2 fast forward or replace
* Movable content test
* Check lifecycle order in nested screens when activity/fragment recreated, by rotating the screen. Following rules should be applied:
* Parent screens events ON_CREATE, ON_START, ON_RESUME should be called before child screens events
* Parent screens events ON_PAUSE, ON_STOP, ON_DESTROY should be called after child screens events

## Test cases

### Removing previous screen

1. Use `LifecycleScreenEffect` to subscribe to lifecycle updates in `ButtonsScreenContent` (remove comments from code).
2. Launch app and navigate to "Stack Actions".
3. Click "Remove previous".
4. "LifecycleScreenEffect ON_DESTROY" in logcat for previous screen.
3 changes: 2 additions & 1 deletion config/detekt/detekt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,7 @@ style:
ignoreEnums: false
ignoreRanges: false
ignoreExtensionFunctions: true
ignoreAnnotated: ['Preview']
MandatoryBracesLoops:
active: false
MaxChainedCallsOnSameLine:
Expand Down Expand Up @@ -882,7 +883,7 @@ Compose:
CompositionLocalAllowlist:
active: true
# -- You can optionally define a list of CompositionLocals that are allowed here
allowedCompositionLocals: LocalContainerScreen,LocalMultiScreenNavigation,LocalSaveableStateHolder,LocalTransitionCompleteChannel,LocalStackNavigation,
allowedCompositionLocals: LocalContainerScreen,LocalMultiScreenNavigation,LocalSaveableStateHolder,LocalBeforeScreenContentOnDispose,LocalAfterScreenContentOnDispose,LocalStackNavigation,
CompositionLocalNaming:
active: true
ContentEmitterReturningValues:
Expand Down
9 changes: 8 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[versions]
composeWheelPicker = "1.0.0-beta05"
leakcanaryAndroid = "2.14"
modo = "0.9.0"
modo = "0.10.0"
androidGradlePlugin = "8.4.0"
detektComposeVersion = "0.3.20"
detektVersion = "1.23.6"
Expand All @@ -18,6 +19,7 @@ kotlin = "1.9.23"
kotlinCompilerExtension = "1.5.12"
minSdk = "21"
compileSdk = "34"
koin = "4.0.0"

[libraries]
androidx-compose-bom-modo = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBomModo" }
Expand All @@ -38,20 +40,25 @@ androidx-lifecycle-runtimeKtx = { group = "androidx.lifecycle", name = "lifecycl
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-fragment = { group = "androidx.fragment", name = "fragment-ktx", version = "1.6.2" }

compose-wheelPicker = { module = "com.github.zj565061763:compose-wheel-picker", version.ref = "composeWheelPicker" }
detekt-composeRules = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektComposeVersion" }
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" }

debug-logcat = { group = "com.squareup.logcat", name = "logcat", version = "0.1" }

kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version = "1.8.1" }

# Dependencies of the included build-logic
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
android-tools-common = { group = "com.android.tools", name = "common", version.ref = "androidTools" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
detektPlugin = { group = "io.gitlab.arturbosch.detekt", name = "detekt-gradle-plugin", version.ref = "detektVersion" }

koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
[plugins]
modo-detekt = { id = "modo-detekt", version = "unspecified" }
modo-android-library = { id = "modo-android-library", version = "unspecified" }
Expand Down
161 changes: 121 additions & 40 deletions modo-compose/src/main/java/com/github/terrakok/modo/ComposeRender.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,55 +3,102 @@ package com.github.terrakok.modo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ProvidedValue
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.neverEqualPolicy
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.SaveableStateHolder
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.animation.displayingScreens
import com.github.terrakok.modo.animation.ScreenTransition
import com.github.terrakok.modo.animation.displayingScreensAfterScreenContent
import com.github.terrakok.modo.animation.displayingScreensBeforeScreenContent
import com.github.terrakok.modo.lifecycle.LifecycleDependency
import com.github.terrakok.modo.model.ScreenModelStore
import com.github.terrakok.modo.model.dependenciesSortedByRemovePriority
import com.github.terrakok.modo.util.currentOrThrow
import kotlinx.coroutines.channels.Channel

typealias RendererContent<State> = @Composable ComposeRendererScope<State>.(Modifier) -> Unit

val defaultRendererContent: (@Composable ComposeRendererScope<*>.(screenModifier: Modifier) -> Unit) = { screenModifier ->
screen.SaveableContent(screenModifier)
val channel = LocalTransitionCompleteChannel.current
// There's no animation, we can instantly mark the transition as completed
DisposableEffect(screen.screenKey) {
onDispose {
channel.trySend(Unit)
}
}
}

val LocalSaveableStateHolder = staticCompositionLocalOf<SaveableStateHolder?> { null }

private val LocalBeforeScreenContentOnDispose = staticCompositionLocalOf<() -> Unit> {
error("No LocalBeforeScreenContentOnDispose provided!")
}

private val LocalAfterScreenContentOnDispose = staticCompositionLocalOf<() -> Unit> {
error("No LocalAfterScreenContentOnDispose provided!")
}

/**
* You can receive channel from it to inform Modo that your custom [ContainerScreen] finished transition and screen can be safely removed.
* Provides integration of [Screen] to Modo's navigation system:
* 1. Adds support of [rememberSaveable] by using [SaveableStateHolder.SaveableStateProvider] to store [Screen]'s state.
* 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.onResume] and [LifecycleDependency.onPause]
* 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
*/
val LocalTransitionCompleteChannel = staticCompositionLocalOf<Channel<Unit>> { error("no channel provided") }

@Composable
fun Screen.SaveableContent(modifier: Modifier = Modifier) {
fun Screen.SaveableContent(
modifier: Modifier = Modifier,
manualResumePause: Boolean = false
) {
LocalSaveableStateHolder.currentOrThrow.SaveableStateProvider(key = screenKey) {
ModoScreenAndroidAdapter.get(this).ProvideAndroidIntegration {
DisposableEffect(this@SaveableContent) {
// For debugging
// Log.d("SaveableContent", "put screen $screenKey")
displayingScreens[this@SaveableContent] = Unit
onDispose {
// Log.d("SaveableContent", "remove screen $screenKey")
displayingScreens -= this@SaveableContent
}
}
ModoScreenAndroidAdapter.get(this).ProvideAndroidIntegration(manualResumePause) {
BeforeScreenContent()
Content(modifier)
AfterScreenContent()
}
}
}

/**
* This function responsible for correct cleaning [Screen]'s when it already has left composition and some clean up can be made.
* It's not always clean [Screen] when it lives composition, but adds extra logic of tracking displaying [Screen]'s and triggering
* function provided by [LocalBeforeScreenContentOnDispose] in order to try clean removed screens that are not visible for user.
*/
@Composable
private inline fun Screen.BeforeScreenContent() {
val onDisposed = LocalBeforeScreenContentOnDispose.current
DisposableEffect(this) {
displayingScreensBeforeScreenContent[this@BeforeScreenContent] = Unit
onDispose {
displayingScreensBeforeScreenContent -= this@BeforeScreenContent
// Log.d("LifecycleDebug", "BeforeScreenContent $screenKey onDispose")
onDisposed.invoke()
}
}
}

@Composable
private inline fun Screen.AfterScreenContent() {
val onPreDispose = LocalAfterScreenContentOnDispose.current
DisposableEffect(this) {
displayingScreensAfterScreenContent[this@AfterScreenContent] = Unit
onDispose {
displayingScreensAfterScreenContent -= this@AfterScreenContent
// Log.d("LifecycleDebug", "AfterScreenContent $screenKey onDispose")
onPreDispose()
}
}
}
Expand Down Expand Up @@ -81,7 +128,7 @@ internal class ComposeRenderer<State : NavigationState>(
var state: State? by mutableStateOf(null, neverEqualPolicy())
private set

// TODO: share removed screen for whole structure
// TODO: share removed screen for whole structure?
private val removedScreens = mutableSetOf<Screen>()

override fun render(state: State) {
Expand All @@ -90,6 +137,9 @@ internal class ComposeRenderer<State : NavigationState>(
}
lastState = this.state
this.state = state
// Handling a case when updating state doesn't cause UI to update. But if some screens was removed, we need to move them to destroy state.
// F.e. removing previous screen causes this case.
onPreDispose()
}

@Suppress("UnusedPrivateProperty", "SpreadOperator")
Expand All @@ -101,24 +151,25 @@ internal class ComposeRenderer<State : NavigationState>(
content: RendererContent<State> = defaultRendererContent
) {
val stateHolder: SaveableStateHolder = LocalSaveableStateHolder.currentOrThrow
// For cases when content lives composition and LaunchedEffect doesn't handle event.
// You can reproduce it by creating custom dialog and pressing back button.
// Without this DisposableEffect you will not receive onDestroy events and won't clear screen model store.
DisposableEffect(Unit) {
onDispose {
// Log.d("ComposeRenderer", "DisposableEffect ${screen.screenKey}")

val beforeScreenContentOnDispose = remember {
{
clearScreens(stateHolder)
}
}
LaunchedEffect(screen.screenKey) {
for (event in transitionCompleteChannel) {
// Log.d("ComposeRenderer", "LaunchedEffect ${screen.screenKey}")
clearScreens(stateHolder)

// pre dispose means that we can send ON_DISPOSE if screen is removing,
// to let Screen.Content to handle ON_DISPOSE by using functions like DisposableEffect
val afterScreenContentOnDispose = remember {
{
onPreDispose()
}
}

CompositionLocalProvider(
LocalContainerScreen provides containerScreen,
LocalTransitionCompleteChannel provides transitionCompleteChannel,
LocalBeforeScreenContentOnDispose provides beforeScreenContentOnDispose,
LocalAfterScreenContentOnDispose provides afterScreenContentOnDispose,
*provideCompositionLocal
) {
ComposeRendererScope(lastState, state, screen).content(modifier)
Expand All @@ -131,12 +182,16 @@ internal class ComposeRenderer<State : NavigationState>(
* @param clearAll - forces to remove all screen states that renderer holds (removed and "displayed")
*/
private fun clearScreens(stateHolder: SaveableStateHolder, clearAll: Boolean = false) {
fun Iterable<Screen>.clearStates(stateHolder: SaveableStateHolder) = forEach { screen ->
screen.clearState(stateHolder)
}

if (clearAll) {
state?.getChildScreens()?.clearStates(stateHolder)
}
// There can be several transition of different screens on the screen,
// so it is important properly clear screens that are not visible for user.
val safeToRemove = removedScreens.filter { it !in displayingScreens }
val safeToRemove = removedScreens.filter { it !in displayingScreensBeforeScreenContent }
safeToRemove.clearStates(stateHolder)
if (removedScreens.isNotEmpty()) {
safeToRemove.forEach {
Expand All @@ -145,13 +200,29 @@ internal class ComposeRenderer<State : NavigationState>(
}
}

private fun Iterable<Screen>.clearStates(stateHolder: SaveableStateHolder) = forEach { screen ->
screen.clearState(stateHolder)
/**
* Called onPreDispose for removed screens, that are not presented in [displayingScreensAfterScreenContent] (not displayed on screen).
* @param clearAll - forces to call onPreDispose on all children screen states that renderer holds (removed and "displayed")
*/
private fun onPreDispose(clearAll: Boolean = false) {
fun Iterable<Screen>.onPreDispose() = forEach { screen ->
screen.onPreDispose()
}

if (clearAll) {
state?.getChildScreens()?.onPreDispose()
}
// There can be several transition of different screens on the screen,
// so it is important properly clear screens that are not visible for user.
val safeToRemove = removedScreens.filter { it !in displayingScreensAfterScreenContent }
safeToRemove.onPreDispose()
}

private fun Screen.clearState(stateHolder: SaveableStateHolder) {
if (this in displayingScreens) {
ModoDevOptions.onIllegalScreenModelStoreAccess.validationFailed(
// It's important to do this check for debug purpose, because we must guaranty that Screen is cleaned only if it is not displaying anymore.
// But it seems like it is not working with movable content, so this one is going to be triggered.
if (this in displayingScreensBeforeScreenContent) {
ModoDevOptions.onIllegalClearState.validationFailed(
IllegalStateException(
"Trying to remove clean state of the screen $this, why this screen still is visible for User."
)
Expand All @@ -163,6 +234,16 @@ internal class ComposeRenderer<State : NavigationState>(
((this as? ContainerScreen<*, *>)?.renderer as? ComposeRenderer<*>)?.clearScreens(stateHolder, clearAll = true)
}

// need for correct handling lifecycle
private fun Screen.onPreDispose() {
// Log.d("LifecycleDebug", "afterScreenContentOnDispose $screenKey")
dependenciesSortedByRemovePriority()
.filterIsInstance<LifecycleDependency>()
.forEach { it.onPreDispose() }
// send afterScreenContentOnDispose to nested screens
((this as? ContainerScreen<*, *>)?.renderer as? ComposeRenderer<*>)?.onPreDispose(clearAll = true)
}

private fun calculateRemovedScreens(oldState: NavigationState, newState: NavigationState): List<Screen> {
val newChainSet = newState.getChildScreens()
return oldState.getChildScreens().filter { it !in newChainSet }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ object ModoDevOptions {
Log.e("Modo", "Modo internal error", throwable)
}

var onIllegalClearState: ValidationFailedStrategy = ValidationFailedStrategy { throwable ->
Log.e("Modo", "Modo internal error", throwable)
}

internal const val REPORT_ISSUE_URL = "You can report issue here https://github.com/terrakok/Modo/issues"

fun interface ValidationFailedStrategy {
Expand Down
15 changes: 15 additions & 0 deletions modo-compose/src/main/java/com/github/terrakok/modo/ModoModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ package com.github.terrakok.modo

import android.os.Parcelable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn

/**
* State of navigation used in [NavigationContainer]. Can be any type.
Expand Down Expand Up @@ -38,6 +44,15 @@ interface NavigationContainer<State : NavigationState, in Action : NavigationAct

}

fun <State : NavigationState, Action : NavigationAction<State>> NavigationContainer<State, Action>.navigationStateFlow(): Flow<State> =
snapshotFlow { navigationState }

fun <State : NavigationState, Action : NavigationAction<State>> NavigationContainer<State, Action>.navigationStateStateFlow(
coroutineScope: CoroutineScope,
): StateFlow<State> =
snapshotFlow { navigationState }
.stateIn(coroutineScope, started = SharingStarted.WhileSubscribed(), initialValue = navigationState)

interface NavigationRenderer<State : NavigationState> {
fun render(state: State)
}
Loading

0 comments on commit 6dff8e7

Please sign in to comment.