diff --git a/samples/android/src/main/AndroidManifest.xml b/samples/android/src/main/AndroidManifest.xml index 96782b71..156c3bac 100644 --- a/samples/android/src/main/AndroidManifest.xml +++ b/samples/android/src/main/AndroidManifest.xml @@ -20,7 +20,8 @@ - + + diff --git a/samples/android/src/main/java/cafe/adriel/voyager/sample/SampleActivity.kt b/samples/android/src/main/java/cafe/adriel/voyager/sample/SampleActivity.kt index 8d9be558..a56999b4 100644 --- a/samples/android/src/main/java/cafe/adriel/voyager/sample/SampleActivity.kt +++ b/samples/android/src/main/java/cafe/adriel/voyager/sample/SampleActivity.kt @@ -22,6 +22,8 @@ import cafe.adriel.voyager.sample.androidLegacy.LegacyActivity import cafe.adriel.voyager.sample.androidViewModel.AndroidViewModelActivity import cafe.adriel.voyager.sample.basicNavigation.BasicNavigationActivity import cafe.adriel.voyager.sample.bottomSheetNavigation.BottomSheetNavigationActivity +import cafe.adriel.voyager.sample.disposeSample.DisposeWhenStackChangedSampleActivity +import cafe.adriel.voyager.sample.disposeSample.DisposeWhenTransitionFinishedSampleActivity import cafe.adriel.voyager.sample.hiltIntegration.HiltMainActivity import cafe.adriel.voyager.sample.kodeinIntegration.KodeinIntegrationActivity import cafe.adriel.voyager.sample.koinIntegration.KoinIntegrationActivity @@ -52,6 +54,8 @@ class SampleActivity : ComponentActivity() { contentPadding = PaddingValues(24.dp) ) { item { + StartSampleButton("DisposeWhenTransitionFinished") + StartSampleButton("DisposeWhenStackChanged") StartSampleButton("SnapshotStateStack") StartSampleButton("Basic Navigation") StartSampleButton("Basic Navigation with Parcelable") @@ -76,7 +80,9 @@ class SampleActivity : ComponentActivity() { Button( onClick = { context.startActivity(Intent(this, T::class.java)) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) ) { Text(text = text) } diff --git a/samples/android/src/main/java/cafe/adriel/voyager/sample/disposeSample/DisposeSampleActivity.kt b/samples/android/src/main/java/cafe/adriel/voyager/sample/disposeSample/DisposeSampleActivity.kt new file mode 100644 index 00000000..cce30fde --- /dev/null +++ b/samples/android/src/main/java/cafe/adriel/voyager/sample/disposeSample/DisposeSampleActivity.kt @@ -0,0 +1,39 @@ +package cafe.adriel.voyager.sample.disposeSample + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import cafe.adriel.voyager.navigator.DisposeStepsBehavior +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior +import cafe.adriel.voyager.transitions.SlideTransition + +open class DisposeSampleActivity( + private val disposeStepsBehavior: DisposeStepsBehavior +) : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + Content() + } + } + + @Composable + fun Content() { + Navigator( + screen = SampleScreen(0), + disposeBehavior = NavigatorDisposeBehavior( + disposeStepsBehavior = disposeStepsBehavior + ) + ) { + SlideTransition( + navigator = it, + animationSpec = tween(1000) + ) + } + } +} diff --git a/samples/android/src/main/java/cafe/adriel/voyager/sample/disposeSample/DisposeWhenStackChangedSampleActivity.kt b/samples/android/src/main/java/cafe/adriel/voyager/sample/disposeSample/DisposeWhenStackChangedSampleActivity.kt new file mode 100644 index 00000000..2d99ecda --- /dev/null +++ b/samples/android/src/main/java/cafe/adriel/voyager/sample/disposeSample/DisposeWhenStackChangedSampleActivity.kt @@ -0,0 +1,5 @@ +package cafe.adriel.voyager.sample.disposeSample + +import cafe.adriel.voyager.navigator.DisposeStepsBehavior + +class DisposeWhenStackChangedSampleActivity : DisposeSampleActivity(DisposeStepsBehavior.DisposeWhenStackChanged) diff --git a/samples/android/src/main/java/cafe/adriel/voyager/sample/disposeSample/DisposeWhenTransitionFinishedSampleActivity.kt b/samples/android/src/main/java/cafe/adriel/voyager/sample/disposeSample/DisposeWhenTransitionFinishedSampleActivity.kt new file mode 100644 index 00000000..e4091f5e --- /dev/null +++ b/samples/android/src/main/java/cafe/adriel/voyager/sample/disposeSample/DisposeWhenTransitionFinishedSampleActivity.kt @@ -0,0 +1,7 @@ +package cafe.adriel.voyager.sample.disposeSample + +import cafe.adriel.voyager.navigator.DisposeStepsBehavior + +class DisposeWhenTransitionFinishedSampleActivity : DisposeSampleActivity( + DisposeStepsBehavior.DisposeWhenTransitionFinished +) diff --git a/samples/android/src/main/java/cafe/adriel/voyager/sample/disposeSample/SampleScreen.kt b/samples/android/src/main/java/cafe/adriel/voyager/sample/disposeSample/SampleScreen.kt new file mode 100644 index 00000000..79c12a86 --- /dev/null +++ b/samples/android/src/main/java/cafe/adriel/voyager/sample/disposeSample/SampleScreen.kt @@ -0,0 +1,84 @@ +package cafe.adriel.voyager.sample.disposeSample + +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class SampleScreenModel : ViewModel() { + + init { + Log.d("SampleScreenModel", "start doing job") + viewModelScope.launch { + while (true) { + Log.d("SampleScreenModel", "Doing job") + delay(1000) + } + } + } + + override fun onCleared() { + Log.d("SampleScreenModel", "Disposed") + } +} + +data class SampleScreen( + private val index: Int +) : Screen { + + override val key: ScreenKey = "SampleScreen$index" + + @Composable + override fun Content() { + if (index == 1) { + viewModel(key = key) { SampleScreenModel() } + } + + val navigator = LocalNavigator.currentOrThrow + + Column( + modifier = Modifier + .fillMaxSize() + .padding(40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text(text = "Screen $index") + + Spacer(modifier = Modifier.height(20.dp)) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { navigator.push(SampleScreen(index = index + 1)) } + ) { + Text(text = "Next") + } + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { navigator.pop() } + ) { + Text(text = "Back") + } + } + } +} diff --git a/voyager-navigator/build.gradle.kts b/voyager-navigator/build.gradle.kts index 961ecd71..0ba3a960 100644 --- a/voyager-navigator/build.gradle.kts +++ b/voyager-navigator/build.gradle.kts @@ -15,6 +15,7 @@ kotlin { sourceSets { commonMain.dependencies { api(projects.voyagerCore) + implementation(libs.coroutines.core) compileOnly(compose.runtime) compileOnly(compose.runtimeSaveable) } diff --git a/voyager-navigator/src/commonMain/kotlin/cafe/adriel/voyager/navigator/Navigator.kt b/voyager-navigator/src/commonMain/kotlin/cafe/adriel/voyager/navigator/Navigator.kt index f9239a71..9d6e0399 100644 --- a/voyager-navigator/src/commonMain/kotlin/cafe/adriel/voyager/navigator/Navigator.kt +++ b/voyager-navigator/src/commonMain/kotlin/cafe/adriel/voyager/navigator/Navigator.kt @@ -23,15 +23,21 @@ import cafe.adriel.voyager.navigator.internal.ChildrenNavigationDisposableEffect import cafe.adriel.voyager.navigator.internal.LocalNavigatorStateHolder import cafe.adriel.voyager.navigator.internal.NavigatorBackHandler import cafe.adriel.voyager.navigator.internal.NavigatorDisposableEffect +import cafe.adriel.voyager.navigator.internal.StepDisposableAfterTransitionEffect import cafe.adriel.voyager.navigator.internal.StepDisposableEffect import cafe.adriel.voyager.navigator.internal.getNavigatorScreenLifecycleProvider import cafe.adriel.voyager.navigator.internal.rememberNavigator import cafe.adriel.voyager.navigator.lifecycle.NavigatorKey +import kotlinx.coroutines.channels.Channel public typealias NavigatorContent = @Composable (navigator: Navigator) -> Unit public typealias OnBackPressed = ((currentScreen: Screen) -> Boolean)? +@InternalVoyagerApi +public val LocalTransitionFinishedEvents: ProvidableCompositionLocal> = + staticCompositionLocalOf { error("No LocalTransitionFinishedEvents provided") } + public val LocalNavigator: ProvidableCompositionLocal = staticCompositionLocalOf { null } @@ -86,11 +92,20 @@ public fun Navigator( NavigatorDisposableEffect(navigator) } + val transitionFinishedEvents = remember { Channel() } + CompositionLocalProvider( - LocalNavigator provides navigator + LocalNavigator provides navigator, + LocalTransitionFinishedEvents provides transitionFinishedEvents ) { - if (disposeBehavior.disposeSteps) { - StepDisposableEffect(navigator) + when (disposeBehavior.disposeStepsBehavior) { + DisposeStepsBehavior.DisposeWhenTransitionFinished -> StepDisposableAfterTransitionEffect( + transitionFinishedEvents = transitionFinishedEvents, + navigator = navigator + ) + + DisposeStepsBehavior.DisposeWhenStackChanged -> StepDisposableEffect(navigator) + DisposeStepsBehavior.None -> Unit } NavigatorBackHandler(navigator, onBackPressed) @@ -180,10 +195,27 @@ public class Navigator @InternalVoyagerApi constructor( } } +public enum class DisposeStepsBehavior { + DisposeWhenTransitionFinished, + DisposeWhenStackChanged, + None +} + public data class NavigatorDisposeBehavior( val disposeNestedNavigators: Boolean = true, - val disposeSteps: Boolean = true -) + val disposeStepsBehavior: DisposeStepsBehavior +) { + public constructor( + disposeNestedNavigators: Boolean = true, + disposeSteps: Boolean = true + ) : this( + disposeNestedNavigators = disposeNestedNavigators, + disposeStepsBehavior = when { + disposeSteps -> DisposeStepsBehavior.DisposeWhenStackChanged + else -> DisposeStepsBehavior.None + } + ) +} @InternalVoyagerApi @Composable diff --git a/voyager-navigator/src/commonMain/kotlin/cafe/adriel/voyager/navigator/internal/NavigatorDisposable.kt b/voyager-navigator/src/commonMain/kotlin/cafe/adriel/voyager/navigator/internal/NavigatorDisposable.kt index 81408884..1421d3a0 100644 --- a/voyager-navigator/src/commonMain/kotlin/cafe/adriel/voyager/navigator/internal/NavigatorDisposable.kt +++ b/voyager-navigator/src/commonMain/kotlin/cafe/adriel/voyager/navigator/internal/NavigatorDisposable.kt @@ -2,14 +2,27 @@ package cafe.adriel.voyager.navigator.internal import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable import cafe.adriel.voyager.core.lifecycle.DisposableEffectIgnoringConfiguration +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.core.stack.StackEvent import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.lifecycle.NavigatorLifecycleStore +import kotlinx.coroutines.channels.Channel private val disposableEvents: Set = setOf(StackEvent.Pop, StackEvent.Replace) +private data class ScreenData( + val key: ScreenKey, + val screen: Screen +) + @Composable internal fun NavigatorDisposableEffect( navigator: Navigator @@ -40,6 +53,38 @@ internal fun StepDisposableEffect( } } +@Composable +internal fun StepDisposableAfterTransitionEffect( + transitionFinishedEvents: Channel, + navigator: Navigator +) { + val screenCandidatesToDispose = rememberSaveable(saver = screenCandidatesToDisposeSaver()) { + mutableStateOf(emptySet()) + } + + val currentScreens = navigator.items + + DisposableEffect(currentScreens) { + onDispose { + val newScreenKeys = navigator.items.map { it.key } + screenCandidatesToDispose.value += currentScreens.filter { it.key !in newScreenKeys } + .map { ScreenData(it.key, it) } + } + } + + LaunchedEffect(Unit) { + for (event in transitionFinishedEvents) { + val newScreens = navigator.items.map { it.key } + val screensToDispose = screenCandidatesToDispose.value.filterNot { it.key in newScreens } + if (screensToDispose.isNotEmpty()) { + screensToDispose.forEach { navigator.dispose(it.screen) } + navigator.clearEvent() + } + screenCandidatesToDispose.value = emptySet() + } + } +} + @Composable internal fun ChildrenNavigationDisposableEffect( navigator: Navigator @@ -80,3 +125,10 @@ internal fun disposeNavigator(navigator: Navigator) { NavigatorLifecycleStore.remove(navigator) navigator.clearEvent() } + +private fun screenCandidatesToDisposeSaver(): Saver>, List> { + return Saver( + save = { it.value.toList() }, + restore = { mutableStateOf(it.toSet()) } + ) +} diff --git a/voyager-transitions/src/commonMain/kotlin/cafe/adriel/voyager/transitions/ScreenTransition.kt b/voyager-transitions/src/commonMain/kotlin/cafe/adriel/voyager/transitions/ScreenTransition.kt index e0b1fb3d..ec84a6ae 100644 --- a/voyager-transitions/src/commonMain/kotlin/cafe/adriel/voyager/transitions/ScreenTransition.kt +++ b/voyager-transitions/src/commonMain/kotlin/cafe/adriel/voyager/transitions/ScreenTransition.kt @@ -6,12 +6,15 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.stack.StackEvent +import cafe.adriel.voyager.navigator.LocalTransitionFinishedEvents import cafe.adriel.voyager.navigator.Navigator @ExperimentalVoyagerApi @@ -55,6 +58,7 @@ public fun ScreenTransition( ) } +@OptIn(ExperimentalAnimationApi::class) @Composable public fun ScreenTransition( navigator: Navigator, @@ -90,6 +94,10 @@ public fun ScreenTransition( }, modifier = modifier ) { screen -> + if (this.transition.targetState == this.transition.currentState) { + val transitionFinishedEvents = LocalTransitionFinishedEvents.current + LaunchedEffect(Unit) { transitionFinishedEvents.trySend(Unit) } + } navigator.saveableState("transition", screen) { content(screen) }