From fd18ca35bac8d61edc987636ed313ee671a8e007 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Mon, 12 Aug 2024 18:09:50 +1200 Subject: [PATCH 01/51] Updated ComposableDestinationOwner to use a separate ComposableDestinationAnimations class for managing animation behaviour, based on SeekableTransitionState --- .../enro/animation/NavigationAnimations.kt | 15 ++- .../ComposableDestinationExtensions.kt | 24 +++-- .../ComposableNavigationContainer.kt | 9 +- .../ComposableDestinationAnimations.kt | 96 +++++++++++++++++++ .../destination/ComposableDestinationOwner.kt | 48 ++-------- .../AnimatedContentScope.KeepVisibleWith.kt | 35 ------- libs.versions.toml | 14 +-- 7 files changed, 133 insertions(+), 108 deletions(-) create mode 100644 enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt delete mode 100644 enro-core/src/main/java/dev/enro/extensions/AnimatedContentScope.KeepVisibleWith.kt diff --git a/enro-core/src/main/java/dev/enro/animation/NavigationAnimations.kt b/enro-core/src/main/java/dev/enro/animation/NavigationAnimations.kt index 6e15357c..40249cd6 100644 --- a/enro-core/src/main/java/dev/enro/animation/NavigationAnimations.kt +++ b/enro-core/src/main/java/dev/enro/animation/NavigationAnimations.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterExitState import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.Transition import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember @@ -15,7 +14,6 @@ import androidx.compose.ui.platform.LocalContext import dev.enro.core.AnyOpenInstruction import dev.enro.core.NavigationDirection import dev.enro.core.container.originalNavigationDirection -import dev.enro.extensions.KeepVisibleWith import dev.enro.extensions.ResourceAnimatedVisibility import dev.enro.extensions.getAttributeResourceId import dev.enro.extensions.getNestedAttributeResourceId @@ -51,9 +49,9 @@ public sealed interface NavigationAnimation { internal abstract val forView: ForView @androidx.compose.runtime.Composable - public abstract fun Animate( + internal abstract fun Animate( visible: Transition, - content: @androidx.compose.runtime.Composable () -> Unit + content: @androidx.compose.runtime.Composable (Transition) -> Unit, ) public companion object { @@ -84,9 +82,11 @@ public sealed interface NavigationAnimation { val exit: ExitTransition = ExitTransition.None, override val forView: ForView = DefaultAnimations.ForView.noneEnter, ) : Composable(), Enter, Exit { - @OptIn(ExperimentalAnimationApi::class) @androidx.compose.runtime.Composable - override fun Animate(visible: Transition, content: @androidx.compose.runtime.Composable () -> Unit) { + override fun Animate( + visible: Transition, + content: @androidx.compose.runtime.Composable (Transition) -> Unit, + ) { val context = LocalContext.current val resourceAnimation = remember(this, forView) { forView.asResource(context.theme) } visible.AnimatedVisibility( @@ -99,9 +99,8 @@ public sealed interface NavigationAnimation { enter = resourceAnimation.id, exit = resourceAnimation.id, ) { - content() + content(transition) } - KeepVisibleWith(visible) } } } diff --git a/enro-core/src/main/java/dev/enro/destination/compose/ComposableDestinationExtensions.kt b/enro-core/src/main/java/dev/enro/destination/compose/ComposableDestinationExtensions.kt index 78be2006..ad508938 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/ComposableDestinationExtensions.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/ComposableDestinationExtensions.kt @@ -2,10 +2,10 @@ package dev.enro.core.compose import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.Transition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeOut @@ -15,24 +15,22 @@ import androidx.compose.ui.platform.LocalInspectionMode import dev.enro.animation.NavigationAnimation import dev.enro.annotations.AdvancedEnroApi import dev.enro.core.navigationContext +import dev.enro.destination.compose.destination.AnimationEvent /** * Causes the ComposableDestination's transition to immediately finish */ @AdvancedEnroApi public fun ComposableDestination.finishTransition() { - val transition = owner.transitionState - if (!transition.isIdle) { - owner.transitionState = MutableTransitionState(transition.targetState) - } + owner.animations.setAnimationEvent(AnimationEvent.SnapTo(false)) } /** * Gets the transition for the ComposableDestination */ @AdvancedEnroApi -public fun ComposableDestination.getTransition() : Transition { - return owner.transition +public fun ComposableDestination.getTransition(): Transition { + return owner.animations.enterExitTransition } /** @@ -40,7 +38,7 @@ public fun ComposableDestination.getTransition() : Transition { * and will otherwise throw an exception. */ @AdvancedEnroApi -public val navigationTransition: Transition +public val navigationTransition: Transition @Composable get() { val destination = navigationContext.contextReference as ComposableDestination @@ -68,11 +66,11 @@ public fun OverrideNavigationAnimations( val navigationContext = navigationContext val destination = navigationContext.contextReference as ComposableDestination DisposableEffect(enter, exit) { - destination.owner.animationOverride = NavigationAnimation.Composable( + destination.owner.animations.animationOverride = NavigationAnimation.Composable( enter = enter, exit = exit, ) - onDispose { } + onDispose { } } } @@ -100,7 +98,7 @@ public fun OverrideNavigationAnimations( val navigationContext = navigationContext val destination = navigationContext.contextReference as ComposableDestination DisposableEffect(Unit) { - destination.owner.animationOverride = NavigationAnimation.Composable( + destination.owner.animations.animationOverride = NavigationAnimation.Composable( enter = EnterTransition.None, // We need a little fade out here to keep the animation active while the animated visibility below has a chance to run // and attach child transitions. This is a bit of a hack, but it's the only way to ensure that child exit transitions @@ -110,10 +108,10 @@ public fun OverrideNavigationAnimations( animationSpec = tween(512), ), ) - onDispose { } + onDispose { } } navigationTransition.AnimatedVisibility( - visible = {it}, + visible = { it == EnterExitState.Visible }, enter = enter, exit = exit, ) { diff --git a/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt b/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt index 5ef406d4..a7105c07 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt @@ -42,6 +42,7 @@ import dev.enro.core.container.NavigationInstructionFilter import dev.enro.core.container.merge import dev.enro.core.controller.get import dev.enro.core.controller.interceptor.builder.NavigationInterceptorBuilder +import dev.enro.destination.compose.destination.AnimationEvent import java.io.Closeable import kotlin.collections.set @@ -71,7 +72,7 @@ public class ComposableNavigationContainer internal constructor( get() = true public val isAnimating: Boolean by derivedStateOf { - destinationOwners.any { !it.transitionState.isIdle } + destinationOwners.any { it.animations.isAnimating } } private val onDestroyLifecycleObserver = LifecycleEventObserver { _, event -> @@ -198,7 +199,7 @@ public class ComposableNavigationContainer internal constructor( destinationOwners.forEach { if (activeDestinations[it.instruction] == null) { - it.transitionState.targetState = false + it.animations.setAnimationEvent(AnimationEvent.AnimateTo(false)) } } destinationOwners = merge(transition.previousBackstack, transition.activeBackstack) @@ -251,11 +252,13 @@ public class ComposableNavigationContainer internal constructor( val isPushedDialogOrBottomSheet = ((destinationOwner.destination is DialogDestination || destinationOwner.destination is BottomSheetDestination) && activePresented != null) - destinationOwner.transitionState.targetState = when (instruction) { + + val target = when (instruction) { activePresented -> !isParentBeingRemoved activePush -> !isParentBeingRemoved && !isPushedDialogOrBottomSheet else -> false } + destinationOwner.animations.setAnimationEvent(AnimationEvent.AnimateTo(target)) } } diff --git a/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt b/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt new file mode 100644 index 00000000..3ea2c0d6 --- /dev/null +++ b/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt @@ -0,0 +1,96 @@ +package dev.enro.destination.compose.destination + +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.rememberTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.enro.animation.NavigationAnimation +import dev.enro.core.compose.destination.ComposableDestinationOwner +import dev.enro.core.compose.dialog.BottomSheetDestination +import dev.enro.core.compose.dialog.DialogDestination +import dev.enro.core.container.getAnimationsForEntering +import dev.enro.core.container.getAnimationsForExiting + +internal sealed class AnimationEvent { + data class AnimateTo(val visible: Boolean) : AnimationEvent() + data class SnapTo(val visible: Boolean) : AnimationEvent() + data class Seek(val progress: Float, val visible: Boolean) : AnimationEvent() +} + +internal class ComposableDestinationAnimations( + private val owner: ComposableDestinationOwner, +) { + private var currentAnimationEvent by mutableStateOf(AnimationEvent.SnapTo(false)) + private val visibilityState = SeekableTransitionState(false) + + internal var animationOverride by mutableStateOf(null) + + internal lateinit var enterExitTransition: Transition + + val isAnimating by derivedStateOf { + when(currentAnimationEvent) { + is AnimationEvent.AnimateTo -> visibilityState.targetState != visibilityState.currentState + is AnimationEvent.SnapTo -> false + is AnimationEvent.Seek -> true + } + } + + internal fun setAnimationEvent(event: AnimationEvent) { + currentAnimationEvent = event + } + + @OptIn(ExperimentalMaterialApi::class) + @Composable + fun Animate(content: @Composable () -> Unit) { + val targetState = visibilityState.targetState + val instruction = owner.instruction + val parentContainer = owner.parentContainer + + val animation = remember( + instruction, + targetState, + parentContainer, + animationOverride + ) { + animationOverride ?: when (owner.destination) { + is DialogDestination -> NavigationAnimation.Composable(EnterTransition.None, ExitTransition.None) + is BottomSheetDestination -> NavigationAnimation.Composable( + enter = EnterTransition.None, + exit = fadeOut(tween(75, 150)), + ) + else -> when { + visibilityState.targetState >= visibilityState.currentState -> parentContainer.getAnimationsForEntering(instruction).asComposable() + else -> parentContainer.getAnimationsForExiting(instruction).asComposable() + } + } + } + + LaunchedEffect(currentAnimationEvent) { + val event = currentAnimationEvent + when(event) { + is AnimationEvent.AnimateTo -> visibilityState.animateTo(event.visible) + is AnimationEvent.SnapTo -> visibilityState.snapTo(event.visible) + is AnimationEvent.Seek -> visibilityState.seekTo(event.progress, event.visible) + } + } + + animation.Animate( + visible = rememberTransition(visibilityState, "ComposableDestination Visibility"), + ) { + enterExitTransition = it + content() + } + } +} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationOwner.kt b/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationOwner.kt index 2add9e3a..6a62bc9a 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationOwner.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationOwner.kt @@ -3,24 +3,14 @@ package dev.enro.core.compose.destination import android.annotation.SuppressLint import android.os.Bundle import androidx.activity.ComponentActivity -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.Transition -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.ReusableContent -import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLifecycleOwner @@ -39,7 +29,6 @@ import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryOwner -import dev.enro.animation.NavigationAnimation import dev.enro.core.AnyOpenInstruction import dev.enro.core.activity import dev.enro.core.compose.ComposableDestination @@ -49,12 +38,11 @@ import dev.enro.core.compose.dialog.DialogDestination import dev.enro.core.compose.dialog.EnroBottomSheetContainer import dev.enro.core.compose.dialog.EnroDialogContainer import dev.enro.core.container.NavigationContainer -import dev.enro.core.container.getAnimationsForEntering -import dev.enro.core.container.getAnimationsForExiting import dev.enro.core.controller.usecase.ComposeEnvironment import dev.enro.core.controller.usecase.OnNavigationContextCreated import dev.enro.core.controller.usecase.OnNavigationContextSaved import dev.enro.core.getNavigationHandle +import dev.enro.destination.compose.destination.ComposableDestinationAnimations import dev.enro.extensions.rememberLifecycleState import java.lang.ref.WeakReference @@ -73,27 +61,25 @@ internal class ComposableDestinationOwner( SavedStateRegistryOwner, HasDefaultViewModelProviderFactory { - internal var transitionState by mutableStateOf(MutableTransitionState(false)) - internal lateinit var transition: Transition - internal var animationOverride by mutableStateOf(null) private var _parentContainer: NavigationContainer? = parentContainer private var weakParentContainerReference: WeakReference = WeakReference(parentContainer) internal val parentContainer get() = weakParentContainerReference.get() ?: _parentContainer!! @SuppressLint("StaticFieldLeak") - @Suppress("LeakingThis") private val lifecycleRegistry = LifecycleRegistry(this) - @Suppress("LeakingThis") private val savedStateRegistryOwner = ComposableDestinationSavedStateRegistryOwner(this, savedInstanceState) - @Suppress("LeakingThis") private val viewModelStoreOwner = ComposableDestinationViewModelStoreOwner( owner = this, savedState = savedStateRegistryOwner.savedState, viewModelStore = viewModelStore ) + internal val animations = ComposableDestinationAnimations( + owner = this, + ) + override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryOwner.savedStateRegistry @@ -145,7 +131,6 @@ internal class ComposableDestinationOwner( _parentContainer = null } - @OptIn(ExperimentalMaterialApi::class) @Composable internal fun Render( backstackState: List, @@ -163,30 +148,9 @@ internal class ComposableDestinationOwner( } } } - - val animation = remember(instruction.instructionId, transitionState.targetState, parentContainer, animationOverride) { - animationOverride ?: when (destination) { - is DialogDestination -> NavigationAnimation.Composable(EnterTransition.None, ExitTransition.None) - is BottomSheetDestination -> NavigationAnimation.Composable( - enter = EnterTransition.None, - exit = fadeOut(tween(75, 150)), - ) - else -> when (transitionState.targetState) { - true -> parentContainer.getAnimationsForEntering(instruction).asComposable() - else -> parentContainer.getAnimationsForExiting(instruction).asComposable() - } - } - } - transition = updateTransition(transitionState, "ComposableDestination Visibility") - - if (!lifecycleState.isAtLeast(Lifecycle.State.STARTED) - && !transition.currentState - && !transition.targetState - ) return - ReusableContent(instruction.instructionId) { ProvideRenderingWindow(backstackState) { - animation.Animate(transition) { + animations.Animate { renderDestination() RegisterComposableLifecycleState(backstackState, parentContainer) } diff --git a/enro-core/src/main/java/dev/enro/extensions/AnimatedContentScope.KeepVisibleWith.kt b/enro-core/src/main/java/dev/enro/extensions/AnimatedContentScope.KeepVisibleWith.kt deleted file mode 100644 index 931f9deb..00000000 --- a/enro-core/src/main/java/dev/enro/extensions/AnimatedContentScope.KeepVisibleWith.kt +++ /dev/null @@ -1,35 +0,0 @@ -package dev.enro.extensions - -import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.Transition -import androidx.compose.animation.core.animateInt -import androidx.compose.animation.core.tween -import androidx.compose.runtime.Composable -import androidx.compose.runtime.key - -/** - * By default, an AnimatedVisibility composable created with Transition.AnimatedVisibility will stop rendering child content - * even while the parent transition is still active. In the case of dialogs, this can cause a Dialog that's doing a custom - * animation from inside of an AnimatedVisibility to exit before the animation is completed. - * - * This method allows an AnimatedVisibilityScope to bind itself to some other transition, and remain active (and rendering child - * content) while the other transition is running. - */ -@OptIn(ExperimentalAnimationApi::class) -@Composable -internal fun AnimatedVisibilityScope.KeepVisibleWith( - otherTransition: Transition -) { - key(otherTransition.currentState, otherTransition.targetState, otherTransition.hashCode()) { - if(otherTransition.totalDurationNanos > 0) { - transition.animateInt( - transitionSpec = { - tween((otherTransition.totalDurationNanos / 1000000).toInt()) - }, - label = "bindVisibilityToTransition", - targetValueByState = { it.hashCode() } - ).value - } - } -} \ No newline at end of file diff --git a/libs.versions.toml b/libs.versions.toml index d7f4f084..712ed7a5 100644 --- a/libs.versions.toml +++ b/libs.versions.toml @@ -23,13 +23,13 @@ androidx-lifecycle-process = "androidx.lifecycle:lifecycle-process:2.8.4" compose-viewmodel = "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4" compose-compiler = "androidx.compose.compiler:compiler:1.5.15" -compose-foundation = "androidx.compose.foundation:foundation:1.6.8" -compose-foundationLayout = "androidx.compose.foundation:foundation-layout:1.6.8" -compose-ui = "androidx.compose.ui:ui:1.6.8" -compose-uiTooling = "androidx.compose.ui:ui-tooling:1.6.8" -compose-runtime = "androidx.compose.runtime:runtime:1.6.8" -compose-livedata = "androidx.compose.runtime:runtime-livedata:1.6.8" -compose-material = "androidx.compose.material:material:1.6.8" +compose-foundation = "androidx.compose.foundation:foundation:1.7.0-beta07" +compose-foundationLayout = "androidx.compose.foundation:foundation-layout:1.7.0-beta07" +compose-ui = "androidx.compose.ui:ui:1.7.0-beta07" +compose-uiTooling = "androidx.compose.ui:ui-tooling:1.7.0-beta07" +compose-runtime = "androidx.compose.runtime:runtime:1.7.0-beta07" +compose-livedata = "androidx.compose.runtime:runtime-livedata:1.7.0-beta07" +compose-material = "androidx.compose.material:material:1.7.0-beta07" compose-accompanist-systemUiController = "com.google.accompanist:accompanist-systemuicontroller:0.27.0" From cf2236adf679f3aaeaad8630a93817c075fe59ee Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Mon, 12 Aug 2024 18:52:36 +1200 Subject: [PATCH 02/51] Added a playground to the test application for compose animations --- .../application/compose/ComposeAnimations.kt | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 tests/application/src/main/java/dev/enro/tests/application/compose/ComposeAnimations.kt diff --git a/tests/application/src/main/java/dev/enro/tests/application/compose/ComposeAnimations.kt b/tests/application/src/main/java/dev/enro/tests/application/compose/ComposeAnimations.kt new file mode 100644 index 00000000..a820d95c --- /dev/null +++ b/tests/application/src/main/java/dev/enro/tests/application/compose/ComposeAnimations.kt @@ -0,0 +1,206 @@ +package dev.enro.tests.application.compose + +import android.view.Window +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogWindowProvider +import dev.enro.animation.direction +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.annotations.NavigationDestination +import dev.enro.core.NavigationDirection +import dev.enro.core.NavigationKey +import dev.enro.core.compose.OverrideNavigationAnimations +import dev.enro.core.compose.dialog.DialogDestination +import dev.enro.core.compose.navigationHandle +import dev.enro.core.compose.navigationTransition +import dev.enro.core.compose.rememberNavigationContainer +import dev.enro.core.container.EmptyBehavior +import dev.enro.core.container.accept +import dev.enro.core.present +import dev.enro.core.push +import dev.enro.core.requestClose +import dev.enro.tests.application.compose.common.TitledColumn +import kotlinx.parcelize.Parcelize + +@Parcelize +object ComposeAnimations : NavigationKey.SupportsPush { + @Parcelize + internal data object Root : NavigationKey.SupportsPush + + @Parcelize + internal data object PushWithSlide : NavigationKey.SupportsPush + + @Parcelize + internal data object PushWithAnimatedSquare : NavigationKey.SupportsPush + + @Parcelize + internal data object Dialog : NavigationKey.SupportsPresent +} + +private fun defaultSpec() = tween(1500) +private fun defaultSpecDelay() = tween(16, 1500) + +@NavigationDestination(ComposeAnimations::class) +@Composable +fun ComposeAnimationsDestination() { + val container = rememberNavigationContainer( + emptyBehavior = EmptyBehavior.CloseParent, + root = ComposeAnimations.Root, + filter = accept { + key { it::class.java.enclosingClass == ComposeAnimations::class.java } + }, + animations = { + direction( + direction = NavigationDirection.Push, + entering = fadeIn(defaultSpec()), + exiting = fadeOut(defaultSpec()), + returnEntering = fadeIn(defaultSpec()), + returnExiting = fadeOut(defaultSpec()), + ) + } + ) + Box( + modifier = Modifier.fillMaxSize(), + ) { + container.Render() + } +} + +@NavigationDestination(ComposeAnimations.Root::class) +@Composable +fun ComposeAnimationsRoot() { + val navigation = navigationHandle() + TitledColumn( + title = "Compose Animations" + ) { + Button(onClick = { + navigation.push(ComposeAnimations.PushWithSlide) + }) { + Text(text = "Push (with slide)") + } + + Button(onClick = { + navigation.push(ComposeAnimations.PushWithAnimatedSquare) + }) { + Text(text = "Push (with animated square)") + } + + + Button(onClick = { + navigation.present(ComposeAnimations.Dialog) + }) { + Text(text = "Dialog") + } + } +} + + +@OptIn(AdvancedEnroApi::class) +@NavigationDestination(ComposeAnimations.PushWithSlide::class) +@Composable +fun PushWithSlideDestination() { + val navigationHandle = navigationHandle() + navigationTransition.AnimatedVisibility( + visible = { it == EnterExitState.Visible }, + enter = slideInVertically(defaultSpec()) { it / 2 }, + exit = slideOutVertically(defaultSpec()) { it / 2 }, + ) { + TitledColumn( + title = "Push (with slide)", + ) { + Button(onClick = { + navigationHandle.requestClose() + }) { + Text(text = "Close") + } + } + } +} + +@OptIn(AdvancedEnroApi::class) +@NavigationDestination(ComposeAnimations.PushWithAnimatedSquare::class) +@Composable +fun PushWithAnimatedSquareDestination() { + val navigationHandle = navigationHandle() + OverrideNavigationAnimations( + enter = fadeIn(), + exit = fadeOut(defaultSpecDelay()), + ) + + TitledColumn( + title = "Push (with animated square)", + ) { + Button(onClick = { + navigationHandle.requestClose() + }) { + Text(text = "Close") + } + + val size = navigationTransition.animateDp( + label = "", + transitionSpec = { defaultSpec() } + ) { + if (it == EnterExitState.Visible) 100.dp else 0.dp + } + + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(size.value) + .background(Color.Red), + ) + } +} + + +@OptIn(AdvancedEnroApi::class) +@NavigationDestination(ComposeAnimations.Dialog::class) +@Composable +fun ComposeAnimationsDialogDestination() = DialogDestination { + val navigationHandle = navigationHandle() + Dialog(onDismissRequest = { navigationHandle.requestClose() }) { + navigationTransition.AnimatedVisibility( + visible = { it == EnterExitState.Visible }, + enter = fadeIn(defaultSpec()) + slideInVertically(defaultSpec()) { it / 2 }, + exit = fadeOut(defaultSpec()) + slideOutVertically(defaultSpec()) { it / 2 }, + ) { + TitledColumn( + title = "Dialog", + modifier = Modifier.heightIn(max = 256.dp) + ) { + Button(onClick = { + navigationHandle.requestClose() + }) { + Text(text = "Close") + } + } + } + } +} + +@ReadOnlyComposable +@Composable +fun getDialogWindow(): Window { + return requireNotNull((LocalView.current.parent as? DialogWindowProvider)?.window) +} From 1f6d0f8d7898253e29fb36d1d277b552058252f8 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Mon, 12 Aug 2024 23:29:40 +1200 Subject: [PATCH 03/51] Fix issue with givenContainerGroupsWithNestedContainers_whenActiveContainerIsChanged_thenStabilitySnapshotIsStableForNestedKeys test --- .../ComposableDestinationAnimations.kt | 19 +- .../application/src/main/AndroidManifest.xml | 1 + .../application/compose/ComposeStability.kt | 165 ++++++++++++++++++ 3 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 tests/application/src/main/java/dev/enro/tests/application/compose/ComposeStability.kt diff --git a/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt b/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt index 3ea2c0d6..6c1c97e0 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt @@ -40,7 +40,7 @@ internal class ComposableDestinationAnimations( internal lateinit var enterExitTransition: Transition val isAnimating by derivedStateOf { - when(currentAnimationEvent) { + when (currentAnimationEvent) { is AnimationEvent.AnimateTo -> visibilityState.targetState != visibilityState.currentState is AnimationEvent.SnapTo -> false is AnimationEvent.Seek -> true @@ -70,8 +70,12 @@ internal class ComposableDestinationAnimations( enter = EnterTransition.None, exit = fadeOut(tween(75, 150)), ) + else -> when { - visibilityState.targetState >= visibilityState.currentState -> parentContainer.getAnimationsForEntering(instruction).asComposable() + visibilityState.targetState >= visibilityState.currentState -> parentContainer.getAnimationsForEntering( + instruction + ).asComposable() + else -> parentContainer.getAnimationsForExiting(instruction).asComposable() } } @@ -79,11 +83,14 @@ internal class ComposableDestinationAnimations( LaunchedEffect(currentAnimationEvent) { val event = currentAnimationEvent - when(event) { - is AnimationEvent.AnimateTo -> visibilityState.animateTo(event.visible) - is AnimationEvent.SnapTo -> visibilityState.snapTo(event.visible) - is AnimationEvent.Seek -> visibilityState.seekTo(event.progress, event.visible) + runCatching { + when (event) { + is AnimationEvent.AnimateTo -> visibilityState.animateTo(event.visible) + is AnimationEvent.SnapTo -> visibilityState.snapTo(event.visible) + is AnimationEvent.Seek -> visibilityState.seekTo(event.progress, event.visible) + } } + currentAnimationEvent = AnimationEvent.SnapTo(visibilityState.targetState) } animation.Animate( diff --git a/tests/application/src/main/AndroidManifest.xml b/tests/application/src/main/AndroidManifest.xml index f3a59cd8..2eeb3da6 100644 --- a/tests/application/src/main/AndroidManifest.xml +++ b/tests/application/src/main/AndroidManifest.xml @@ -25,6 +25,7 @@ + \ No newline at end of file diff --git a/tests/application/src/main/java/dev/enro/tests/application/compose/ComposeStability.kt b/tests/application/src/main/java/dev/enro/tests/application/compose/ComposeStability.kt new file mode 100644 index 00000000..2301fa94 --- /dev/null +++ b/tests/application/src/main/java/dev/enro/tests/application/compose/ComposeStability.kt @@ -0,0 +1,165 @@ +package dev.enro.tests.application.compose + +import android.annotation.SuppressLint +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import dev.enro.annotations.NavigationDestination +import dev.enro.core.NavigationContainerKey +import dev.enro.core.NavigationKey +import dev.enro.core.compose.container.rememberNavigationContainerGroup +import dev.enro.core.compose.navigationHandle +import dev.enro.core.compose.rememberNavigationContainer +import dev.enro.core.container.EmptyBehavior +import dev.enro.core.container.push +import dev.enro.core.container.setBackstack +import dev.enro.core.onContainer +import dev.enro.core.push +import dev.enro.tests.application.compose.common.TitledColumn +import kotlinx.coroutines.isActive +import kotlinx.parcelize.Parcelize +import java.util.UUID + +@Parcelize +object ComposeStability : NavigationKey.SupportsPush { + + internal val primaryContainer = NavigationContainerKey.FromName("primaryContainer") + internal val secondaryContainer = NavigationContainerKey.FromName("secondaryContainer") + internal val tertiaryContainer = NavigationContainerKey.FromName("tertiaryContainer") + + @Parcelize + internal data class Content( + val id: String = UUID.randomUUID().toString() + ) : NavigationKey.SupportsPush { + val childContainerKey get() = NavigationContainerKey.FromName(id) + val testTag get() = "ComposeStabilityContent@$id" + } + +} + +@NavigationDestination(ComposeStability::class) +class ComposeStabilityActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val navigation = navigationHandle() + val containerGroup = rememberNavigationContainerGroup( + rememberNavigationContainer(ComposeStability.primaryContainer, emptyBehavior = EmptyBehavior.AllowEmpty), + rememberNavigationContainer(ComposeStability.secondaryContainer, emptyBehavior = EmptyBehavior.AllowEmpty), + rememberNavigationContainer(ComposeStability.tertiaryContainer, emptyBehavior = EmptyBehavior.AllowEmpty), + ) + TitledColumn("Compose Stability") { + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button( + onClick = { navigation.onContainer(ComposeStability.primaryContainer) { setActive() } } + ) { Text("One") } + + Button( + onClick = { navigation.onContainer(ComposeStability.secondaryContainer) { setActive() } } + ) { Text("Two") } + + Button( + onClick = { navigation.onContainer(ComposeStability.tertiaryContainer) { setActive() } } + ) { Text("Three") } + } + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button( + onClick = { containerGroup.activeContainer.setBackstack { it.push(ComposeStability.Content()) } } + ) { Text("Push Root") } + + Button( + onClick = { + val childContext = containerGroup.activeContainer.childContext ?: return@Button + childContext.navigationHandle.push(ComposeStability.Content()) + } + ) { Text("Push Child") } + } + Spacer(modifier = Modifier.height(8.dp)) + Text("Active: ${containerGroup.activeContainer.key.name}") + Spacer(modifier = Modifier.height(8.dp)) + Box { + containerGroup.activeContainer.Render() + } + } + } + } +} + +class ComposeStabilityContentViewModel( + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + val id: String = UUID.randomUUID().toString() + val saveStateHandleId = savedStateHandle.getStateFlow("savedStateId", UUID.randomUUID().toString()) +} + +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +@NavigationDestination(ComposeStability.Content::class) +fun ComposeStabilityContentScreen() { + val rawNavigationHandle = navigationHandle() + val typedNavigationHandle = navigationHandle() + val rememberSaveable = rememberSaveable { UUID.randomUUID().toString() } + val viewModel = viewModel() + val viewModelStore = LocalViewModelStoreOwner.current?.viewModelStore + + val stabilityContent = buildString { + appendLine("navigationId: ${rawNavigationHandle.id}") + appendLine("navigationKeyId: ${typedNavigationHandle.key.id}") + appendLine("navigationHashCode: ${rawNavigationHandle.hashCode()}") + appendLine("viewModelId: ${viewModel.id}") + appendLine("viewModelHashCode: ${viewModel.hashCode()}") + appendLine("viewModelSavedStateId: ${viewModel.saveStateHandleId.value}") + appendLine("viewModelStoreHashCode: ${viewModelStore.hashCode()}") + appendLine("viewModelScopeActive: ${viewModel.viewModelScope.isActive}") + appendLine("rememberSaveableId: $rememberSaveable") + } + + val childContainer = rememberNavigationContainer( + key = typedNavigationHandle.key.childContainerKey, + emptyBehavior = EmptyBehavior.AllowEmpty, + ) + Column( + modifier = Modifier + .padding(8.dp) + .background(Color.Black.copy(alpha = 0.05f)) + .padding(8.dp) + ) { + Text( + text = stabilityContent, + fontSize = 11.sp, + lineHeight = 13.sp, + modifier = Modifier.semantics { + testTag = typedNavigationHandle.key.testTag + } + ) + childContainer.Render() + } +} \ No newline at end of file From 1d1b071a18a9e15dfc9d0339f720c4c7fa741624 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Tue, 13 Aug 2024 00:02:35 +1200 Subject: [PATCH 04/51] Fixed remaining failing tests (ComposableDestinationContainerGroup tests) --- .../ComposableDestinationAnimations.kt | 14 +++++++++---- .../ComposableDestinationContainerGroups.kt | 9 ++++++++- .../application/compose/ComposeStability.kt | 20 ++++++++++++++++--- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt b/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt index 6c1c97e0..7c7588c7 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt @@ -11,6 +11,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.NonSkippableComposable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -24,9 +25,11 @@ import dev.enro.core.container.getAnimationsForEntering import dev.enro.core.container.getAnimationsForExiting internal sealed class AnimationEvent { - data class AnimateTo(val visible: Boolean) : AnimationEvent() - data class SnapTo(val visible: Boolean) : AnimationEvent() - data class Seek(val progress: Float, val visible: Boolean) : AnimationEvent() + abstract val visible: Boolean + + data class AnimateTo(override val visible: Boolean) : AnimationEvent() + data class SnapTo(override val visible: Boolean) : AnimationEvent() + data class Seek(val progress: Float, override val visible: Boolean) : AnimationEvent() } internal class ComposableDestinationAnimations( @@ -53,6 +56,7 @@ internal class ComposableDestinationAnimations( @OptIn(ExperimentalMaterialApi::class) @Composable + @NonSkippableComposable fun Animate(content: @Composable () -> Unit) { val targetState = visibilityState.targetState val instruction = owner.instruction @@ -90,7 +94,9 @@ internal class ComposableDestinationAnimations( is AnimationEvent.Seek -> visibilityState.seekTo(event.progress, event.visible) } } - currentAnimationEvent = AnimationEvent.SnapTo(visibilityState.targetState) + if (currentAnimationEvent == event) { + currentAnimationEvent = AnimationEvent.SnapTo(event.visible) + } } animation.Animate( diff --git a/enro/src/androidTest/java/dev/enro/core/compose/ComposableDestinationContainerGroups.kt b/enro/src/androidTest/java/dev/enro/core/compose/ComposableDestinationContainerGroups.kt index ef3b8447..39248664 100644 --- a/enro/src/androidTest/java/dev/enro/core/compose/ComposableDestinationContainerGroups.kt +++ b/enro/src/androidTest/java/dev/enro/core/compose/ComposableDestinationContainerGroups.kt @@ -5,7 +5,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.* +import androidx.compose.material.BottomNavigation +import androidx.compose.material.BottomNavigationItem +import androidx.compose.material.Icon +import androidx.compose.material.Scaffold +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home import androidx.compose.runtime.Composable @@ -44,6 +48,7 @@ class ComposableDestinationContainerGroups { @Test fun whenComposableDestinationIsLaunchedWithContainerGroup_thenContainerGroupsAreSelectable() { val root = launchComposable(Destinations.RootDestination) + runBlocking { composeContentRule.awaitIdle() } expectComposableContext() composeContentRule.onNodeWithText("First Tab Screen").assertExists() composeContentRule.onNodeWithText("Second Tab Screen").assertDoesNotExist() @@ -80,6 +85,7 @@ class ComposableDestinationContainerGroups { @Test fun whenComposableDestinationIsLaunchedWithContainerGroup_andBackButtonIsPressed_thenContainerEmptyBehaviorIsRespected() { val root = launchComposable(Destinations.RootDestination) + runBlocking { composeContentRule.awaitIdle() } expectComposableContext() composeContentRule.onNodeWithText("First Tab Screen").assertExists() composeContentRule.onNodeWithText("Second Tab Screen").assertDoesNotExist() @@ -106,6 +112,7 @@ class ComposableDestinationContainerGroups { @Test fun whenComposableDestinationIsLaunchedWithContainerGroup_andSecondaryContainerSelected_andActivityIsRecreated_thenActiveContainerRemainsActive() { val root = launchComposable(Destinations.RootDestination) + runBlocking { composeContentRule.awaitIdle() } expectComposableContext() composeContentRule.onNodeWithText("First Tab Screen").assertExists() composeContentRule.onNodeWithText("Second Tab Screen").assertDoesNotExist() diff --git a/tests/application/src/main/java/dev/enro/tests/application/compose/ComposeStability.kt b/tests/application/src/main/java/dev/enro/tests/application/compose/ComposeStability.kt index 2301fa94..adf180df 100644 --- a/tests/application/src/main/java/dev/enro/tests/application/compose/ComposeStability.kt +++ b/tests/application/src/main/java/dev/enro/tests/application/compose/ComposeStability.kt @@ -43,6 +43,10 @@ import kotlinx.coroutines.isActive import kotlinx.parcelize.Parcelize import java.util.UUID +// TODO - this could have additional tests added to match those in ComposableDestinationContainerGroups.kt and/or ComposableContainerStabilityTests.kt +// generally, some tests that flick quickly between the containers and ensure that they are stable and are rendered correctly. Due to those other +// tests in the core enro suite, it's not a high priority to add these (as those will catch bugs), but it is easier to debug tests which are +// written against the test application (as compared to the core suite). @Parcelize object ComposeStability : NavigationKey.SupportsPush { @@ -68,9 +72,19 @@ class ComposeStabilityActivity : AppCompatActivity() { setContent { val navigation = navigationHandle() val containerGroup = rememberNavigationContainerGroup( - rememberNavigationContainer(ComposeStability.primaryContainer, emptyBehavior = EmptyBehavior.AllowEmpty), - rememberNavigationContainer(ComposeStability.secondaryContainer, emptyBehavior = EmptyBehavior.AllowEmpty), - rememberNavigationContainer(ComposeStability.tertiaryContainer, emptyBehavior = EmptyBehavior.AllowEmpty), + rememberNavigationContainer( + key= ComposeStability.primaryContainer, + emptyBehavior = EmptyBehavior.AllowEmpty, + root = ComposeStability.Content(), + ), + rememberNavigationContainer( + key = ComposeStability.secondaryContainer, + emptyBehavior = EmptyBehavior.AllowEmpty + ), + rememberNavigationContainer( + key = ComposeStability.tertiaryContainer, + emptyBehavior = EmptyBehavior.AllowEmpty + ), ) TitledColumn("Compose Stability") { Row( From 43783387ce56da04e149f064be007cc56570a934 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Tue, 13 Aug 2024 00:11:50 +1200 Subject: [PATCH 05/51] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c6188be..c8e114b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog ## 2.8.0 +* Updated Compose to 1.7.0 * Added support for NavigationKey.WithExtras to `NavigationResultChannel` and `NavigationFlowScope` * Updated `enro-test` methods to provide more descriptive error messages when assert/expect methods fail, and added kdoc comments to many of the functions +* Updated Composable navigation animations to use SeekableTransitionState, as a step towards supporting predictive back navigation animations ## 2.7.0 * ⚠️ Updated to androidx.lifecycle 2.8.1 ⚠️ From 1aa014a1f6d7c96f576812519302dcb1d204543a Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Mon, 12 Aug 2024 12:39:03 +0000 Subject: [PATCH 06/51] Released 2.8.0-alpha01 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 74e17ed8..7985c90d 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -versionName=2.7.0 -versionCode=102 \ No newline at end of file +versionName=2.8.0-alpha01 +versionCode=103 \ No newline at end of file From b061682e01f4a808428cf194c79032552a45cc76 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Wed, 21 Aug 2024 08:32:43 +1200 Subject: [PATCH 07/51] Added shared element support for Fragments and Composables which are hosted in Fragment NavigationContainers --- .../fragment/FragmentSharedElements.kt | 219 ++++++++++++++ .../container/FragmentNavigationContainer.kt | 7 + enro-core/src/main/res/values/id.xml | 1 + .../application/src/main/AndroidManifest.xml | 1 + .../enro/tests/application/TestApplication.kt | 8 + .../fragment/FragmentWithSharedElements.kt | 281 ++++++++++++++++++ .../fragment_shared_element_activity.xml | 12 + 7 files changed, 529 insertions(+) create mode 100644 enro-core/src/main/java/dev/enro/destination/fragment/FragmentSharedElements.kt create mode 100644 tests/application/src/main/java/dev/enro/tests/application/fragment/FragmentWithSharedElements.kt create mode 100644 tests/application/src/main/res/layout/fragment_shared_element_activity.xml diff --git a/enro-core/src/main/java/dev/enro/destination/fragment/FragmentSharedElements.kt b/enro-core/src/main/java/dev/enro/destination/fragment/FragmentSharedElements.kt new file mode 100644 index 00000000..298f1847 --- /dev/null +++ b/enro-core/src/main/java/dev/enro/destination/fragment/FragmentSharedElements.kt @@ -0,0 +1,219 @@ +package dev.enro.destination.fragment + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.os.Bundle +import android.transition.TransitionInflater +import android.util.Log +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalView +import androidx.core.view.doOnAttach +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks +import androidx.fragment.app.FragmentTransaction +import androidx.fragment.app.findFragment +import dev.enro.core.NavigationHost +import dev.enro.core.R +import dev.enro.core.controller.NavigationController +import dev.enro.core.controller.application +import dev.enro.core.plugins.EnroPlugin +import dev.enro.destination.fragment.FragmentSharedElements.DelayedTransitionController +import java.util.WeakHashMap + +/** + * This object provides hooks for supporting shared element transitions in Fragments. + */ +public object FragmentSharedElements { + internal class SharedElement(val view: View, val name: String) + internal class SharedElementContainer(val map: WeakHashMap = WeakHashMap()) + + internal fun getSharedElements(fragment: Fragment): List { + val container = fragment.view?.getTag(R.id.enro_internal_shared_element_container_id) as? SharedElementContainer + ?: return emptyList() + + return container.map.map { (view, name) -> SharedElement(view, name) } + } + + /** + * This method configures a shared element transition for the View/name combination that is provided. When the Fragment + * associated with the View is part of a Fragment transaction, the View provided will be added to the transaction + * using [FragmentTransaction.addSharedElement]. + * + * If you add a shared element with a name that has already been used, it will cause the View associated with that name to + * be removed as a shared element. + * + * If you've previously configured a shared element transition for a View, but you want to remove it, use [clearSharedElement] + */ + public fun addSharedElement(view: View, name: String) { + view.doOnAttach { + val rootFragmentView = runCatching { view.findFragment() } + .getOrNull() + ?.view + + if (rootFragmentView == null) { + throw IllegalStateException("Cannot add shared element to a View that is not attached to a Fragment") + } + + val sharedElementContainer = + rootFragmentView.getTag(R.id.enro_internal_shared_element_container_id) as? SharedElementContainer + ?: SharedElementContainer().apply { + rootFragmentView.setTag( + R.id.enro_internal_shared_element_container_id, + this + ) + } + + // ensure we don't have duplicate names + sharedElementContainer.map.toList().forEach { (otherView, otherName) -> + if (otherName == name) { sharedElementContainer.map.remove(otherView) } + } + sharedElementContainer.map[view] = name + } + } + + /** + * Removes a shared element from the shared element transition for the Fragment that contains the provided View. + */ + public fun clearSharedElement(view: View) { + val rootFragmentView = runCatching { view.findFragment() } + .getOrNull() + ?.view + + if (rootFragmentView == null) { + throw IllegalStateException("Cannot clear shared element from a View that is not attached to a Fragment") + } + + val sharedElementContainer = + rootFragmentView.getTag(R.id.enro_internal_shared_element_container_id) as? SharedElementContainer + ?: SharedElementContainer().apply { + rootFragmentView.setTag( + R.id.enro_internal_shared_element_container_id, + this + ) + } + + sharedElementContainer.map.remove(view) + } + + private val delayedTransitionFragments = WeakHashMap() + + /** + * This plugin is used to provide interoperability support for Compose and Fragment shared element transitions. You should + * install this plugin in your NavigationController if you want to enable shared element transitions for Composables that + * are hosted in FragmentNavigationContainers. + */ + public val composeCompatibilityPlugin: EnroPlugin = object : EnroPlugin() { + private val fragmentCallbacks = object : FragmentLifecycleCallbacks() { + override fun onFragmentViewCreated(fm: FragmentManager, f: Fragment, v: View, savedInstanceState: Bundle?) { + if (f !is NavigationHost) return + if (v !is ComposeView) return + f.postponeEnterTransition() + v.post { + if (delayedTransitionFragments.containsKey(f)) return@post + f.startPostponedEnterTransition() + } + } + } + + private val activityCallbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (activity is FragmentActivity) { + activity.supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentCallbacks, true) + } + } + + override fun onActivityStarted(activity: Activity) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + } + + override fun onAttached(navigationController: NavigationController) { + super.onAttached(navigationController) + navigationController.application.registerActivityLifecycleCallbacks(activityCallbacks) + } + + override fun onDetached(navigationController: NavigationController) { + super.onDetached(navigationController) + navigationController.application.unregisterActivityLifecycleCallbacks(activityCallbacks) + } + } + + /** + * This method is used to configure the shared element transitions for a Composable destination that is hosted in a + * FragmentNavigationContainer. + * + * By default, this method will use android.R.transition.move for shared element transitions, but by providing a value + * to [sharedElementEnter] or [sharedElementReturn], you can customize the shared element transitions for the Composable. + * These lambdas expect an [Any?] because that's the same type used by a Fragment's sharedElementEnterTransition and + * sharedElementReturnTransition. + * + * If you need to delay the start of the shared element transition, you can use [rememberDelayedTransitionController] to + * create a [DelayedTransitionController] that can be used to control the start of the shared element transition. + */ + @Composable + public fun ConfigureComposable( + sharedElementEnter: (Context) -> Any? = { TransitionInflater.from(it).inflateTransition(android.R.transition.move) }, + sharedElementReturn: (Context) -> Any? = { TransitionInflater.from(it).inflateTransition(android.R.transition.move) }, + ) { + val view = LocalView.current + LaunchedEffect(view) { + val fragment = runCatching { + view.findFragment() + }.getOrNull() + + if (fragment == null) { + Log.e("Enro", "Attempted to use FragmentSharedElements.ConfigureComposable in a Composable that is not hosted in a Fragment") + return@LaunchedEffect + } + fragment.sharedElementEnterTransition = sharedElementEnter(fragment.requireContext()) + fragment.sharedElementReturnTransition = sharedElementEnter(fragment.requireContext()) + } + } + + /** + * This interface is used to control the start of a delayed shared element transition. + * + * When using the FragmentSharedElement interoperability support for Compose, if you need to delay the start of the + * shared element transition, you can call [FragmentSharedElements.rememberDelayedTransitionController], to get an instance + * of [DelayedTransitionController]. This will cause the shared element transition to be delayed until you call [start] on + * the [DelayedTransitionController] instance. + */ + public fun interface DelayedTransitionController { public fun start() } + + /** + * [rememberDelayedTransitionController] is used to create a [DelayedTransitionController] that can be used to control the + * start of a delayed shared element transition when using the FragmentSharedElement interoperability support for Compose. + * This method should only be called from a Composable that has already called [FragmentSharedElements.ConfigureComposable]. + * + * @return A [DelayedTransitionController] instance that can be used to control the start of a delayed shared element transition. + */ + @Composable + public fun rememberDelayedTransitionController(): DelayedTransitionController { + val view = LocalView.current + return remember(view) { + val fragment = runCatching { + view.findFragment() + }.getOrNull() + + if (fragment == null) { + Log.e("Enro", "Attempted to use FragmentSharedElements.rememberDelayedTransitionController in a Composable that is not hosted in a Fragment") + return@remember DelayedTransitionController {} + } + delayedTransitionFragments[fragment] = Unit + DelayedTransitionController { + delayedTransitionFragments.remove(fragment) + fragment.startPostponedEnterTransition() + } + } + } +} diff --git a/enro-core/src/main/java/dev/enro/destination/fragment/container/FragmentNavigationContainer.kt b/enro-core/src/main/java/dev/enro/destination/fragment/container/FragmentNavigationContainer.kt index da4648eb..ccb74a5e 100644 --- a/enro-core/src/main/java/dev/enro/destination/fragment/container/FragmentNavigationContainer.kt +++ b/enro-core/src/main/java/dev/enro/destination/fragment/container/FragmentNavigationContainer.kt @@ -34,6 +34,7 @@ import dev.enro.core.controller.get import dev.enro.core.controller.interceptor.builder.NavigationInterceptorBuilder import dev.enro.core.controller.usecase.HostInstructionAs import dev.enro.core.navigationContext +import dev.enro.destination.fragment.FragmentSharedElements import dev.enro.extensions.animate import dev.enro.extensions.getParcelableCompat @@ -172,6 +173,9 @@ public class FragmentNavigationContainer internal constructor( ) toRemoveDirect.forEach { remove(it) + FragmentSharedElements.getSharedElements(it).forEach { sharedElement -> + addSharedElement(sharedElement.view, sharedElement.name) + } ownedFragments.remove(it.tag) } runOnCommit { @@ -181,6 +185,9 @@ public class FragmentNavigationContainer internal constructor( } } toDetach.forEach { + FragmentSharedElements.getSharedElements(it.fragment).forEach { sharedElement -> + addSharedElement(sharedElement.view, sharedElement.name) + } detach(it.fragment) } if (activePushed != null) { diff --git a/enro-core/src/main/res/values/id.xml b/enro-core/src/main/res/values/id.xml index 6c55075c..5ee6a69a 100644 --- a/enro-core/src/main/res/values/id.xml +++ b/enro-core/src/main/res/values/id.xml @@ -3,4 +3,5 @@ + \ No newline at end of file diff --git a/tests/application/src/main/AndroidManifest.xml b/tests/application/src/main/AndroidManifest.xml index f3a59cd8..383a1c72 100644 --- a/tests/application/src/main/AndroidManifest.xml +++ b/tests/application/src/main/AndroidManifest.xml @@ -25,6 +25,7 @@ + \ No newline at end of file diff --git a/tests/application/src/main/java/dev/enro/tests/application/TestApplication.kt b/tests/application/src/main/java/dev/enro/tests/application/TestApplication.kt index a63a510b..9c979321 100644 --- a/tests/application/src/main/java/dev/enro/tests/application/TestApplication.kt +++ b/tests/application/src/main/java/dev/enro/tests/application/TestApplication.kt @@ -5,6 +5,7 @@ import androidx.compose.material.MaterialTheme import dev.enro.annotations.NavigationComponent import dev.enro.core.controller.NavigationApplication import dev.enro.core.controller.createNavigationController +import dev.enro.destination.fragment.FragmentSharedElements @NavigationComponent class TestApplication : Application(), NavigationApplication { @@ -13,5 +14,12 @@ class TestApplication : Application(), NavigationApplication { composeEnvironment { content -> MaterialTheme { content() } } + + /** + * The following plugin is installed specifically to support the example in + * [dev.enro.tests.application.fragment.FragmentSharedElementDestination], which has an example of + * shared element transitions between a Fragment and Composable NavigationDestination + */ + plugin(FragmentSharedElements.composeCompatibilityPlugin) } } \ No newline at end of file diff --git a/tests/application/src/main/java/dev/enro/tests/application/fragment/FragmentWithSharedElements.kt b/tests/application/src/main/java/dev/enro/tests/application/fragment/FragmentWithSharedElements.kt new file mode 100644 index 00000000..d3da18e8 --- /dev/null +++ b/tests/application/src/main/java/dev/enro/tests/application/fragment/FragmentWithSharedElements.kt @@ -0,0 +1,281 @@ +package dev.enro.tests.application.fragment + +import android.content.res.ColorStateList +import android.os.Bundle +import android.os.Parcelable +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +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.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.doOnPreDraw +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.transition.TransitionInflater +import dev.enro.annotations.AdvancedEnroApi +import dev.enro.annotations.NavigationDestination +import dev.enro.core.NavigationKey +import dev.enro.core.compose.OverrideNavigationAnimations +import dev.enro.core.compose.navigationHandle +import dev.enro.core.container.EmptyBehavior +import dev.enro.core.fragment.container.navigationContainer +import dev.enro.core.navigationHandle +import dev.enro.core.push +import dev.enro.core.requestClose +import dev.enro.destination.fragment.FragmentSharedElements +import dev.enro.test.application.R +import kotlinx.parcelize.Parcelize + +/** + * FragmentSharedElementDestination is a destination that provides an example of using FragmentSharedElements to create shared + * element transitions between Fragments and Composables. This example uses a RecyclerView to display a list of images, and + * allows the user to open a Fragment or Composable that displays the image in a larger format, with a shared element transition + * going from the RecyclerView to the Fragment/Composable (and back again). + * + * Note: The NavigationController must be configured with [FragmentSharedElements.composeCompatibilityPlugin] to allow shared + * element transitions between Fragments and Composables (it's not required for Fragment-to-Fragment shared element transitions). + * See [dev.enro.tests.application.TestApplication] where [FragmentSharedElements.composeCompatibilityPlugin] is installed. + */ +@Parcelize +object FragmentSharedElementDestination : NavigationKey.SupportsPresent { + @Parcelize + internal object RecyclerViewFragment : Parcelable, NavigationKey.SupportsPush + + @Parcelize + internal class DetailViewFragment( + val imageId: Int + ) : Parcelable, NavigationKey.SupportsPush + + @Parcelize + internal class DetailViewComposable( + val imageId: Int + ) : Parcelable, NavigationKey.SupportsPush +} + +/** + * This Activity is not interesting, it's just a container to hold the Fragments/Composables for this example. + */ +@NavigationDestination(FragmentSharedElementDestination::class) +class FragmentSharedElementActivity : AppCompatActivity() { + val container by navigationContainer( + containerId = R.id.fragment_container, + root = { FragmentSharedElementDestination.RecyclerViewFragment }, + emptyBehavior = EmptyBehavior.CloseParent, + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.fragment_shared_element_activity) + } +} + +/** + * This imageTints list is used as a way to tint the images in the RecyclerView for this example, the position in the list + * is considered to be an "id" for the examples below. In reality, you would likely load the data from a remote source. + */ +private val imageTints = listOf( + 0xFFFF0000, + 0xFF00FF00, + 0xFF0000FF, + 0xFFFF00FF, + 0xFFFFFF00, + 0xFF00FFFF, +) + +/** + * The FragmentSharedElementRecyclerView is a simple RecyclerView that displays a list of images, each one tinted to a different + * color based on [imageTints]. Each row in the RecyclerView can open a Fragment or Composable that displays the image in a + * larger "detail" format. There is a shared element transition between FragmentSharedElementRecyclerView and + * FragmentSharedElementDetailFragment/FragmentSharedElementDetailComposable. + */ +@NavigationDestination(FragmentSharedElementDestination.RecyclerViewFragment::class) +class FragmentSharedElementRecyclerView : Fragment() { + + private val navigation by navigationHandle() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sharedElementEnterTransition = TransitionInflater.from(requireContext()) + .inflateTransition(android.R.transition.move) + sharedElementReturnTransition = TransitionInflater.from(requireContext()) + .inflateTransition(android.R.transition.move) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return RecyclerView(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + setBackgroundColor(0xFFFFFFFF.toInt()) + layoutManager = LinearLayoutManager(requireContext()) + adapter = object : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SharedElementViewHolder { + val view = LinearLayout(requireContext()) + view.layoutParams = + ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + view.setVerticalGravity(Gravity.CENTER_VERTICAL) + view.orientation = LinearLayout.HORIZONTAL + view.addView(ImageView(requireContext()).apply { + setImageResource(R.drawable.ic_launcher_foreground) + }) + view.addView(Button(requireContext()).apply { + text = "Open Fragment" + }) + view.addView(Button(requireContext()).apply { + text = "Open Compose" + }) + return SharedElementViewHolder(view) + } + + override fun onBindViewHolder(holder: SharedElementViewHolder, position: Int) { + holder.bind(position) + } + + override fun getItemCount(): Int = imageTints.size + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // It's required to slightly delay enter transitions when using RecyclerViews, according to the following documentation: + // https://developer.android.com/guide/fragments/animate#recyclerview + // The following code will delay the enter transition until the RecyclerView is ready to draw: + postponeEnterTransition() + view.doOnPreDraw { startPostponedEnterTransition() } + } + + inner class SharedElementViewHolder(view: View) : RecyclerView.ViewHolder(view) { + fun bind(imageId: Int) { + val view = itemView as LinearLayout + view.getChildAt(0).apply { + this as ImageView + transitionName = "image_$imageId" + imageTintList = ColorStateList.valueOf(imageTints[imageId].toInt()) + } + view.getChildAt(1).setOnClickListener { + FragmentSharedElements.addSharedElement(view.getChildAt(0), "sharedElementImage") + navigation.push(FragmentSharedElementDestination.DetailViewFragment(imageId)) + } + view.getChildAt(2).setOnClickListener { + FragmentSharedElements.addSharedElement(view.getChildAt(0), "sharedElementImage") + navigation.push(FragmentSharedElementDestination.DetailViewComposable(imageId)) + } + } + } +} + +@NavigationDestination(FragmentSharedElementDestination.DetailViewFragment::class) +class FragmentSharedElementDetailFragment : Fragment() { + + private val navigation by navigationHandle() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sharedElementEnterTransition = TransitionInflater.from(requireContext()) + .inflateTransition(android.R.transition.move) + sharedElementReturnTransition = TransitionInflater.from(requireContext()) + .inflateTransition(android.R.transition.move) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + requireNotNull(container) + + val imageSize = maxOf(container.measuredWidth, container.measuredHeight) / 2 + return LinearLayout(requireContext()).apply { + setBackgroundColor(0xFFFFFFFF.toInt()) + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + setHorizontalGravity(Gravity.CENTER_HORIZONTAL) + orientation = LinearLayout.VERTICAL + val image = ImageView(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams(imageSize, imageSize) + transitionName = "sharedElementImage" + setImageResource(R.drawable.ic_launcher_foreground) + imageTintList = ColorStateList.valueOf(imageTints[navigation.key.imageId].toInt()) + } + addView(image) + addView(Button(requireContext()).apply { + text = "Close" + FragmentSharedElements.addSharedElement(image, "image_${navigation.key.imageId}") + setOnClickListener { + navigation.requestClose() + } + }) + } + } +} + +@OptIn(AdvancedEnroApi::class) +@NavigationDestination(FragmentSharedElementDestination.DetailViewComposable::class) +@Composable +fun FragmentSharedElementDetailComposable() { + // It's important to call FragmentSharedElements.ConfigureComposable() when you want to configure an + // @Composable @NavigationDestination to support shared elements that are shared with Fragments. + // In this case, we're also calling OverrideNavigationAnimations() to override the default navigation animations, so + // that the other elements on the screen fade in and out, which puts a focus on the shared element animation. + FragmentSharedElements.ConfigureComposable() + OverrideNavigationAnimations(fadeIn(), fadeOut()) + + // We're also going to configure a delayed transition, so that the shared element transition can be delayed until we're + // ready to draw the View associated with the shared element. This is important for shared elements that are not + // immediately visible when the view is drawn. It's not strictly necessary in this case, but it's a useful example. + val delayedTransition = FragmentSharedElements.rememberDelayedTransitionController() + + val navigation = navigationHandle() + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + val constraints = constraints + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val imageSize = maxOf(constraints.maxWidth, constraints.maxHeight) / 2 + val imageSizeDp = LocalDensity.current.run { imageSize.toDp() } + + AndroidView( + modifier = Modifier.size(imageSizeDp), + factory = { context -> + ImageView(context).apply { + setImageResource(R.drawable.ic_launcher_foreground) + imageTintList = ColorStateList.valueOf(imageTints[navigation.key.imageId].toInt()) + transitionName = "sharedElementImage" + FragmentSharedElements.addSharedElement(this, "image_${navigation.key.imageId}") + + // Wait until the view is ready to draw before starting the transition + doOnPreDraw { delayedTransition.start() } + } + }, + ) + + Button( + onClick = { + navigation.requestClose() + } + ) { + Text("Close") + } + } + } +} + + diff --git a/tests/application/src/main/res/layout/fragment_shared_element_activity.xml b/tests/application/src/main/res/layout/fragment_shared_element_activity.xml new file mode 100644 index 00000000..f592a160 --- /dev/null +++ b/tests/application/src/main/res/layout/fragment_shared_element_activity.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file From efb57f324130c1a7cff6440d332936b9a99f8e14 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Wed, 4 Sep 2024 00:28:45 +1200 Subject: [PATCH 08/51] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b89d34..6f1b8fd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Updated `enro-test` methods to provide more descriptive error messages when assert/expect methods fail, and added kdoc comments to many of the functions * Updated Composable navigation animations to use SeekableTransitionState, as a step towards supporting predictive back navigation animations * Fixed a bug where managed flows (`registerForFlowResult`) that launch embedded flows (`deliverResultFromPush/Present`) were not correctly handling the result of the embedded flow +* Added `FragmentSharedElements` to provide a way to define shared elements for Fragment navigation, including a compatibility layer for Composable NavigationDestinations that want to use AndroidViews as shared elements with Fragments. See `FragmentsWithSharedElements.kt` in the test application for examples of how to use `FragmentSharedElements` ## 2.7.0 * ⚠️ Updated to androidx.lifecycle 2.8.1 ⚠️ From 9fed56970af64635ce29b01529e2644e8bb1f705 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Tue, 3 Sep 2024 12:42:17 +0000 Subject: [PATCH 09/51] Released 2.8.0-alpha02 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 7985c90d..0fb60d9b 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -versionName=2.8.0-alpha01 -versionCode=103 \ No newline at end of file +versionName=2.8.0-alpha02 +versionCode=104 \ No newline at end of file From 0fc4aaaddfdcfa079d71d5d4ee2df231ea1e909a Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Wed, 4 Sep 2024 00:56:45 +1200 Subject: [PATCH 10/51] Revert version change --- version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.properties b/version.properties index 0fb60d9b..a235157f 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -versionName=2.8.0-alpha02 +versionName=2.8.0-alpha01 versionCode=104 \ No newline at end of file From 45cb9b3861cc57477f08615eba571cd9a55376b4 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Tue, 3 Sep 2024 13:01:09 +0000 Subject: [PATCH 11/51] Released 2.8.0-alpha02 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index a235157f..ac86bb74 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -versionName=2.8.0-alpha01 -versionCode=104 \ No newline at end of file +versionName=2.8.0-alpha02 +versionCode=105 \ No newline at end of file From 98d913d0f8680b71fec50462c2b1f8624815785e Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 12 Sep 2024 21:38:09 +1200 Subject: [PATCH 12/51] Added a new test for testing a single ViewModel with multiple result channels of different types (this is important to verify Kotlin 2.0 lambda changes) --- .../application/SelectDestinationRobot.kt | 28 ++- .../results/ComposeMixedResultTypesRobot.kt | 106 +++++++++++ .../results/ComposeMixedResultTypesTest.kt | 27 +++ .../results/ComposeMixedResultTypes.kt | 169 ++++++++++++++++++ 4 files changed, 321 insertions(+), 9 deletions(-) create mode 100644 tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeMixedResultTypesRobot.kt create mode 100644 tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeMixedResultTypesTest.kt create mode 100644 tests/application/src/main/java/dev/enro/tests/application/compose/results/ComposeMixedResultTypes.kt diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt b/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt index 083e09ba..16e76201 100644 --- a/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt +++ b/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt @@ -17,6 +17,7 @@ import dev.enro.test.application.compose.results.ComposeAsyncManagedResultFlowRo import dev.enro.test.application.compose.results.ComposeEmbeddedResultFlowRobot import dev.enro.test.application.compose.results.ComposeManagedResultFlowRobot import dev.enro.test.application.compose.results.ComposeManagedResultsWithNestedFlowAndEmptyRootRobot +import dev.enro.test.application.compose.results.ComposeMixedResultTypesRobot import dev.enro.test.application.compose.results.ComposeNestedResultsRobot import dev.enro.test.application.compose.results.ResultsWithExtraRobot import dev.enro.test.application.fragment.UnboundBottomSheetRobot @@ -32,7 +33,7 @@ class SelectDestinationRobot( } } - fun openBottomSheetCloseAndPresent() : BottomSheetCloseAndPresentRobot { + fun openBottomSheetCloseAndPresent(): BottomSheetCloseAndPresentRobot { composeRule.onNode(hasText("Bottom Sheet Close And Present")) .performScrollTo() .onSiblings() @@ -42,7 +43,7 @@ class SelectDestinationRobot( return BottomSheetCloseAndPresentRobot(composeRule) } - fun openBottomSheetChangeSize() : BottomSheetChangeSizeRobot { + fun openBottomSheetChangeSize(): BottomSheetChangeSizeRobot { composeRule.onNode(hasText("Bottom Sheet Change Size")) .performScrollTo() .onSiblings() @@ -52,7 +53,7 @@ class SelectDestinationRobot( return BottomSheetChangeSizeRobot(composeRule) } - fun openLegacyBottomSheets() : LegacyBottomSheetsRobot { + fun openLegacyBottomSheets(): LegacyBottomSheetsRobot { composeRule.onNode(hasText("Legacy Bottom Sheets")) .performScrollTo() .onSiblings() @@ -62,7 +63,7 @@ class SelectDestinationRobot( return LegacyBottomSheetsRobot(composeRule) } - fun openSimpleActivity() : SimpleActivityRobot { + fun openSimpleActivity(): SimpleActivityRobot { composeRule.onNode(hasText("Simple Activity")) .performScrollTo() .onSiblings() @@ -72,7 +73,7 @@ class SelectDestinationRobot( return SimpleActivityRobot(composeRule) } - fun openUnboundBottomSheet() : UnboundBottomSheetRobot { + fun openUnboundBottomSheet(): UnboundBottomSheetRobot { composeRule .onNode(hasText("Unbound Bottom Sheet")) .performScrollTo() @@ -83,7 +84,7 @@ class SelectDestinationRobot( return UnboundBottomSheetRobot(composeRule) } - fun openBottomNavigation() : BottomNavigationRobot { + fun openBottomNavigation(): BottomNavigationRobot { composeRule.onNode(hasText("Bottom Navigation")) .performScrollTo() .onSiblings() @@ -93,7 +94,7 @@ class SelectDestinationRobot( return BottomNavigationRobot(composeRule) } - fun openSyntheticViewModelAccess() : SyntheticViewModelAccessRobot { + fun openSyntheticViewModelAccess(): SyntheticViewModelAccessRobot { composeRule.onNode(hasText("Synthetic View Model Access")) .performScrollTo() .onSiblings() @@ -103,7 +104,7 @@ class SelectDestinationRobot( return SyntheticViewModelAccessRobot(composeRule) } - fun openFindContext() : FindContextRobot { + fun openFindContext(): FindContextRobot { composeRule.onNode(hasText("Find Context")) .performScrollTo() .onSiblings() @@ -163,7 +164,7 @@ class SelectDestinationRobot( return ResultsWithExtraRobot(composeRule) } - fun openComposeManagedResultsWithNestedFlowAndEmptyRoot() : ComposeManagedResultsWithNestedFlowAndEmptyRootRobot { + fun openComposeManagedResultsWithNestedFlowAndEmptyRoot(): ComposeManagedResultsWithNestedFlowAndEmptyRootRobot { composeRule.onNode(hasText("Compose Managed Results With Nested Flow And Empty Root")) .performScrollTo() .onSiblings() @@ -172,4 +173,13 @@ class SelectDestinationRobot( return ComposeManagedResultsWithNestedFlowAndEmptyRootRobot(composeRule) } + + fun openComposeMixedResultTypes(): ComposeMixedResultTypesRobot { + composeRule.onNode(hasText("Compose Mixed Result Types")) + .performScrollTo() + .onSiblings() + .filterToOne(hasText("Push")) + .performClick() + return ComposeMixedResultTypesRobot(composeRule) + } } \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeMixedResultTypesRobot.kt b/tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeMixedResultTypesRobot.kt new file mode 100644 index 00000000..c421df91 --- /dev/null +++ b/tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeMixedResultTypesRobot.kt @@ -0,0 +1,106 @@ +package dev.enro.test.application.compose.results + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import dev.enro.test.application.waitForNavigationHandle +import dev.enro.tests.application.compose.results.ComposeMixedResultTypes + +@OptIn(ExperimentalTestApi::class) +class ComposeMixedResultTypesRobot( + private val composeRule: ComposeTestRule +) { + init { + composeRule.waitForNavigationHandle { + it.key is ComposeMixedResultTypes + } + } + + fun assertStringResult(): ComposeMixedResultTypesRobot { + composeRule.onNodeWithText("Get Result: String") + .assertExists() + .performClick() + + composeRule.waitUntilAtLeastOneExists(hasText("Send Result")) + composeRule.onNodeWithText("Send Result") + .assertExists() + .performClick() + + composeRule.waitUntilAtLeastOneExists(hasText("Mixed Result Types")) + composeRule.onNodeWithText( + "Current Result: java.lang.String \"This is a String\"" + ) + return this + } + + fun assertIntResult(): ComposeMixedResultTypesRobot { + composeRule.onNodeWithText("Get Result: Int") + .assertExists() + .performClick() + + composeRule.waitUntilAtLeastOneExists(hasText("Send Result")) + composeRule.onNodeWithText("Send Result") + .assertExists() + .performClick() + + composeRule.waitUntilAtLeastOneExists(hasText("Mixed Result Types")) + composeRule.onNodeWithText( + "Current Result: int 1" + ) + return this + } + + fun assertListStringResult(): ComposeMixedResultTypesRobot { + composeRule.onNodeWithText("Get Result: List") + .assertExists() + .performClick() + + composeRule.waitUntilAtLeastOneExists(hasText("Send Result")) + composeRule.onNodeWithText("Send Result") + .assertExists() + .performClick() + + composeRule.waitUntilAtLeastOneExists(hasText("Mixed Result Types")) + composeRule.onNodeWithText( + "Current Result: java.util.Arrays\$ArrayList [wow, nice]" + ) + return this + } + + fun assertBooleanResult(): ComposeMixedResultTypesRobot { + composeRule.onNodeWithText("Get Result: Boolean") + .assertExists() + .performClick() + + composeRule.waitUntilAtLeastOneExists(hasText("Send Result")) + composeRule.onNodeWithText("Send Result") + .assertExists() + .performClick() + + composeRule.waitUntilAtLeastOneExists(hasText("Mixed Result Types")) + composeRule.onNodeWithText( + "Current Result: boolean true" + ) + return this + } + + fun assertObjectResult(): ComposeMixedResultTypesRobot { + composeRule.onNodeWithText("Get Result: AnotherObject") + .assertExists() + .performClick() + + composeRule.waitUntilAtLeastOneExists(hasText("Send Result")) + composeRule.onNodeWithText("Send Result") + .assertExists() + .performClick() + + composeRule.waitUntilAtLeastOneExists(hasText("Mixed Result Types")) + composeRule.onNodeWithText( + substring = true, + text = "Current Result: dev.enro.test.application.compose.results.AnotherObject", + ) + return this + } +} \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeMixedResultTypesTest.kt b/tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeMixedResultTypesTest.kt new file mode 100644 index 00000000..0cf75423 --- /dev/null +++ b/tests/application/src/androidTest/java/dev/enro/test/application/compose/results/ComposeMixedResultTypesTest.kt @@ -0,0 +1,27 @@ +package dev.enro.test.application.compose.results + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import dev.enro.test.application.SelectDestinationRobot +import dev.enro.tests.application.TestActivity +import org.junit.Rule +import org.junit.Test + +class ComposeMixedResultTypesTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun test() { + SelectDestinationRobot(composeRule) + .openComposeMixedResultTypes() + .assertStringResult() + .assertIntResult() + .assertListStringResult() + .assertBooleanResult() + .assertObjectResult() + .assertStringResult() + .assertBooleanResult() + .assertIntResult() + .assertStringResult() + } +} \ No newline at end of file diff --git a/tests/application/src/main/java/dev/enro/tests/application/compose/results/ComposeMixedResultTypes.kt b/tests/application/src/main/java/dev/enro/tests/application/compose/results/ComposeMixedResultTypes.kt new file mode 100644 index 00000000..ebc860e6 --- /dev/null +++ b/tests/application/src/main/java/dev/enro/tests/application/compose/results/ComposeMixedResultTypes.kt @@ -0,0 +1,169 @@ +package dev.enro.tests.application.compose.results + +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import dev.enro.annotations.NavigationDestination +import dev.enro.core.NavigationKey +import dev.enro.core.closeWithResult +import dev.enro.core.compose.navigationHandle +import dev.enro.core.result.registerForNavigationResult +import dev.enro.tests.application.compose.common.TitledColumn +import kotlinx.parcelize.Parcelize +import java.util.UUID + +@Parcelize +object ComposeMixedResultTypes : NavigationKey.SupportsPush { + @Parcelize + internal class StringResult : NavigationKey.SupportsPush.WithResult + + @Parcelize + internal class IntResult : NavigationKey.SupportsPush.WithResult + + @Parcelize + internal class ListOfStringResult : NavigationKey.SupportsPush.WithResult> + + @Parcelize + internal class BooleanResult : NavigationKey.SupportsPush.WithResult + + @Parcelize + internal class AnotherObjectResult : NavigationKey.SupportsPush.WithResult +} + +data class AnotherObject( + val id: String = UUID.randomUUID().toString(), + val int: Int = id.hashCode(), +) + +class ComposeMixedResultViewModel : ViewModel() { + + var currentResult by mutableStateOf("No Result") + + val stringChannel by registerForNavigationResult { + currentResult = "${it::class.java.name} $it" + } + val intChannel by registerForNavigationResult { + currentResult = "${it::class.java.name} $it" + } + val listOfStringChannel by registerForNavigationResult> { + currentResult = "${it::class.java.name} $it" + } + val booleanChannel by registerForNavigationResult { + currentResult = "${it::class.java.name} $it" + } + val anotherObjectChannel by registerForNavigationResult { + currentResult = "${it::class.java.name} $it" + } +} + +@NavigationDestination(ComposeMixedResultTypes::class) +@Composable +fun ComposeMixedResultTypesScreen() { + val viewModel = viewModel() + + TitledColumn( + title = "Mixed Result Types", + ) { + Text("Current result: ${viewModel.currentResult}") + + Button( + onClick = { viewModel.stringChannel.push(ComposeMixedResultTypes.StringResult()) } + ) { + Text("Get Result: String") + } + + Button( + onClick = { viewModel.intChannel.push(ComposeMixedResultTypes.IntResult()) } + ) { + Text("Get Result: Int") + } + + Button( + onClick = { viewModel.listOfStringChannel.push(ComposeMixedResultTypes.ListOfStringResult()) } + ) { + Text("Get Result: List") + } + + Button( + onClick = { viewModel.booleanChannel.push(ComposeMixedResultTypes.BooleanResult()) } + ) { + Text("Get Result: Boolean") + } + + Button( + onClick = { viewModel.anotherObjectChannel.push(ComposeMixedResultTypes.AnotherObjectResult()) } + ) { + Text("Get Result: AnotherObject") + } + } +} + +@NavigationDestination(ComposeMixedResultTypes.StringResult::class) +@Composable +fun StringResultScreen() { + val navigation = navigationHandle() + TitledColumn("StringResult") { + Button( + onClick = { navigation.closeWithResult("\"This is a String\"") } + ) { + Text("Send Result") + } + } +} + +@NavigationDestination(ComposeMixedResultTypes.IntResult::class) +@Composable +fun IntResultScreen() { + val navigation = navigationHandle() + TitledColumn("IntResult") { + Button( + onClick = { navigation.closeWithResult(1) } + ) { + Text("Send Result") + } + } +} + +@NavigationDestination(ComposeMixedResultTypes.ListOfStringResult::class) +@Composable +fun ListOfStringResultScreen() { + val navigation = navigationHandle() + TitledColumn("ListOfStringResult") { + Button( + onClick = { navigation.closeWithResult(listOf("wow", "nice")) } + ) { + Text("Send Result") + } + } +} + +@NavigationDestination(ComposeMixedResultTypes.BooleanResult::class) +@Composable +fun BooleanResultScreen() { + val navigation = navigationHandle() + TitledColumn("BooleanResult") { + Button( + onClick = { navigation.closeWithResult(true) } + ) { + Text("Send Result") + } + } +} + +@NavigationDestination(ComposeMixedResultTypes.AnotherObjectResult::class) +@Composable +fun AnotherObjectResultScreen() { + val navigation = navigationHandle() + TitledColumn("AnotherObjectResult") { + Button( + onClick = { navigation.closeWithResult(AnotherObject()) } + ) { + Text("Send Result") + } + } +} From 11943e04f1d2c20a74ec1444cd87078dd8f7d57f Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 12 Sep 2024 21:39:41 +1200 Subject: [PATCH 13/51] Added basic proguard config (disabled for now) to make it easy to quickly test how proguard changes might affect the test application --- tests/application/build.gradle.kts | 19 +++++++++++++++++++ tests/application/proguard-rules.pro | 3 +++ 2 files changed, 22 insertions(+) diff --git a/tests/application/build.gradle.kts b/tests/application/build.gradle.kts index a92ede02..9381bf2d 100644 --- a/tests/application/build.gradle.kts +++ b/tests/application/build.gradle.kts @@ -10,6 +10,25 @@ configureAndroidApp("dev.enro.test.application") configureCompose() configureEmulatorWtf() +android { + buildTypes { + getByName("debug") { + // Set isTestingProguard to true to test proguard rules + // this is useful when testing bugs that are related to minification + val isTestingProguard = false + + if (isTestingProguard) { + isDebuggable = false + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + } +} + dependencies { implementation(project(":tests:module-one")) diff --git a/tests/application/proguard-rules.pro b/tests/application/proguard-rules.pro index a2435633..98d8584f 100644 --- a/tests/application/proguard-rules.pro +++ b/tests/application/proguard-rules.pro @@ -21,3 +21,6 @@ #-renamesourcefileattribute SourceFile -keep public class kotlin.** -keep class kotlin.Metadata { *; } +-keep public class *.** { *;} +-dontobfuscate +-dontwarn javax.lang.model.element.Modifier \ No newline at end of file From a11dbed0b0638dede517a791bef330ceb1c20f6d Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 12 Sep 2024 21:42:50 +1200 Subject: [PATCH 14/51] Updated NavigationController to take an "EnroConfig" for configurable properties like isInTest, backConfiguration or isStrictMode, updated NavigationAnimation to allow disabling View based animations for Composables (as this was causing issues with tests) --- .../enro/animation/NavigationAnimations.kt | 40 ++++++++++++++----- .../src/main/java/dev/enro/core/EnroConfig.kt | 16 ++++++++ .../main/java/dev/enro/core/EnroExceptions.kt | 4 +- .../java/dev/enro/core/NavigationHandle.kt | 2 +- .../core/NavigationHandleConfiguration.kt | 2 +- .../core/controller/NavigationController.kt | 20 +++++++--- .../controller/createNavigationController.kt | 16 ++++++-- .../controller/usecase/AddPendingResult.kt | 2 +- .../usecase/GetNavigationAnimations.kt | 4 +- .../NavigationHandleViewModelFactory.kt | 2 +- .../destination/activity/ActivityContext.kt | 2 +- .../destination/compose/ComposeContext.kt | 2 +- .../destination/fragment/FragmentContext.kt | 4 +- .../src/main/java/dev/enro/test/EnroTest.kt | 30 +++++++++++--- 14 files changed, 108 insertions(+), 38 deletions(-) create mode 100644 enro-core/src/main/java/dev/enro/core/EnroConfig.kt diff --git a/enro-core/src/main/java/dev/enro/animation/NavigationAnimations.kt b/enro-core/src/main/java/dev/enro/animation/NavigationAnimations.kt index 40249cd6..d5a1a786 100644 --- a/enro-core/src/main/java/dev/enro/animation/NavigationAnimations.kt +++ b/enro-core/src/main/java/dev/enro/animation/NavigationAnimations.kt @@ -8,12 +8,15 @@ import androidx.compose.animation.EnterExitState import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.updateTransition import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import dev.enro.core.AnyOpenInstruction +import dev.enro.core.EnroConfig import dev.enro.core.NavigationDirection import dev.enro.core.container.originalNavigationDirection +import dev.enro.core.controller.NavigationApplication import dev.enro.extensions.ResourceAnimatedVisibility import dev.enro.extensions.getAttributeResourceId import dev.enro.extensions.getNestedAttributeResourceId @@ -88,18 +91,33 @@ public sealed interface NavigationAnimation { content: @androidx.compose.runtime.Composable (Transition) -> Unit, ) { val context = LocalContext.current - val resourceAnimation = remember(this, forView) { forView.asResource(context.theme) } - visible.AnimatedVisibility( - visible = { it }, - enter = enter, - exit = exit, - ) { - transition.ResourceAnimatedVisibility( - visible = { it == EnterExitState.Visible }, - enter = resourceAnimation.id, - exit = resourceAnimation.id, + val config = remember(context) { + val navigationApplication = (context.applicationContext as? NavigationApplication) + navigationApplication?.navigationController?.config ?: EnroConfig() + } + + if (config.isAnimationsDisabled) { + val transition = updateTransition(EnterExitState.Visible, "NavigationAnimation.Composable") + content(transition) + } + else { + val resourceAnimation = remember(this, forView) { forView.asResource(context.theme) } + visible.AnimatedVisibility( + visible = { it }, + enter = enter, + exit = exit, ) { - content(transition) + if (config.enableViewAnimationsForCompose) { + transition.ResourceAnimatedVisibility( + visible = { it == EnterExitState.Visible }, + enter = resourceAnimation.id, + exit = resourceAnimation.id, + ) { + content(transition) + } + } else { + content(transition) + } } } } diff --git a/enro-core/src/main/java/dev/enro/core/EnroConfig.kt b/enro-core/src/main/java/dev/enro/core/EnroConfig.kt new file mode 100644 index 00000000..a014d6ab --- /dev/null +++ b/enro-core/src/main/java/dev/enro/core/EnroConfig.kt @@ -0,0 +1,16 @@ +package dev.enro.core + +import dev.enro.core.controller.EnroBackConfiguration + +public data class EnroConfig( + val isInTest: Boolean = false, + internal val isAnimationsDisabled: Boolean = false, + internal val isStrictMode: Boolean = false, + internal val backConfiguration: EnroBackConfiguration = EnroBackConfiguration.Default, + /** + * This Boolean sets whether or not Composables will attempt to fallback to View based animations (Animation or Animator) + * when there are no Composable Enter/ExitTransition animations provided. This is disabled by default for tests, based + * on checking for the presence of the JUnit Test class, because these animations cause issues with ComposeTestRule tests. + */ + internal val enableViewAnimationsForCompose: Boolean = runCatching { Class.forName("org.junit.Test") }.isFailure, +) diff --git a/enro-core/src/main/java/dev/enro/core/EnroExceptions.kt b/enro-core/src/main/java/dev/enro/core/EnroExceptions.kt index f69d8a8a..06e5efc1 100644 --- a/enro-core/src/main/java/dev/enro/core/EnroExceptions.kt +++ b/enro-core/src/main/java/dev/enro/core/EnroExceptions.kt @@ -66,7 +66,7 @@ public abstract class EnroException( val message = "Opened ${args.key::class.java.simpleName} as a ${args.instruction.navigationDirection::class.java.simpleName} instruction. Forward and Replace type instructions are deprecated, please replace these with Push and Present instructions." - if (navigationController.isStrictMode) { + if (navigationController.config.isStrictMode) { throw LegacyNavigationDirectionUsedInStrictMode(message) } else { Log.w("Enro", "$message Enro would have thrown in strict mode.") @@ -84,7 +84,7 @@ public abstract class EnroException( ) { val message = "Attempted to Push to ${navigationKey::class.java.simpleName}, but could not find a valid container." - if (navigationController.isStrictMode) { + if (navigationController.config.isStrictMode) { throw MissingContainerForPushInstruction(message) } else { Log.w( diff --git a/enro-core/src/main/java/dev/enro/core/NavigationHandle.kt b/enro-core/src/main/java/dev/enro/core/NavigationHandle.kt index 75c730fd..0e827c76 100644 --- a/enro-core/src/main/java/dev/enro/core/NavigationHandle.kt +++ b/enro-core/src/main/java/dev/enro/core/NavigationHandle.kt @@ -125,7 +125,7 @@ public fun NavigationHandle.requestClose() { internal fun NavigationHandle.runWhenHandleActive(block: () -> Unit) { val isMainThread = runCatching { Looper.getMainLooper() == Looper.myLooper() - }.getOrElse { dependencyScope.get().isInTest } // if the controller is in a Jvm only test, the block above may fail to run + }.getOrElse { dependencyScope.get().config.isInTest } // if the controller is in a Jvm only test, the block above may fail to run if(isMainThread && lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { block() diff --git a/enro-core/src/main/java/dev/enro/core/NavigationHandleConfiguration.kt b/enro-core/src/main/java/dev/enro/core/NavigationHandleConfiguration.kt index 69519dcb..ca4c7f29 100644 --- a/enro-core/src/main/java/dev/enro/core/NavigationHandleConfiguration.kt +++ b/enro-core/src/main/java/dev/enro/core/NavigationHandleConfiguration.kt @@ -49,7 +49,7 @@ public class LazyNavigationHandleConfiguration( if (handle is NavigationHandleViewModel) { handle.internalOnCloseRequested = { onCloseRequested(navigationHandle.asTyped(keyType)) } - } else if (handle.dependencyScope.get().isInTest) { + } else if (handle.dependencyScope.get().config.isInTest) { val field = handle::class.java.declaredFields .firstOrNull { it.name.startsWith("internalOnCloseRequested") } ?: return diff --git a/enro-core/src/main/java/dev/enro/core/controller/NavigationController.kt b/enro-core/src/main/java/dev/enro/core/controller/NavigationController.kt index 12bf9ab9..9208a1cb 100644 --- a/enro-core/src/main/java/dev/enro/core/controller/NavigationController.kt +++ b/enro-core/src/main/java/dev/enro/core/controller/NavigationController.kt @@ -2,6 +2,7 @@ package dev.enro.core.controller import android.app.Application import androidx.annotation.Keep +import dev.enro.core.EnroConfig import dev.enro.core.EnroException import dev.enro.core.NavigationBinding import dev.enro.core.NavigationExecutor @@ -14,12 +15,6 @@ import dev.enro.core.result.EnroResult import kotlin.reflect.KClass public class NavigationController internal constructor() { - internal var isInTest = false - internal var isAnimationsDisabled = false - - internal var isStrictMode: Boolean = false - internal var backConfiguration: EnroBackConfiguration = EnroBackConfiguration.Default - internal val dependencyScope = NavigationControllerScope(this) private val enroResult: EnroResult = dependencyScope.get() @@ -28,6 +23,9 @@ public class NavigationController internal constructor() { private val executorRepository: ExecutorRepository = dependencyScope.get() private val addModuleToController: AddModuleToController = dependencyScope.get() + public var config: EnroConfig = EnroConfig() + private set + init { pluginRepository.addPlugins(listOf(enroResult)) addModule(defaultNavigationModule) @@ -69,6 +67,16 @@ public class NavigationController internal constructor() { navigationControllerBindings.remove(application) } + /** + * This method is used to set the config, instead of using "internal set" on the config variable, because we + * want to be able to use this method from inside the test module, which needs to use @Suppress for + * "INVISIBLE_REFERENCE" and "INVISIBLE_MEMBER" to access internal functionality, and it appears that this does not + * allow access to set variables declared as "internal set" + */ + internal fun setConfig(config: EnroConfig) { + this.config = config + } + public companion object { internal val navigationControllerBindings = mutableMapOf() diff --git a/enro-core/src/main/java/dev/enro/core/controller/createNavigationController.kt b/enro-core/src/main/java/dev/enro/core/controller/createNavigationController.kt index 4d18ab37..e2e54767 100644 --- a/enro-core/src/main/java/dev/enro/core/controller/createNavigationController.kt +++ b/enro-core/src/main/java/dev/enro/core/controller/createNavigationController.kt @@ -19,8 +19,12 @@ public fun NavigationApplication.createNavigationController( navigationController.addModule(loadGeneratedNavigationModule()) navigationController.addModule(createNavigationModule(block)) return navigationController.apply { - this.isStrictMode = strictMode - this.backConfiguration = backConfiguration + setConfig( + config.copy( + isStrictMode = strictMode, + backConfiguration = backConfiguration, + ) + ) install(this@createNavigationController) } } @@ -45,8 +49,12 @@ internal fun createUnattachedNavigationController( val navigationController = NavigationController() navigationController.addModule(createNavigationModule(block)) return navigationController.apply { - isStrictMode = strictMode - this.backConfiguration = backConfiguration + setConfig( + config.copy( + isStrictMode = strictMode, + backConfiguration = backConfiguration, + ) + ) } } diff --git a/enro-core/src/main/java/dev/enro/core/controller/usecase/AddPendingResult.kt b/enro-core/src/main/java/dev/enro/core/controller/usecase/AddPendingResult.kt index 334ea4c9..3c17da07 100644 --- a/enro-core/src/main/java/dev/enro/core/controller/usecase/AddPendingResult.kt +++ b/enro-core/src/main/java/dev/enro/core/controller/usecase/AddPendingResult.kt @@ -23,7 +23,7 @@ internal class AddPendingResult( if (navigationKey !is NavigationKey.WithResult<*>) return val resultId = openInstruction.internal.resultId ?: when { - controller.isInTest -> ResultChannelId( + controller.config.isInTest -> ResultChannelId( ownerId = openInstruction.instructionId, resultId = openInstruction.instructionId ) diff --git a/enro-core/src/main/java/dev/enro/core/controller/usecase/GetNavigationAnimations.kt b/enro-core/src/main/java/dev/enro/core/controller/usecase/GetNavigationAnimations.kt index 7258cf5d..6ffbb0ba 100644 --- a/enro-core/src/main/java/dev/enro/core/controller/usecase/GetNavigationAnimations.kt +++ b/enro-core/src/main/java/dev/enro/core/controller/usecase/GetNavigationAnimations.kt @@ -6,7 +6,7 @@ import dev.enro.animation.DefaultAnimations import dev.enro.animation.NavigationAnimationOverride import dev.enro.animation.NavigationAnimationTransition import dev.enro.animation.OpeningTransition -import dev.enro.core.* +import dev.enro.core.AnyOpenInstruction import dev.enro.core.controller.NavigationController import dev.enro.core.controller.application @@ -73,6 +73,6 @@ internal class GetNavigationAnimations( ) }.getOrDefault(1.0f) - return animationScale < 0.01f || controller.isAnimationsDisabled + return animationScale < 0.01f || controller.config.isAnimationsDisabled } } \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModelFactory.kt b/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModelFactory.kt index 2a0cba16..5d33840e 100644 --- a/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModelFactory.kt +++ b/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModelFactory.kt @@ -19,7 +19,7 @@ internal class NavigationHandleViewModelFactory( } override fun create(modelClass: Class, extras: CreationExtras): T { - if(navigationController.isInTest) { + if(navigationController.config.isInTest) { return TestNavigationHandleViewModel( navigationController, instruction diff --git a/enro-core/src/main/java/dev/enro/destination/activity/ActivityContext.kt b/enro-core/src/main/java/dev/enro/destination/activity/ActivityContext.kt index 4c0611fc..cf56e32a 100644 --- a/enro-core/src/main/java/dev/enro/destination/activity/ActivityContext.kt +++ b/enro-core/src/main/java/dev/enro/destination/activity/ActivityContext.kt @@ -36,7 +36,7 @@ internal fun ActivityContext( } private fun bindBackHandling(navigationContext: NavigationContext, navigationHandle: NavigationHandle) { - val backConfiguration = navigationContext.controller.backConfiguration + val backConfiguration = navigationContext.controller.config.backConfiguration when (backConfiguration) { is EnroBackConfiguration.Default -> configureDefaultBackHandling(navigationContext) diff --git a/enro-core/src/main/java/dev/enro/destination/compose/ComposeContext.kt b/enro-core/src/main/java/dev/enro/destination/compose/ComposeContext.kt index 3b23c331..2b1452ab 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/ComposeContext.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/ComposeContext.kt @@ -37,7 +37,7 @@ private fun bindBackHandling( navigationContext: NavigationContext, navigationHandle: NavigationHandle ) { - val backConfiguration = navigationContext.controller.backConfiguration + val backConfiguration = navigationContext.controller.config.backConfiguration when (backConfiguration) { is EnroBackConfiguration.Default -> { diff --git a/enro-core/src/main/java/dev/enro/destination/fragment/FragmentContext.kt b/enro-core/src/main/java/dev/enro/destination/fragment/FragmentContext.kt index ecb9675b..8cc5bcb6 100644 --- a/enro-core/src/main/java/dev/enro/destination/fragment/FragmentContext.kt +++ b/enro-core/src/main/java/dev/enro/destination/fragment/FragmentContext.kt @@ -51,7 +51,7 @@ internal fun FragmentContext( } private fun bindBackHandling(navigationContext: NavigationContext, navigationHandle: NavigationHandle) { - val backConfiguration = navigationContext.controller.backConfiguration + val backConfiguration = navigationContext.controller.config.backConfiguration when(backConfiguration) { is EnroBackConfiguration.Default -> configureDefaultBackHandling(navigationContext) @@ -134,7 +134,7 @@ private fun earlyExitForUnboundFragmentsInTesting( fragment: Fragment ) : Boolean { val hasKey = fragment.getNavigationHandle().hasKey - val isInTest = fragment.requireActivity().application.navigationController.isInTest + val isInTest = fragment.requireActivity().application.navigationController.config.isInTest return isInTest && !hasKey } diff --git a/enro-test/src/main/java/dev/enro/test/EnroTest.kt b/enro-test/src/main/java/dev/enro/test/EnroTest.kt index 7d82c0c8..a0c88f68 100644 --- a/enro-test/src/main/java/dev/enro/test/EnroTest.kt +++ b/enro-test/src/main/java/dev/enro/test/EnroTest.kt @@ -30,7 +30,11 @@ object EnroTest { is NavigationApplication -> application.navigationController else -> createUnattachedNavigationController() }.apply { - isInTest = true + setConfig( + config.copy( + isInTest = true + ) + ) when (val application = application) { is NavigationApplication -> return@apply null -> installForJvmTests() @@ -42,11 +46,19 @@ object EnroTest { fun uninstallNavigationController() { EnroViewModelNavigationHandleProvider.clearAllForTest() navigationController?.apply { - isInTest = false + setConfig( + config.copy( + isInTest = false + ) + ) } navigationController?.apply { - isInTest = false + setConfig( + config.copy( + isInTest = false + ) + ) if (application is NavigationApplication) return@apply uninstall(application ?: return@apply) } @@ -58,11 +70,19 @@ object EnroTest { } fun disableAnimations(controller: NavigationController) { - controller.isAnimationsDisabled = true + controller.setConfig( + controller.config.copy( + isAnimationsDisabled = true + ) + ) } fun enableAnimations(controller: NavigationController) { - controller.isAnimationsDisabled = false + controller.setConfig( + controller.config.copy( + isAnimationsDisabled = false + ) + ) } } From 28cd52403bf14c2d341520d6f74984c28b0daf7c Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 12 Sep 2024 21:44:04 +1200 Subject: [PATCH 15/51] Updated Enro's result channel ids to be based on properties through property delegates, as opposed to relying on lambda class names, as this will break in Kotlin 2.0 --- .../controller/usecase/CreateResultChannel.kt | 72 +---- .../core/result/NavigationResultExtensions.kt | 256 ++++++------------ .../core/result/flows/FlowResultChannel.kt | 2 +- .../internal/LazyResultChannelProperty.kt | 82 ++---- .../compose/ComposableNavigationResult.kt | 5 +- 5 files changed, 114 insertions(+), 303 deletions(-) diff --git a/enro-core/src/main/java/dev/enro/core/controller/usecase/CreateResultChannel.kt b/enro-core/src/main/java/dev/enro/core/controller/usecase/CreateResultChannel.kt index 6ac29be1..1f14005f 100644 --- a/enro-core/src/main/java/dev/enro/core/controller/usecase/CreateResultChannel.kt +++ b/enro-core/src/main/java/dev/enro/core/controller/usecase/CreateResultChannel.kt @@ -18,78 +18,18 @@ internal class CreateResultChannel( @PublishedApi internal val navigationHandle: NavigationHandle, @PublishedApi internal val enroResult: EnroResult, ) { - /** - * The resultId being set here to the JVM class name of the onResult lambda is a key part of - * being able to make result channels work without providing an explicit id. The JVM will treat - * the lambda as an anonymous class, which is uniquely identifiable by it's class name. - * - * If the behaviour of the Kotlin/JVM interaction changes in a future release, it may be required - * to pass an explicit resultId as a part of the ResultChannelImpl constructor, which would need - * to be unique per result channel created. - * - * It is possible to have two result channels registered for the same result type: - * - * val resultOne = registerForResult { ... } - * val resultTwo = registerForResult { ... } - * - * // ... - * resultTwo.open(SomeNavigationKey( ... )) - * - * - * It's important in this case that resultTwo can be identified as the channel to deliver the - * result into, and this identification needs to be stable across application process death. - * The simple solution would be to require users to provide a name for the channel: - * - * val resultTwo = registerForResult("resultTwo") { ... } - * - * - * but using the anonymous class name is a nicer way to do things for now, with the ability to - * fall back to explicit identification of the channels in the case that the Kotlin/JVM behaviour - * changes in the future. - */ - fun Any.createResultId(): String { - return this::class.java.name - } - - // It is important that these functions are inlined, so that the empty lambda can be used - // as a part of the result id, which is helpful for providing uniqueness related to location in - // the code - @Deprecated("Use the other overload of invoke, as key is provided through the NavigationResultScope and doesn't need to be passed as a parameter") - inline operator fun > invoke( + operator fun > invoke( resultType: KClass, - crossinline onClosed: NavigationResultScope.(Key) -> Unit, - noinline onResult: NavigationResultScope.(Key, Result) -> Unit, - additionalResultId: String = "", - ): UnmanagedNavigationResultChannel { - val internalOnClosed: NavigationResultScope.() -> Unit = { onClosed(key) } - val internalOnResult: NavigationResultScope.(Result) -> Unit = { result -> onResult(key, result) } - val resultId = onResult.createResultId() + "@" + internalOnResult.createResultId().hashCode() - return create( - resultType = resultType, - resultId = resultId, - onClosed = internalOnClosed, - onResult = internalOnResult, - additionalResultId = additionalResultId, - ) - } - - // It is important that these functions are inlined, so that the empty lambda can be used - // as a part of the result id, which is helpful for providing uniqueness related to location in - // the code - inline operator fun > invoke( - resultType: KClass, - crossinline onClosed: NavigationResultScope.() -> Unit, - noinline onResult: NavigationResultScope.(Result) -> Unit, + resultId: String, + onClosed: NavigationResultScope.() -> Unit, + onResult: NavigationResultScope.(Result) -> Unit, additionalResultId: String = "", ): UnmanagedNavigationResultChannel { - val internalOnClosed: NavigationResultScope.() -> Unit = { onClosed(this) } - val internalOnResult: NavigationResultScope.(Result) -> Unit = { result -> onResult(this, result) } - val resultId = onResult.createResultId() + "@" + internalOnResult.createResultId().hashCode() return create( resultType = resultType, resultId = resultId, - onClosed = internalOnClosed, - onResult = internalOnResult, + onClosed = onClosed, + onResult = onResult, additionalResultId = additionalResultId, ) } diff --git a/enro-core/src/main/java/dev/enro/core/result/NavigationResultExtensions.kt b/enro-core/src/main/java/dev/enro/core/result/NavigationResultExtensions.kt index 1cf8385c..088d1392 100644 --- a/enro-core/src/main/java/dev/enro/core/result/NavigationResultExtensions.kt +++ b/enro-core/src/main/java/dev/enro/core/result/NavigationResultExtensions.kt @@ -11,20 +11,9 @@ import androidx.recyclerview.widget.RecyclerView import dev.enro.core.* import dev.enro.core.controller.usecase.createResultChannel import dev.enro.core.result.internal.LazyResultChannelProperty -import dev.enro.viewmodel.getNavigationHandle +import kotlin.properties.PropertyDelegateProvider import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KClass -import dev.enro.core.closeWithResult as nonDeprecatedCloseWithResult - -@Deprecated( - message = "Please use closeWithResult from dev.enro.core", - level = DeprecationLevel.WARNING, - replaceWith = - ReplaceWith("closeWithResult(result)", "dev.enro.core.closeWithResult"), -) -public fun TypedNavigationHandle>.closeWithResult(result: T) { - nonDeprecatedCloseWithResult(result) -} public fun TypedNavigationHandle>.deliverResultFromPush( navigationKey: NavigationKey.SupportsPush.WithResult @@ -50,163 +39,129 @@ public fun TypedNavigationHandle>.deli ) } -@Deprecated("It is no longer required to provide a navigationHandle") +@Suppress("UnusedReceiverParameter") // provided to ensure the method is executed on the correct object public inline fun ViewModel.registerForNavigationResult( - navigationHandle: NavigationHandle, noinline onClosed: NavigationResultScope>.() -> Unit = {}, noinline onResult: NavigationResultScope>.(T) -> Unit -): ReadOnlyProperty>> = - LazyResultChannelProperty( - owner = navigationHandle, - resultType = T::class, +): PropertyDelegateProvider>>> { + return createResultChannelProperty( onClosed = onClosed, - onResult = onResult + onResult = onResult, ) +} -public inline fun ViewModel.registerForNavigationResult( - noinline onClosed: NavigationResultScope>.() -> Unit = {}, - noinline onResult: NavigationResultScope>.(T) -> Unit -): ReadOnlyProperty>> = - LazyResultChannelProperty( - owner = getNavigationHandle(), - resultType = T::class, +@Suppress("UnusedReceiverParameter") // provided to ensure the method is executed on the correct object +public inline fun > ViewModel.registerForNavigationResult( + @Suppress("UNUSED_PARAMETER") // provided to allow better type inference + key: KClass, + noinline onClosed: NavigationResultScope.() -> Unit = {}, + noinline onResult: NavigationResultScope.(T) -> Unit +): PropertyDelegateProvider>> { + return createResultChannelProperty( onClosed = onClosed, onResult = onResult, ) +} -@Deprecated("Please use registerForNavigationResult, as the key is provided through the NavigationResultScope") -public inline fun ViewModel.registerForNavigationResultWithKey( - noinline onClosed: NavigationResultScope>.(NavigationKey.WithResult) -> Unit = {}, - noinline onResult: NavigationResultScope>.(NavigationKey.WithResult, T) -> Unit -): ReadOnlyProperty>> = - LazyResultChannelProperty( - owner = getNavigationHandle(), - resultType = T::class, +@JvmName("registerForNavigationResultDelegated") +public inline fun registerForNavigationResult( + owner: ViewModel, + additionalResultId: String = "", + noinline onClosed: NavigationResultScope>.() -> Unit = {}, + noinline onResult: NavigationResultScope>.(T) -> Unit +): PropertyDelegateProvider>>> { + return createResultChannelProperty( + owner = owner, + additionalResultId = additionalResultId, onClosed = onClosed, - onResult = onResult + onResult = onResult, ) +} -public inline fun > ViewModel.registerForNavigationResult( +@JvmName("registerForNavigationResultDelegated") +public inline fun > registerForNavigationResult( + @Suppress("UNUSED_PARAMETER") // provided to allow better type inference key: KClass, + owner: ViewModel, + additionalResultId: String = "", noinline onClosed: NavigationResultScope.() -> Unit = {}, noinline onResult: NavigationResultScope.(T) -> Unit -): ReadOnlyProperty> = - LazyResultChannelProperty( - owner = getNavigationHandle(), - resultType = T::class, - onClosed = onClosed, - onResult = onResult - ) - -@Deprecated("Please use registerForNavigationResult, as the key is provided through the NavigationResultScope") -public inline fun > ViewModel.registerForNavigationResultWithKey( - key: KClass, - noinline onClosed: NavigationResultScope.(Key) -> Unit = {}, - noinline onResult: NavigationResultScope.(Key, T) -> Unit -): ReadOnlyProperty> = - LazyResultChannelProperty( - owner = getNavigationHandle(), - resultType = T::class, +): PropertyDelegateProvider>> { + return createResultChannelProperty( + owner = owner, + additionalResultId = additionalResultId, onClosed = onClosed, - onResult = onResult + onResult = onResult, ) +} +@Suppress("UnusedReceiverParameter") // provided to ensure the method is executed on the correct object public inline fun ComponentActivity.registerForNavigationResult( noinline onClosed: NavigationResultScope>.() -> Unit = {}, noinline onResult: NavigationResultScope>.(T) -> Unit -): ReadOnlyProperty>> = - LazyResultChannelProperty( - owner = this, - resultType = T::class, +): PropertyDelegateProvider>>> { + return createResultChannelProperty( onClosed = onClosed, - onResult = onResult - ) - -@Deprecated("Please use registerForNavigationResult, as the key is provided through the NavigationResultScope") -public inline fun ComponentActivity.registerForNavigationResultWithKey( - noinline onClosed: NavigationResultScope>.(NavigationKey.WithResult) -> Unit = {}, - noinline onResult: NavigationResultScope>.(NavigationKey.WithResult, T) -> Unit -): ReadOnlyProperty>> = - LazyResultChannelProperty( - owner = this, - resultType = T::class, - onClosed = onClosed, - onResult = onResult + onResult = onResult, ) +} - +@Suppress("UnusedReceiverParameter") // provided to ensure the method is executed on the correct object public inline fun > ComponentActivity.registerForNavigationResult( + @Suppress("UNUSED_PARAMETER") // provided to allow better type inference key: KClass, noinline onClosed: NavigationResultScope.() -> Unit = {}, noinline onResult: NavigationResultScope.(T) -> Unit -): ReadOnlyProperty> = - LazyResultChannelProperty( - owner = this, - resultType = T::class, +): PropertyDelegateProvider>> { + return createResultChannelProperty( onClosed = onClosed, - onResult = onResult - ) - -@Deprecated("Please use registerForNavigationResult, as the key is provided through the NavigationResultScope") -public inline fun > ComponentActivity.registerForNavigationResultWithKey( - key: KClass, - noinline onClosed: NavigationResultScope.(Key) -> Unit = {}, - noinline onResult: NavigationResultScope.(Key, T) -> Unit -): ReadOnlyProperty> = - LazyResultChannelProperty( - owner = this, - resultType = T::class, - onClosed = onClosed, - onResult = onResult + onResult = onResult, ) +} +@Suppress("UnusedReceiverParameter") // provided to ensure the method is executed on the correct object public inline fun Fragment.registerForNavigationResult( noinline onClosed: NavigationResultScope>.() -> Unit = {}, noinline onResult: NavigationResultScope>.(T) -> Unit -): ReadOnlyProperty>> = - LazyResultChannelProperty( - owner = this, - resultType = T::class, - onClosed = onClosed, - onResult = onResult - ) - -@Deprecated("Please use registerForNavigationResult, as the key is provided through the NavigationResultScope") -public inline fun Fragment.registerForNavigationResultWithKey( - noinline onClosed: NavigationResultScope>.(NavigationKey.WithResult) -> Unit = {}, - noinline onResult: NavigationResultScope>.(NavigationKey.WithResult, T) -> Unit -): ReadOnlyProperty>> = - LazyResultChannelProperty( - owner = this, - resultType = T::class, +): PropertyDelegateProvider>>> { + return createResultChannelProperty( onClosed = onClosed, - onResult = onResult + onResult = onResult, ) +} +@Suppress("UnusedReceiverParameter") // provided to ensure the method is executed on the correct object public inline fun > Fragment.registerForNavigationResult( + @Suppress("UNUSED_PARAMETER") // provided to allow better type inference key: KClass, noinline onClosed: NavigationResultScope.() -> Unit = {}, noinline onResult: NavigationResultScope.(T) -> Unit -): ReadOnlyProperty> = - LazyResultChannelProperty( - owner = this, - resultType = T::class, +): PropertyDelegateProvider>> { + return createResultChannelProperty( onClosed = onClosed, - onResult = onResult + onResult = onResult, ) +} -@Deprecated("Please use registerForNavigationResult, as the key is provided through the NavigationResultScope") -public inline fun > Fragment.registerForNavigationResultWithKey( - key: KClass, - noinline onClosed: NavigationResultScope.(Key) -> Unit = {}, - noinline onResult: NavigationResultScope.(Key, T) -> Unit -): ReadOnlyProperty> = - LazyResultChannelProperty( - owner = this, - resultType = T::class, - onClosed = onClosed, - onResult = onResult - ) +@PublishedApi +internal inline fun > createResultChannelProperty( + owner: Any? = null, + additionalResultId: String = "", + noinline onClosed: NavigationResultScope.() -> Unit, + noinline onResult: NavigationResultScope.(Result) -> Unit, +): PropertyDelegateProvider>> { + return PropertyDelegateProvider { thisRef, property -> + val resultId = "${thisRef::class.java.name}.${property.name}" + LazyResultChannelProperty( + owner = owner ?: thisRef, + resultType = Result::class, + resultId = resultId, + onClosed = onClosed, + onResult = onResult, + additionalResultId = additionalResultId, + ) + } +} /** * Register for an UnmanagedEnroResultChannel. @@ -225,33 +180,9 @@ public inline fun NavigationHandle.registerForNavigationResult ): UnmanagedNavigationResultChannel> { return createResultChannel( resultType = T::class, + resultId = id, onClosed = onClosed, onResult = onResult, - additionalResultId = id - ) -} - -/** - * Register for an UnmanagedEnroResultChannel. - * - * Be aware that you need to manage the attach/detach/destroy lifecycle events of this result channel - * yourself, including the initial attach. - * - * @see UnmanagedNavigationResultChannel - * @see managedByLifecycle - * @see managedByView - */ -@Deprecated("Please use registerForNavigationResult, as the key is provided through the NavigationResultScope") -public inline fun NavigationHandle.registerForNavigationResultWithKey( - id: String, - noinline onClosed: NavigationResultScope>.(NavigationKey.WithResult) -> Unit = {}, - noinline onResult: NavigationResultScope>.(NavigationKey.WithResult, T) -> Unit -): UnmanagedNavigationResultChannel> { - return createResultChannel( - resultType = T::class, - onClosed = onClosed, - onResult = onResult, - additionalResultId = id ) } @@ -273,34 +204,9 @@ public inline fun > Navigatio ): UnmanagedNavigationResultChannel { return createResultChannel( resultType = T::class, + resultId = id, onClosed = onClosed, onResult = onResult, - additionalResultId = id - ) -} - -/** - * Register for an UnmanagedEnroResultChannel. - * - * Be aware that you need to manage the attach/detach/destroy lifecycle events of this result channel - * yourself, including the initial attach. - * - * @see UnmanagedNavigationResultChannel - * @see managedByLifecycle - * @see managedByView - */ -@Deprecated("Please use registerForNavigationResult, as the key is provided through the NavigationResultScope") -public inline fun > NavigationHandle.registerForNavigationResultWithKey( - id: String, - key: KClass, - noinline onClosed: NavigationResultScope.(Key) -> Unit = {}, - noinline onResult: NavigationResultScope.(Key, T) -> Unit -): UnmanagedNavigationResultChannel { - return createResultChannel( - resultType = T::class, - onClosed = onClosed, - onResult = onResult, - additionalResultId = id ) } diff --git a/enro-core/src/main/java/dev/enro/core/result/flows/FlowResultChannel.kt b/enro-core/src/main/java/dev/enro/core/result/flows/FlowResultChannel.kt index 472898c3..7cdad907 100644 --- a/enro-core/src/main/java/dev/enro/core/result/flows/FlowResultChannel.kt +++ b/enro-core/src/main/java/dev/enro/core/result/flows/FlowResultChannel.kt @@ -219,7 +219,7 @@ public fun ViewModel.registerForFlowResult( registerForNavigationResult( onClosed = onClosed, onResult = onResult, - ).getValue(thisRef, property) + ).provideDelegate(thisRef, property).getValue(thisRef, property) }, flow = flow, onCompleted = onCompleted, diff --git a/enro-core/src/main/java/dev/enro/core/result/internal/LazyResultChannelProperty.kt b/enro-core/src/main/java/dev/enro/core/result/internal/LazyResultChannelProperty.kt index dc1dabe8..c931ff65 100644 --- a/enro-core/src/main/java/dev/enro/core/result/internal/LazyResultChannelProperty.kt +++ b/enro-core/src/main/java/dev/enro/core/result/internal/LazyResultChannelProperty.kt @@ -5,6 +5,7 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel import dev.enro.core.EnroException import dev.enro.core.NavigationHandle import dev.enro.core.NavigationKey @@ -13,44 +14,20 @@ import dev.enro.core.getNavigationHandle import dev.enro.core.result.NavigationResultChannel import dev.enro.core.result.NavigationResultScope import dev.enro.core.result.managedByLifecycle +import dev.enro.viewmodel.getNavigationHandle import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KClass import kotlin.reflect.KProperty @PublishedApi -internal class LazyResultChannelProperty> private constructor( +internal class LazyResultChannelProperty>( owner: Any, + resultId: String, resultType: KClass, - params: LambdaParams -) : ReadOnlyProperty> { - - constructor( - owner: Any, - resultType: KClass, - onClosed: NavigationResultScope.(Key) -> Unit = {}, - onResult: NavigationResultScope.(Key, Result) -> Unit - ) : this( - owner = owner, - resultType = resultType, - params = LambdaParams.WithKey( - onClosed = onClosed, - onResult = onResult, - ) - ) - - constructor( - owner: Any, - resultType: KClass, - onClosed: NavigationResultScope.() -> Unit = {}, - onResult: NavigationResultScope.(Result) -> Unit - ) : this( - owner = owner, - resultType = resultType, - params = LambdaParams.WithoutKey( - onClosed = onClosed, - onResult = onResult, - ) - ) + onClosed: NavigationResultScope.() -> Unit = {}, + onResult: NavigationResultScope.(Result) -> Unit, + additionalResultId: String = "", +) : ReadOnlyProperty> { private var resultChannel: NavigationResultChannel? = null @@ -59,49 +36,34 @@ internal class LazyResultChannelProperty lazy { owner.getNavigationHandle() } is Fragment -> lazy { owner.getNavigationHandle() } is NavigationHandle -> lazy { owner as NavigationHandle } + is ViewModel -> lazy { owner.getNavigationHandle() } else -> throw EnroException.UnreachableState() } - val lifecycleOwner = owner as LifecycleOwner + val lifecycleOwner: LifecycleOwner = when (owner) { + is LifecycleOwner -> owner + is ViewModel -> owner.getNavigationHandle() + else -> error("Can't find LifecycleOwner from $owner") + } val lifecycle = lifecycleOwner.lifecycle lifecycle.addObserver(object : LifecycleEventObserver { override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { if (event != Lifecycle.Event.ON_CREATE) return; - resultChannel = when (params) { - is LambdaParams.WithKey -> handle.value.createResultChannel( - resultType = resultType, - onClosed = params.onClosed, - onResult = params.onResult, - ) - - is LambdaParams.WithoutKey -> handle.value.createResultChannel( - resultType = resultType, - onClosed = params.onClosed, - onResult = params.onResult, - ) - }.managedByLifecycle(lifecycle) - + resultChannel = handle.value.createResultChannel( + resultType = resultType, + resultId = resultId, + onClosed = onClosed, + onResult = onResult, + additionalResultId = additionalResultId, + ).managedByLifecycle(lifecycle) } }) } override fun getValue( - thisRef: Any, + thisRef: Owner, property: KProperty<*> ): NavigationResultChannel = resultChannel ?: throw EnroException.ResultChannelIsNotInitialised( "LazyResultChannelProperty's EnroResultChannel is not initialised. Are you attempting to use the result channel before the result channel's lifecycle owner has entered the CREATED state?" ) - - - private sealed class LambdaParams> { - class WithKey>( - val onClosed: NavigationResultScope.(Key) -> Unit, - val onResult: NavigationResultScope.(Key, Result) -> Unit, - ) : LambdaParams() - - class WithoutKey>( - val onClosed: NavigationResultScope.() -> Unit, - val onResult: NavigationResultScope.(Result) -> Unit, - ) : LambdaParams() - } } diff --git a/enro-core/src/main/java/dev/enro/destination/compose/ComposableNavigationResult.kt b/enro-core/src/main/java/dev/enro/destination/compose/ComposableNavigationResult.kt index 724c278e..eb90137f 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/ComposableNavigationResult.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/ComposableNavigationResult.kt @@ -30,10 +30,13 @@ public inline fun registerForNavigationResult( noinline onResult: @DisallowComposableCalls NavigationResultScope>.(T) -> Unit ): NavigationResultChannel> { val navigationHandle = navigationHandle() - + val internalId = rememberSaveable { + UUID.randomUUID().toString() + } val resultChannel = remember(onResult) { navigationHandle.createResultChannel( resultType = T::class, + resultId = internalId, onClosed = onClosed, onResult = onResult, additionalResultId = id From 954bb7962e4c7c59bd439bce48dd198d8d278bd7 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 12 Sep 2024 21:44:13 +1200 Subject: [PATCH 16/51] Updated library versions --- libs.versions.toml | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/libs.versions.toml b/libs.versions.toml index 712ed7a5..c15bbf51 100644 --- a/libs.versions.toml +++ b/libs.versions.toml @@ -7,34 +7,34 @@ emulator-wtf-gradle = "wtf.emulator:gradle-plugin:0.0.11" androidx-core = "androidx.core:core-ktx:1.13.1" androidx-appcompat = "androidx.appcompat:appcompat:1.7.0" androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4" -androidx-fragment = "androidx.fragment:fragment-ktx:1.8.2" +androidx-fragment = "androidx.fragment:fragment-ktx:1.8.3" androidx-recyclerview = "androidx.recyclerview:recyclerview:1.3.2" androidx-splashscreen = "androidx.core:core-splashscreen:1.0.1" # Used for interoperability testing in the main Enro test suite -androidx-navigation-fragment = "androidx.navigation:navigation-fragment-ktx:2.7.7" -androidx-navigation-ui = "androidx.navigation:navigation-ui-ktx:2.7.7" +androidx-navigation-fragment = "androidx.navigation:navigation-fragment-ktx:2.8.0" +androidx-navigation-ui = "androidx.navigation:navigation-ui-ktx:2.8.0" -androidx-activity = "androidx.activity:activity-ktx:1.9.1" -compose-activity = "androidx.activity:activity-compose:1.9.1" +androidx-activity = "androidx.activity:activity-ktx:1.9.2" +compose-activity = "androidx.activity:activity-compose:1.9.2" -androidx-lifecycle = "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4" -androidx-lifecycle-process = "androidx.lifecycle:lifecycle-process:2.8.4" -compose-viewmodel = "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4" +androidx-lifecycle = "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.5" +androidx-lifecycle-process = "androidx.lifecycle:lifecycle-process:2.8.5" +compose-viewmodel = "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.5" compose-compiler = "androidx.compose.compiler:compiler:1.5.15" -compose-foundation = "androidx.compose.foundation:foundation:1.7.0-beta07" -compose-foundationLayout = "androidx.compose.foundation:foundation-layout:1.7.0-beta07" -compose-ui = "androidx.compose.ui:ui:1.7.0-beta07" -compose-uiTooling = "androidx.compose.ui:ui-tooling:1.7.0-beta07" -compose-runtime = "androidx.compose.runtime:runtime:1.7.0-beta07" -compose-livedata = "androidx.compose.runtime:runtime-livedata:1.7.0-beta07" -compose-material = "androidx.compose.material:material:1.7.0-beta07" +compose-foundation = "androidx.compose.foundation:foundation:1.7.1" +compose-foundationLayout = "androidx.compose.foundation:foundation-layout:1.7.1" +compose-ui = "androidx.compose.ui:ui:1.7.1" +compose-uiTooling = "androidx.compose.ui:ui-tooling:1.7.1" +compose-runtime = "androidx.compose.runtime:runtime:1.7.1" +compose-livedata = "androidx.compose.runtime:runtime-livedata:1.7.1" +compose-material = "androidx.compose.material:material:1.7.1" compose-accompanist-systemUiController = "com.google.accompanist:accompanist-systemuicontroller:0.27.0" -compose-materialIcons = "androidx.compose.material:material-icons-core:1.6.8" -compose-materialIconsExtended = "androidx.compose.material:material-icons-extended:1.6.8" +compose-materialIcons = "androidx.compose.material:material-icons-core:1.7.1" +compose-materialIconsExtended = "androidx.compose.material:material-icons-extended:1.7.1" kotlin-gradle = "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10" kotlin-stdLib = "org.jetbrains.kotlin:kotlin-stdlib:1.9.22" @@ -53,13 +53,13 @@ testing-robolectric = "org.robolectric:robolectric:4.8.1" testing-androidx-junit = "androidx.test.ext:junit:1.2.1" testing-androidx-core = "androidx.test:core:1.6.1" -testing-androidx-runner = "androidx.test:runner:1.6.1" +testing-androidx-runner = "androidx.test:runner:1.6.2" testing-androidx-orchestrator = "androidx.test:orchestrator:1.5.0" testing-androidx-espresso = "androidx.test.espresso:espresso-core:3.6.1" testing-androidx-espressoRecyclerView = "androidx.test.espresso:espresso-contrib:3.6.1" testing-androidx-espressoIntents = "androidx.test.espresso:espresso-intents:3.6.1" -testing-androidx-fragment = "androidx.fragment:fragment-testing:1.8.2" -testing-androidx-compose = "androidx.compose.ui:ui-test-junit4:1.6.8" +testing-androidx-fragment = "androidx.fragment:fragment-testing:1.8.3" +testing-androidx-compose = "androidx.compose.ui:ui-test-junit4:1.7.1" testing-leakcanary-instrumentation = "com.squareup.leakcanary:leakcanary-android-instrumentation:2.10" @@ -76,5 +76,5 @@ processing-kotlinPoet-ksp = "com.squareup:kotlinpoet-ksp:1.13.2" material = "com.google.android.material:material:1.12.0" -lint-checks = "com.android.tools.lint:lint-checks:31.5.2" -lint-api = "com.android.tools.lint:lint-api:31.5.2" \ No newline at end of file +lint-checks = "com.android.tools.lint:lint-checks:31.6.0" +lint-api = "com.android.tools.lint:lint-api:31.6.0" From 810c9bf8c6c46c3c412ec48a75f33a3b23f83e71 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 12 Sep 2024 21:51:35 +1200 Subject: [PATCH 17/51] Fixed issues with compilation in the core Enro tests --- enro/build.gradle.kts | 1 + .../androidTest/java/dev/enro/core/UnboundActivitiesTest.kt | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/enro/build.gradle.kts b/enro/build.gradle.kts index 845aab54..69c174fb 100644 --- a/enro/build.gradle.kts +++ b/enro/build.gradle.kts @@ -65,6 +65,7 @@ dependencies { androidTestImplementation(libs.testing.androidx.runner) androidTestImplementation(libs.testing.androidx.compose) + androidTestImplementation(libs.compose.materialIcons) androidTestImplementation(libs.androidx.navigation.fragment) androidTestImplementation(libs.androidx.navigation.ui) diff --git a/enro/src/androidTest/java/dev/enro/core/UnboundActivitiesTest.kt b/enro/src/androidTest/java/dev/enro/core/UnboundActivitiesTest.kt index 5ece853d..ff9b476e 100644 --- a/enro/src/androidTest/java/dev/enro/core/UnboundActivitiesTest.kt +++ b/enro/src/androidTest/java/dev/enro/core/UnboundActivitiesTest.kt @@ -118,7 +118,9 @@ class UnboundActivitiesTest { .apply { // This test specifically requires EnroBackConfiguration.Default @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") - backConfiguration = EnroBackConfiguration.Default + setConfig ( + config.copy(backConfiguration = EnroBackConfiguration.Default) + ) } navigationController.addOverride(override) From 8b8b5bf51a15b031bf72cb5529ec299a1c87fe01 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 12 Sep 2024 23:09:33 +1200 Subject: [PATCH 18/51] Reverted some animation changes for config.isAnimationsDisabled, as this was causing issues with tests not recomposing correctly --- .../enro/animation/NavigationAnimations.kt | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/enro-core/src/main/java/dev/enro/animation/NavigationAnimations.kt b/enro-core/src/main/java/dev/enro/animation/NavigationAnimations.kt index d5a1a786..ddabff26 100644 --- a/enro-core/src/main/java/dev/enro/animation/NavigationAnimations.kt +++ b/enro-core/src/main/java/dev/enro/animation/NavigationAnimations.kt @@ -8,7 +8,6 @@ import androidx.compose.animation.EnterExitState import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.Transition -import androidx.compose.animation.core.updateTransition import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @@ -96,28 +95,22 @@ public sealed interface NavigationAnimation { navigationApplication?.navigationController?.config ?: EnroConfig() } - if (config.isAnimationsDisabled) { - val transition = updateTransition(EnterExitState.Visible, "NavigationAnimation.Composable") - content(transition) - } - else { - val resourceAnimation = remember(this, forView) { forView.asResource(context.theme) } - visible.AnimatedVisibility( - visible = { it }, - enter = enter, - exit = exit, - ) { - if (config.enableViewAnimationsForCompose) { - transition.ResourceAnimatedVisibility( - visible = { it == EnterExitState.Visible }, - enter = resourceAnimation.id, - exit = resourceAnimation.id, - ) { - content(transition) - } - } else { + val resourceAnimation = remember(this, forView) { forView.asResource(context.theme) } + visible.AnimatedVisibility( + visible = { it }, + enter = enter, + exit = exit, + ) { + if (config.enableViewAnimationsForCompose) { + transition.ResourceAnimatedVisibility( + visible = { it == EnterExitState.Visible }, + enter = resourceAnimation.id, + exit = resourceAnimation.id, + ) { content(transition) } + } else { + content(transition) } } } @@ -129,9 +122,11 @@ public sealed interface NavigationAnimation { is Attr -> Resource( theme.getAttributeResourceId(attr), ) + is Theme -> Resource( id(theme), ) + is Composable -> forView.asResource(theme) } @@ -199,6 +194,7 @@ public object DefaultAnimations { NavigationDirection.Present -> ForView.presentCloseEnter else -> ForView.pushCloseEnter } + NavigationDirection.Push, NavigationDirection.Forward -> ForView.pushCloseEnter else -> ForView.presentCloseEnter } From 6ff50c99fec71c555793001bdaaf289376537fa1 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 12 Sep 2024 23:26:17 +1200 Subject: [PATCH 19/51] Updated version.properties --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index a235157f..ac86bb74 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -versionName=2.8.0-alpha01 -versionCode=104 \ No newline at end of file +versionName=2.8.0-alpha02 +versionCode=105 \ No newline at end of file From 9a2a30c1b110c6681d06e5b7a542bd99ee51702d Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 12 Sep 2024 23:44:43 +1200 Subject: [PATCH 20/51] Fix result channel tests --- .../dev/enro/test/CreateResultChannelTest.kt | 174 +++--------------- 1 file changed, 21 insertions(+), 153 deletions(-) diff --git a/enro/src/test/java/dev/enro/test/CreateResultChannelTest.kt b/enro/src/test/java/dev/enro/test/CreateResultChannelTest.kt index 7ad52dcb..0eabd73f 100644 --- a/enro/src/test/java/dev/enro/test/CreateResultChannelTest.kt +++ b/enro/src/test/java/dev/enro/test/CreateResultChannelTest.kt @@ -12,11 +12,9 @@ import dev.enro.core.result.NavigationResultScope import dev.enro.core.result.internal.ResultChannelId import dev.enro.core.result.internal.ResultChannelImpl import dev.enro.core.result.registerForNavigationResult -import dev.enro.core.result.registerForNavigationResultWithKey import dev.enro.test.extensions.putNavigationHandleForViewModel import dev.enro.viewmodel.withNavigationHandle import kotlinx.parcelize.Parcelize -import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Rule import org.junit.Test @@ -24,168 +22,38 @@ import org.junit.Test @Parcelize private class ResultChannelTestKey : NavigationKey.SupportsPresent -/** - * CreateResultChannel has two different methods which allow the creation of a result channel. - * One method allows the onResult/onClosed lambdas to accept a NavigationKey, and one method - * hides the NavigationKey from the lambda. This is the basis of the `key vs. no key` distinction - * in these tests, as channels created through either method should be uniquely identifiable. - */ + class CreateResultChannelTest { @Rule @JvmField val enroTestRule = EnroTestRule() @Test - fun resultChannelsAreUniquelyIdentifiable_keyVsNoKey() { - val createResultChannel = createTestNavigationHandle(ResultChannelTestKey()) - .dependencyScope - .get() - - val channelOne = createResultChannel.invoke( - resultType = String::class, - onClosed = { }, - onResult = { _ -> }, - additionalResultId = "", - ) as ResultChannelImpl - - val channelTwo = createResultChannel.invoke( - resultType = String::class, - onClosed = { _ -> }, - onResult = { _, _ -> }, - additionalResultId = "", - ) as ResultChannelImpl - - assertNotEquals(channelOne.id, channelTwo.id) - } - - @Test - fun resultChannelsAreUniquelyIdentifiable_keyVsKey() { - val createResultChannel = createTestNavigationHandle(ResultChannelTestKey()) - .dependencyScope - .get() - - val channelOne = createResultChannel.invoke( - resultType = String::class, - onClosed = { _ -> }, - onResult = { _, _ -> }, - additionalResultId = "", - ) as ResultChannelImpl - - val channelTwo = createResultChannel.invoke( - resultType = String::class, - onClosed = { _ -> }, - onResult = { _, _ -> }, - additionalResultId = "", - ) as ResultChannelImpl - - assertNotEquals(channelOne.id, channelTwo.id) - } - - @Test - fun resultChannelsAreUniquelyIdentifiable_noKeyVsNoKey() { - val createResultChannel = createTestNavigationHandle(ResultChannelTestKey()) - .dependencyScope - .get() - - val channelOne = createResultChannel.invoke( - resultType = String::class, - onClosed = { }, - onResult = { _ -> }, - additionalResultId = "", - ) as ResultChannelImpl - - val channelTwo = createResultChannel.invoke( - resultType = String::class, - onClosed = { }, - onResult = { _ -> }, - additionalResultId = "", - ) as ResultChannelImpl - - assertNotEquals(channelOne.id, channelTwo.id) - } - - @Test - fun resultChannelsAreUniquelyIdentifiable_keyWithRepeatedLambda() { - val createResultChannel = createTestNavigationHandle(ResultChannelTestKey()) - .dependencyScope - .get() - - val result: NavigationResultScope>.(NavigationKey, String) -> Unit = { _, _ -> } - val channelOne = createResultChannel.invoke( - resultType = String::class, - onClosed = { }, - onResult = result, - additionalResultId = "", - ) as ResultChannelImpl - - val channelTwo = createResultChannel.invoke( - resultType = String::class, - onClosed = { }, - onResult = result, - additionalResultId = "", - ) as ResultChannelImpl - - assertNotEquals(channelOne.id, channelTwo.id) - } - - @Test - fun resultChannelsAreUniquelyIdentifiable_noKeyWithRepeatedLambda() { - val createResultChannel = createTestNavigationHandle(ResultChannelTestKey()) - .dependencyScope - .get() - - val result: NavigationResultScope>.(String) -> Unit = {} - val channelOne = createResultChannel.invoke( - resultType = String::class, - onClosed = { }, - onResult = result, - additionalResultId = "", - ) as ResultChannelImpl - - val channelTwo = createResultChannel.invoke( - resultType = String::class, - onClosed = { }, - onResult = result, - additionalResultId = "", - ) as ResultChannelImpl - - assertNotEquals(channelOne.id, channelTwo.id) - } - - @Test - fun resultChannelsAreNotUniquelyIdentifiableInForLoop() { - val createResultChannel = createTestNavigationHandle(ResultChannelTestKey()) - .dependencyScope - .get() - - val result: NavigationResultScope>.(String) -> Unit = {} - val channels = (0..2).map { - createResultChannel.invoke( - resultType = String::class, - onClosed = { }, - onResult = result, - additionalResultId = "", - ) as ResultChannelImpl + fun resultChannelsAreUniquelyIdentifiableWithinViewModel() { + class ExampleOne : ViewModel() { + val channelOne by registerForNavigationResult { } + val channelTwo by registerForNavigationResult { } } - assertEquals(channels[0].id, channels[1].id) + putNavigationHandleForViewModel(ResultChannelTestKey()) + val viewModel = ExampleOne() + assertNotEquals(viewModel.channelOne.internalId, viewModel.channelTwo.internalId) } @Test - fun resultChannelsAreUniquelyIdentifiableInForLoop_providedAdditionalId() { + fun resultChannelsAreUniquelyIdentifiableWithinViewModel_keyWithRepeatedLambda() { val createResultChannel = createTestNavigationHandle(ResultChannelTestKey()) .dependencyScope .get() - val result: NavigationResultScope>.(String) -> Unit = {} - val channels = (0..2).map { - createResultChannel.invoke( - resultType = String::class, - onClosed = { }, - onResult = result, - additionalResultId = "loop@$it", - ) as ResultChannelImpl + val result: NavigationResultScope>.(String) -> Unit = { } + + class ExampleOne : ViewModel() { + val channelOne by registerForNavigationResult(onResult = result) + val channelTwo by registerForNavigationResult(onResult = result) } - assertNotEquals(channels[0].id, channels[1].id) + putNavigationHandleForViewModel(ResultChannelTestKey()) + val viewModel = ExampleOne() + assertNotEquals(viewModel.channelOne.internalId, viewModel.channelTwo.internalId) } @Test @@ -255,19 +123,19 @@ class TestResultIdsWithKeyViewModel : ViewModel() { var intOneResult: Int? = null var intTwoResult: Int? = null - val stringOne by registerForNavigationResultWithKey { _, it -> + val stringOne by registerForNavigationResult { stringOneResult = it } - val stringTwo by registerForNavigationResultWithKey { _, it -> + val stringTwo by registerForNavigationResult { stringTwoResult = it } - val intOne by registerForNavigationResultWithKey { _, it -> + val intOne by registerForNavigationResult { intOneResult = it } - val intTwo by registerForNavigationResultWithKey { _, it -> + val intTwo by registerForNavigationResult { intTwoResult = it } } From 5c4a97006128e90a82ca5c4b63379504ce0b6154 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 12 Sep 2024 23:55:58 +1200 Subject: [PATCH 21/51] Update CHANGELOG.md --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1b8fd0..5fa29003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,16 @@ # Changelog ## 2.8.0 -* Updated Compose to 1.7.0 +* Updated Compose to 1.7.1 * Added support for NavigationKey.WithExtras to `NavigationResultChannel` and `NavigationFlowScope` * Updated `enro-test` methods to provide more descriptive error messages when assert/expect methods fail, and added kdoc comments to many of the functions * Updated Composable navigation animations to use SeekableTransitionState, as a step towards supporting predictive back navigation animations * Fixed a bug where managed flows (`registerForFlowResult`) that launch embedded flows (`deliverResultFromPush/Present`) were not correctly handling the result of the embedded flow * Added `FragmentSharedElements` to provide a way to define shared elements for Fragment navigation, including a compatibility layer for Composable NavigationDestinations that want to use AndroidViews as shared elements with Fragments. See `FragmentsWithSharedElements.kt` in the test application for examples of how to use `FragmentSharedElements` +* ⚠️ Updated result channel identifiers in preparation for Kotlin 2.0 ⚠️ + * Kotlin 2.0 changes the way that lambdas are compiled, which has implications for `registerForNavigationResult` and how result channels are uniquely identified. Activites, Fragments, Composables and ViewModels that use `by registerForNavigationResult` directly will not be affected by this change. However, if you are creating result channels inside of other objects, such as delegates, helper objects, or extension functions, you should verify that these cases continue to work as expected. It is not expected that there will be issues, but if this does result in bugs in your application, please raise them on the Enro GitHub repository. + ## 2.7.0 * ⚠️ Updated to androidx.lifecycle 2.8.1 ⚠️ * There are breaking changes introduced in androidx.lifecycle 2.8.0; if you use Enro 2.7.0, you must upgrade your project to androidx.lifecycle 2.8+, otherwise you are likely to encounter runtime errors From 0ff64ba82efb2086a14270d1a4fc880ecd5fdf83 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Fri, 13 Sep 2024 00:28:00 +1200 Subject: [PATCH 22/51] Updated NavigationContainer handling to not always accept presented instructions --- CHANGELOG.md | 5 ++++ .../src/main/java/dev/enro/core/EnroConfig.kt | 9 +++++- .../core/container/NavigationContainer.kt | 5 +++- .../container/NavigationInstructionFilter.kt | 30 +++++++++++++++++-- .../core/controller/NavigationController.kt | 2 +- .../controller/createNavigationController.kt | 15 +++++++++- 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa29003..12fa5bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ * ⚠️ Updated result channel identifiers in preparation for Kotlin 2.0 ⚠️ * Kotlin 2.0 changes the way that lambdas are compiled, which has implications for `registerForNavigationResult` and how result channels are uniquely identified. Activites, Fragments, Composables and ViewModels that use `by registerForNavigationResult` directly will not be affected by this change. However, if you are creating result channels inside of other objects, such as delegates, helper objects, or extension functions, you should verify that these cases continue to work as expected. It is not expected that there will be issues, but if this does result in bugs in your application, please raise them on the Enro GitHub repository. +* ⚠️ Updated NavigationContainer handling of NavigationInstructionFilter ⚠️ + * In versions of Enro before 2.8.0, NavigationContainers would always accept destinations that were presented (`NavigationInstruction.Present(...)`, `navigationHandle.present(...)`, etc), and would only enforce their instructionFilter for pushed instructions (`NavigationInstruction.Push(...)`, `navigationHandle.push(...)`, etc). This is no longer the default behavior, and NavigationContainers will apply their instructionFilter to all instructions. + * This behavior can be reverted to the previous behavior by setting `useLegacyContainerPresentBehavior` when creating a NavigationController for your application using `createNavigationController`. + * `useLegacyContainerPresentBehavior` will be removed in a future version of Enro, and it is recommended that you update your NavigationContainers to explicitly declare their instructionFilter for all instructions, not just pushed instructions. + ## 2.7.0 * ⚠️ Updated to androidx.lifecycle 2.8.1 ⚠️ * There are breaking changes introduced in androidx.lifecycle 2.8.0; if you use Enro 2.7.0, you must upgrade your project to androidx.lifecycle 2.8+, otherwise you are likely to encounter runtime errors diff --git a/enro-core/src/main/java/dev/enro/core/EnroConfig.kt b/enro-core/src/main/java/dev/enro/core/EnroConfig.kt index a014d6ab..fffb1f29 100644 --- a/enro-core/src/main/java/dev/enro/core/EnroConfig.kt +++ b/enro-core/src/main/java/dev/enro/core/EnroConfig.kt @@ -3,9 +3,16 @@ package dev.enro.core import dev.enro.core.controller.EnroBackConfiguration public data class EnroConfig( - val isInTest: Boolean = false, + internal val isInTest: Boolean = false, internal val isAnimationsDisabled: Boolean = false, internal val isStrictMode: Boolean = false, + /** + * In versions of Enro before 2.8.0, NavigationContainers would always accept destinations that were presented, and + * would only enforce their navigation instruction filter for pushed instructions. This is no longer the default + * behavior, but can be re-enabled by setting this Boolean to true. + */ + @Deprecated("This behavior is no longer recommended, and will be removed in a future version of Enro. Please update your NavigationContainers to use a NavigationInstructionFilter that explicitly declares all instructions that are valid for the container.") + internal val useLegacyContainerPresentBehavior: Boolean = false, internal val backConfiguration: EnroBackConfiguration = EnroBackConfiguration.Default, /** * This Boolean sets whether or not Composables will attempt to fallback to View based animations (Animation or Animator) diff --git a/enro-core/src/main/java/dev/enro/core/container/NavigationContainer.kt b/enro-core/src/main/java/dev/enro/core/container/NavigationContainer.kt index b142bd5d..f00bf338 100644 --- a/enro-core/src/main/java/dev/enro/core/container/NavigationContainer.kt +++ b/enro-core/src/main/java/dev/enro/core/container/NavigationContainer.kt @@ -172,7 +172,10 @@ public abstract class NavigationContainer( public fun accept( instruction: AnyOpenInstruction ): Boolean { - return (instructionFilter.accept(instruction) || instruction.navigationDirection == NavigationDirection.Present) + val isPresentedWithLegacyBehavior = context.controller.config.useLegacyContainerPresentBehavior + && instruction.navigationDirection == NavigationDirection.Present + + return (instructionFilter.accept(instruction) || isPresentedWithLegacyBehavior) && acceptedByContext(instruction) && canInstructionBeHostedAs( hostType = contextType, diff --git a/enro-core/src/main/java/dev/enro/core/container/NavigationInstructionFilter.kt b/enro-core/src/main/java/dev/enro/core/container/NavigationInstructionFilter.kt index 45fe96bb..92e8915d 100644 --- a/enro-core/src/main/java/dev/enro/core/container/NavigationInstructionFilter.kt +++ b/enro-core/src/main/java/dev/enro/core/container/NavigationInstructionFilter.kt @@ -1,8 +1,8 @@ package dev.enro.core.container +import dev.enro.core.NavigationDirection import dev.enro.core.NavigationInstruction import dev.enro.core.NavigationKey -import kotlin.reflect.KClass /** * A NavigationContainerFilter is used to determine whether or not a given [NavigationInstruction.Open] @@ -18,25 +18,51 @@ public class NavigationInstructionFilter internal constructor( public class NavigationContainerFilterBuilder internal constructor() { private val filters: MutableList = mutableListOf() + /** + * Matches any instructions that have a NavigationKey that returns true for the provided predicate + */ public fun key(predicate: (NavigationKey) -> Boolean) { filters.add(NavigationInstructionFilter { predicate(it.navigationKey) }) } + /** + * Matches any instructions that have a NavigationKey that is equal to the provided key + */ public fun key(key: NavigationKey) { key { it == key } } + /** + * Matches any instructions that match the provided predicate + */ + @JvmName("keyWithType") public inline fun key( - type: KClass = T::class, // can be ignored, required to disambiguate between the two key functions for the JVM crossinline predicate: (T) -> Boolean = { true } ) { key { it is T && predicate(it) } } + /** + * Matches any instructions that match the provided predicate + */ public fun instruction(predicate: (NavigationInstruction.Open<*>) -> Boolean) { filters.add(NavigationInstructionFilter(predicate)) } + /** + * Matches any instructions that are presented (i.e. navigationDirection is NavigationDirection.Present) + */ + public fun anyPresented() { + instruction { it.navigationDirection == NavigationDirection.Present } + } + + /** + * Matches any instructions that are pushed (i.e. navigationDirection is NavigationDirection.Pushed) + */ + public fun anyPushed() { + instruction { it.navigationDirection == NavigationDirection.Push } + } + internal fun build(): NavigationInstructionFilter { return NavigationInstructionFilter { instruction -> filters.any { it.accept(instruction) } diff --git a/enro-core/src/main/java/dev/enro/core/controller/NavigationController.kt b/enro-core/src/main/java/dev/enro/core/controller/NavigationController.kt index 9208a1cb..367a2f17 100644 --- a/enro-core/src/main/java/dev/enro/core/controller/NavigationController.kt +++ b/enro-core/src/main/java/dev/enro/core/controller/NavigationController.kt @@ -23,7 +23,7 @@ public class NavigationController internal constructor() { private val executorRepository: ExecutorRepository = dependencyScope.get() private val addModuleToController: AddModuleToController = dependencyScope.get() - public var config: EnroConfig = EnroConfig() + internal var config: EnroConfig = EnroConfig() private set init { diff --git a/enro-core/src/main/java/dev/enro/core/controller/createNavigationController.kt b/enro-core/src/main/java/dev/enro/core/controller/createNavigationController.kt index e2e54767..07d7a5a2 100644 --- a/enro-core/src/main/java/dev/enro/core/controller/createNavigationController.kt +++ b/enro-core/src/main/java/dev/enro/core/controller/createNavigationController.kt @@ -2,13 +2,17 @@ package dev.enro.core.controller import android.app.Application import androidx.annotation.Keep +import dev.enro.core.EnroConfig /** * Create a NavigationController from the NavigationControllerDefinition/DSL, and immediately attach it * to the NavigationApplication from which this function was called. + * + * @param useLegacyContainerPresentBehavior see [EnroConfig.useLegacyContainerPresentBehavior] */ public fun NavigationApplication.createNavigationController( strictMode: Boolean = false, + useLegacyContainerPresentBehavior: Boolean = false, backConfiguration: EnroBackConfiguration = EnroBackConfiguration.Default, block: NavigationModuleScope.() -> Unit = {} ): NavigationController { @@ -22,6 +26,7 @@ public fun NavigationApplication.createNavigationController( setConfig( config.copy( isStrictMode = strictMode, + useLegacyContainerPresentBehavior = useLegacyContainerPresentBehavior, backConfiguration = backConfiguration, ) ) @@ -35,14 +40,21 @@ public fun NavigationApplication.createNavigationController( ) public fun NavigationApplication.navigationController( strictMode: Boolean = false, + useLegacyContainerPresentBehavior: Boolean = false, backConfiguration: EnroBackConfiguration = EnroBackConfiguration.Default, block: NavigationModuleScope.() -> Unit = {} -): NavigationController = createNavigationController(strictMode, backConfiguration, block) +): NavigationController = createNavigationController( + strictMode = strictMode, + useLegacyContainerPresentBehavior = useLegacyContainerPresentBehavior, + backConfiguration = backConfiguration, + block = block +) @Keep // Used by EnroTest internal fun createUnattachedNavigationController( strictMode: Boolean = false, + useLegacyContainerPresentBehavior: Boolean = false, backConfiguration: EnroBackConfiguration = EnroBackConfiguration.Default, block: NavigationModuleScope.() -> Unit = {} ): NavigationController { @@ -52,6 +64,7 @@ internal fun createUnattachedNavigationController( setConfig( config.copy( isStrictMode = strictMode, + useLegacyContainerPresentBehavior = useLegacyContainerPresentBehavior, backConfiguration = backConfiguration, ) ) From 206ac3d52111941aee3d0108cfd9ac7edc5abd66 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Fri, 13 Sep 2024 00:55:26 +1200 Subject: [PATCH 23/51] Updated PresentationTests for non-legacy present behavior in containers --- .../enro/destination/fragment/FragmentPlugin.kt | 4 ++-- .../java/dev/enro/test/PresentationTests.kt | 15 ++++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/enro-core/src/main/java/dev/enro/destination/fragment/FragmentPlugin.kt b/enro-core/src/main/java/dev/enro/destination/fragment/FragmentPlugin.kt index 41c2e087..f4c763b2 100644 --- a/enro-core/src/main/java/dev/enro/destination/fragment/FragmentPlugin.kt +++ b/enro-core/src/main/java/dev/enro/destination/fragment/FragmentPlugin.kt @@ -8,7 +8,7 @@ import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import dev.enro.core.container.EmptyBehavior import dev.enro.core.container.NavigationContainerProperty -import dev.enro.core.container.acceptNone +import dev.enro.core.container.accept import dev.enro.core.container.emptyBackstack import dev.enro.core.containerManager import dev.enro.core.controller.NavigationController @@ -60,7 +60,7 @@ private class FragmentLifecycleCallbacksForEnro( FragmentNavigationContainer( containerId = android.R.id.content, parentContext = activity.navigationContext, - filter = acceptNone(), + filter = accept { anyPresented() }, emptyBehavior = EmptyBehavior.AllowEmpty, interceptor = {}, animations = {}, diff --git a/enro/src/androidTest/java/dev/enro/test/PresentationTests.kt b/enro/src/androidTest/java/dev/enro/test/PresentationTests.kt index c757c9df..84805528 100644 --- a/enro/src/androidTest/java/dev/enro/test/PresentationTests.kt +++ b/enro/src/androidTest/java/dev/enro/test/PresentationTests.kt @@ -23,8 +23,7 @@ import dev.enro.core.NavigationKey import dev.enro.core.R import dev.enro.core.compose.rememberNavigationContainer import dev.enro.core.container.EmptyBehavior -import dev.enro.core.container.acceptKey -import dev.enro.core.container.doNotAccept +import dev.enro.core.container.accept import dev.enro.core.directParentContainer import dev.enro.core.fragment.container.navigationContainer import dev.enro.core.getNavigationHandle @@ -287,8 +286,11 @@ class ActivityWithFragmentContainer : FragmentActivity() { val container by navigationContainer( containerId = containerId, - filter = acceptKey { - it !is PresentationTests.NotSupportedFragmentKey && it !is PresentationTests.NotSupportedComposeKey + filter = accept { + anyPresented() + key { + it !is PresentationTests.NotSupportedFragmentKey && it !is PresentationTests.NotSupportedComposeKey + } } ) @@ -312,7 +314,10 @@ class ActivityWithComposeContainer : FragmentActivity() { setContent { val container = rememberNavigationContainer( emptyBehavior = EmptyBehavior.AllowEmpty, - filter = doNotAccept { key() } + filter = accept { + anyPresented() + key { it !is PresentationTests.NotSupportedComposeKey } + } ) Box(modifier = Modifier.fillMaxSize()) { container.Render() From 98616bb2b281eeba3f96b861efb011375c4ff6b4 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 12 Sep 2024 13:19:03 +0000 Subject: [PATCH 24/51] Released 2.8.0-rc01 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index ac86bb74..7d081435 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -versionName=2.8.0-alpha02 -versionCode=105 \ No newline at end of file +versionName=2.8.0-rc01 +versionCode=106 \ No newline at end of file From 111be63ab35b6f039ad46db75d07d6901c2e4953 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Mon, 16 Sep 2024 20:05:26 +1200 Subject: [PATCH 25/51] Use key for visibleTransition --- .../compose/destination/ComposableDestinationAnimations.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt b/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt index 7c7588c7..ebd8a702 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.NonSkippableComposable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -98,9 +99,11 @@ internal class ComposableDestinationAnimations( currentAnimationEvent = AnimationEvent.SnapTo(event.visible) } } - + val visibleTransition = key(instruction.instructionId) { + rememberTransition(visibilityState, "ComposableDestination Visibility") + } animation.Animate( - visible = rememberTransition(visibilityState, "ComposableDestination Visibility"), + visible = visibleTransition, ) { enterExitTransition = it content() From bf718927f881d6ba3c81c21b442bfe542d1db723 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Mon, 16 Sep 2024 20:37:52 +1200 Subject: [PATCH 26/51] Updated NavigationContainerGroup to add setActiveInContainerManager to set the first container in the navigation container group as active --- .../container/NavigationContainerGroup.kt | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/enro-core/src/main/java/dev/enro/destination/compose/container/NavigationContainerGroup.kt b/enro-core/src/main/java/dev/enro/destination/compose/container/NavigationContainerGroup.kt index f67afb83..63a20646 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/container/NavigationContainerGroup.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/container/NavigationContainerGroup.kt @@ -1,19 +1,52 @@ package dev.enro.core.compose.container -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import dev.enro.core.containerManager +/** + * A NavigationContainerGroup is a group of [ComposableNavigationContainer]s that are managed together, with one of those + * containers being the active container. This is useful for managing multiple navigation containers that are in the same + * context/ContainerManager, where the active container within the group may or may not be the active container in the ContainerManager. + * + * In most cases, if a NavigationContainerGroup contains the all the containers that are registered with the ContainerManager, + * it will not be required to use a NavigationContainerGroup to implement the desired behaviour. In these cases, you could + * directly use the ContainerManager to manage the active container within the group, but it may still be useful to use a + * NavigationContainerGroup for the simplified syntax, explicit definition of behaviour, or where ordering of containers is important. + */ @Immutable public data class NavigationContainerGroup( public val containers: List, public val activeContainer: ComposableNavigationContainer ) +/** + * This function creates and remembers a NavigationContainerGroup. + * + * @see [NavigationContainerGroup] + * + * @param containers The containers that are part of the NavigationContainerGroup + * @param setActiveInContainerManager Whether the first container in the list should be set as the active container in the associated + * NavigationContainerManager when this NavigationContainerGroup is created. The first container will always be the active + * container within the NavigationContainerGroup, but in cases where multiple NavigationContainerGroups are created in the + * context of the same NavigationContainerManager, it is useful to choose one NavigationContainerGroup to start as + * active in the NavigationContainerManager. This defaults to true. + */ @Composable -public fun rememberNavigationContainerGroup(vararg containers: ComposableNavigationContainer): NavigationContainerGroup { +public fun rememberNavigationContainerGroup( + vararg containers: ComposableNavigationContainer, + setActiveInContainerManager: Boolean = true, +): NavigationContainerGroup { + val containerManager = containerManager val activeInGroup = rememberSaveable { - mutableStateOf(containers.first().key) + val firstContainer = containers.first() + if (setActiveInContainerManager) { containerManager.setActiveContainer(firstContainer) } + + mutableStateOf(firstContainer.key) } val activeContainer = containerManager.activeContainer DisposableEffect(activeContainer) { From 2dd3627c8dd8266572debc34df307ef2d94696d1 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Mon, 16 Sep 2024 20:45:15 +1200 Subject: [PATCH 27/51] Add NavigationInstructionFilter for `acceptFromFlow` --- .../enro/core/container/NavigationInstructionFilter.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/enro-core/src/main/java/dev/enro/core/container/NavigationInstructionFilter.kt b/enro-core/src/main/java/dev/enro/core/container/NavigationInstructionFilter.kt index 92e8915d..92671ceb 100644 --- a/enro-core/src/main/java/dev/enro/core/container/NavigationInstructionFilter.kt +++ b/enro-core/src/main/java/dev/enro/core/container/NavigationInstructionFilter.kt @@ -3,6 +3,7 @@ package dev.enro.core.container import dev.enro.core.NavigationDirection import dev.enro.core.NavigationInstruction import dev.enro.core.NavigationKey +import dev.enro.core.result.flows.FlowStep /** * A NavigationContainerFilter is used to determine whether or not a given [NavigationInstruction.Open] @@ -75,6 +76,14 @@ public class NavigationContainerFilterBuilder internal constructor() { */ public fun acceptAll(): NavigationInstructionFilter = NavigationInstructionFilter { true } +/** + * A [NavigationInstructionFilter] that accepts only [NavigationInstruction.Open] instructions which have been added to the container + * by a [dev.enro.core.result.flows.NavigationFlow]. + */ +public fun acceptFromFlow(): NavigationInstructionFilter = NavigationInstructionFilter { + it.internal.resultKey is FlowStep<*> +} + /** * A [NavigationInstructionFilter] that accepts no [NavigationInstruction.Open] instructions. * From a4239263cb4affe16a93c183eebdbb0d3799a47f Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Mon, 16 Sep 2024 20:47:07 +1200 Subject: [PATCH 28/51] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fa5bba..30f743c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * Updated Composable navigation animations to use SeekableTransitionState, as a step towards supporting predictive back navigation animations * Fixed a bug where managed flows (`registerForFlowResult`) that launch embedded flows (`deliverResultFromPush/Present`) were not correctly handling the result of the embedded flow * Added `FragmentSharedElements` to provide a way to define shared elements for Fragment navigation, including a compatibility layer for Composable NavigationDestinations that want to use AndroidViews as shared elements with Fragments. See `FragmentsWithSharedElements.kt` in the test application for examples of how to use `FragmentSharedElements` +* Added `acceptFromFlow` as a `NavigationContainerFilter` for use on screens that build managed flows using `registerForFlowResult`. This filter will cause the `NavigationContainer` to only accept instructions that have been created as part a managed flow, and will reject instructions that are not part of a managed flow. * ⚠️ Updated result channel identifiers in preparation for Kotlin 2.0 ⚠️ * Kotlin 2.0 changes the way that lambdas are compiled, which has implications for `registerForNavigationResult` and how result channels are uniquely identified. Activites, Fragments, Composables and ViewModels that use `by registerForNavigationResult` directly will not be affected by this change. However, if you are creating result channels inside of other objects, such as delegates, helper objects, or extension functions, you should verify that these cases continue to work as expected. It is not expected that there will be issues, but if this does result in bugs in your application, please raise them on the Enro GitHub repository. From b4d1a66523ac6a4167de0b2f42849b61d4e22830 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Mon, 16 Sep 2024 21:01:14 +1200 Subject: [PATCH 29/51] Removed `isAnimating` property of ComposableNavigationContainer, simplified ComposableDestinationAnimations by removing the transition state as a property. --- CHANGELOG.md | 1 + .../ComposableNavigationContainer.kt | 5 ----- .../ComposableDestinationAnimations.kt | 19 ++++--------------- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f743c2..9e7978e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Fixed a bug where managed flows (`registerForFlowResult`) that launch embedded flows (`deliverResultFromPush/Present`) were not correctly handling the result of the embedded flow * Added `FragmentSharedElements` to provide a way to define shared elements for Fragment navigation, including a compatibility layer for Composable NavigationDestinations that want to use AndroidViews as shared elements with Fragments. See `FragmentsWithSharedElements.kt` in the test application for examples of how to use `FragmentSharedElements` * Added `acceptFromFlow` as a `NavigationContainerFilter` for use on screens that build managed flows using `registerForFlowResult`. This filter will cause the `NavigationContainer` to only accept instructions that have been created as part a managed flow, and will reject instructions that are not part of a managed flow. +* Removed `isAnimating` from `ComposableNavigationContainer`, as it was unused internally, did not appear to be useful for external use cases, and was complicating Compose animation code. If this functionality *was* important to your use case, please create a Github issue to discuss your use case. * ⚠️ Updated result channel identifiers in preparation for Kotlin 2.0 ⚠️ * Kotlin 2.0 changes the way that lambdas are compiled, which has implications for `registerForNavigationResult` and how result channels are uniquely identified. Activites, Fragments, Composables and ViewModels that use `by registerForNavigationResult` directly will not be affected by this change. However, if you are creating result channels inside of other objects, such as delegates, helper objects, or extension functions, you should verify that these cases continue to work as expected. It is not expected that there will be issues, but if this does result in bugs in your application, please raise them on the Enro GitHub repository. diff --git a/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt b/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt index a7105c07..ee34bdd9 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt @@ -4,7 +4,6 @@ import android.os.Bundle import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.movableContentOf @@ -71,10 +70,6 @@ public class ComposableNavigationContainer internal constructor( override val isVisible: Boolean get() = true - public val isAnimating: Boolean by derivedStateOf { - destinationOwners.any { it.animations.isAnimating } - } - private val onDestroyLifecycleObserver = LifecycleEventObserver { _, event -> if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver destroy() diff --git a/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt b/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt index ebd8a702..ec2938e1 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/destination/ComposableDestinationAnimations.kt @@ -12,9 +12,7 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.NonSkippableComposable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -37,20 +35,11 @@ internal class ComposableDestinationAnimations( private val owner: ComposableDestinationOwner, ) { private var currentAnimationEvent by mutableStateOf(AnimationEvent.SnapTo(false)) - private val visibilityState = SeekableTransitionState(false) internal var animationOverride by mutableStateOf(null) internal lateinit var enterExitTransition: Transition - val isAnimating by derivedStateOf { - when (currentAnimationEvent) { - is AnimationEvent.AnimateTo -> visibilityState.targetState != visibilityState.currentState - is AnimationEvent.SnapTo -> false - is AnimationEvent.Seek -> true - } - } - internal fun setAnimationEvent(event: AnimationEvent) { currentAnimationEvent = event } @@ -59,10 +48,12 @@ internal class ComposableDestinationAnimations( @Composable @NonSkippableComposable fun Animate(content: @Composable () -> Unit) { - val targetState = visibilityState.targetState val instruction = owner.instruction val parentContainer = owner.parentContainer + val visibilityState = remember(instruction.instructionId) { SeekableTransitionState(false) } + val targetState = visibilityState.targetState + val animation = remember( instruction, targetState, @@ -99,9 +90,7 @@ internal class ComposableDestinationAnimations( currentAnimationEvent = AnimationEvent.SnapTo(event.visible) } } - val visibleTransition = key(instruction.instructionId) { - rememberTransition(visibilityState, "ComposableDestination Visibility") - } + val visibleTransition = rememberTransition(visibilityState, "ComposableDestination Visibility") animation.Animate( visible = visibleTransition, ) { From 282e61f69a8cfe986c69cfb7d53b42ab90c60910 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Mon, 16 Sep 2024 09:26:55 +0000 Subject: [PATCH 30/51] Released 2.8.0-rc02 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 7d081435..720e4320 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -versionName=2.8.0-rc01 -versionCode=106 \ No newline at end of file +versionName=2.8.0-rc02 +versionCode=107 \ No newline at end of file From eb1f6afd2da9ac2c2f5b8baed7a4cb1b757478f4 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Mon, 16 Sep 2024 09:31:43 +0000 Subject: [PATCH 31/51] Released 2.8.0-rc03 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 720e4320..24c4ac67 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -versionName=2.8.0-rc02 -versionCode=107 \ No newline at end of file +versionName=2.8.0-rc03 +versionCode=108 \ No newline at end of file From 04c6dd00db84a0529575561da55b682fcc480d8b Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 19 Sep 2024 19:55:59 +1200 Subject: [PATCH 32/51] Register saved state handle with NavigationHandle's dependency scope, allowing the savedStateHandle parameter to be removed from registerForFlowResult --- .../java/dev/enro/core/NavigationHandle.kt | 19 ++++++++---- .../usecase/OnNavigationContextCreated.kt | 8 +++-- .../NavigationHandle.savedStateHandle.kt | 9 ++++++ .../internal/handle/NavigationHandleScope.kt | 3 ++ .../NavigationHandleViewModelFactory.kt | 24 +++++++++++---- .../handle/TestNavigationHandleViewModel.kt | 6 +++- .../core/result/flows/FlowResultChannel.kt | 30 +++++++++++++++++++ .../preview/PreviewNavigationHandle.kt | 6 +++- .../dev/enro/test/FakeNavigationHandle.kt | 4 ++- 9 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandle.savedStateHandle.kt diff --git a/enro-core/src/main/java/dev/enro/core/NavigationHandle.kt b/enro-core/src/main/java/dev/enro/core/NavigationHandle.kt index 0e827c76..9cfb6de4 100644 --- a/enro-core/src/main/java/dev/enro/core/NavigationHandle.kt +++ b/enro-core/src/main/java/dev/enro/core/NavigationHandle.kt @@ -28,18 +28,20 @@ public interface TypedNavigationHandle : NavigationHandle { internal class TypedNavigationHandleImpl( internal val navigationHandle: NavigationHandle, private val type: Class -): TypedNavigationHandle { +) : TypedNavigationHandle { override val id: String get() = navigationHandle.id override val instruction: NavigationInstruction.Open<*> = navigationHandle.instruction override val dependencyScope: EnroDependencyScope get() = navigationHandle.dependencyScope @Suppress("UNCHECKED_CAST") - override val key: T get() = navigationHandle.key as? T - ?: throw EnroException.IncorrectlyTypedNavigationHandle("TypedNavigationHandle failed to cast key of type ${navigationHandle.key::class.java.simpleName} to ${type.simpleName}") + override val key: T + get() = navigationHandle.key as? T + ?: throw EnroException.IncorrectlyTypedNavigationHandle("TypedNavigationHandle failed to cast key of type ${navigationHandle.key::class.java.simpleName} to ${type.simpleName}") override val lifecycle: Lifecycle get() = navigationHandle.lifecycle - override fun executeInstruction(navigationInstruction: NavigationInstruction) = navigationHandle.executeInstruction(navigationInstruction) + override fun executeInstruction(navigationInstruction: NavigationInstruction) = + navigationHandle.executeInstruction(navigationInstruction) } public fun NavigationHandle.asTyped(type: KClass): TypedNavigationHandle { @@ -127,7 +129,7 @@ internal fun NavigationHandle.runWhenHandleActive(block: () -> Unit) { Looper.getMainLooper() == Looper.myLooper() }.getOrElse { dependencyScope.get().config.isInTest } // if the controller is in a Jvm only test, the block above may fail to run - if(isMainThread && lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { + if (isMainThread && lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { block() } else { lifecycleScope.launch { @@ -136,4 +138,9 @@ internal fun NavigationHandle.runWhenHandleActive(block: () -> Unit) { } } } -} \ No newline at end of file +} + +internal val NavigationHandle.enroConfig: EnroConfig + get() = runCatching { + dependencyScope.get().config + }.getOrElse { EnroConfig() } diff --git a/enro-core/src/main/java/dev/enro/core/controller/usecase/OnNavigationContextCreated.kt b/enro-core/src/main/java/dev/enro/core/controller/usecase/OnNavigationContextCreated.kt index 3122ba90..fd1d5528 100644 --- a/enro-core/src/main/java/dev/enro/core/controller/usecase/OnNavigationContextCreated.kt +++ b/enro-core/src/main/java/dev/enro/core/controller/usecase/OnNavigationContextCreated.kt @@ -52,9 +52,11 @@ internal class OnNavigationContextCreated( .copy(instructionId = contextId) val viewModelStoreOwner = context.contextReference as ViewModelStoreOwner - val handle = viewModelStoreOwner.createNavigationHandleViewModel( - context.controller, - instruction ?: defaultInstruction + val handle = createNavigationHandleViewModel( + viewModelStoreOwner = viewModelStoreOwner, + savedStateRegistryOwner = context.savedStateRegistryOwner, + navigationController = context.controller, + instruction = instruction ?: defaultInstruction ) handle.navigationContext = context diff --git a/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandle.savedStateHandle.kt b/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandle.savedStateHandle.kt new file mode 100644 index 00000000..c61df9d3 --- /dev/null +++ b/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandle.savedStateHandle.kt @@ -0,0 +1,9 @@ +package dev.enro.core.internal.handle + +import androidx.lifecycle.SavedStateHandle +import dev.enro.core.NavigationHandle +import dev.enro.core.controller.get + +internal fun NavigationHandle.savedStateHandle(): SavedStateHandle { + return dependencyScope.get() +} diff --git a/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleScope.kt b/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleScope.kt index 18f81266..13961b25 100644 --- a/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleScope.kt +++ b/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleScope.kt @@ -2,6 +2,7 @@ package dev.enro.core.internal.handle import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.SavedStateHandle import dev.enro.core.NavigationHandle import dev.enro.core.controller.EnroDependencyContainer import dev.enro.core.controller.EnroDependencyScope @@ -14,6 +15,7 @@ import java.io.Closeable internal class NavigationHandleScope( navigationController: NavigationController, + savedStateHandle: SavedStateHandle, ) : EnroDependencyScope, Closeable { private var boundNavigationHandle: NavigationHandle? = null @@ -28,6 +30,7 @@ internal class NavigationHandleScope( } register { CreateResultChannel(get(), get()) } register { NavigationHandleExtras() } + register { savedStateHandle } } ) diff --git a/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModelFactory.kt b/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModelFactory.kt index 5d33840e..cbf4cc26 100644 --- a/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModelFactory.kt +++ b/enro-core/src/main/java/dev/enro/core/internal/handle/NavigationHandleViewModelFactory.kt @@ -1,10 +1,15 @@ package dev.enro.core.internal.handle +import androidx.lifecycle.SAVED_STATE_REGISTRY_OWNER_KEY +import androidx.lifecycle.VIEW_MODEL_STORE_OWNER_KEY import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelLazy import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.savedstate.SavedStateRegistryOwner import dev.enro.core.AnyOpenInstruction import dev.enro.core.EnroException import dev.enro.core.controller.NavigationController @@ -19,7 +24,7 @@ internal class NavigationHandleViewModelFactory( } override fun create(modelClass: Class, extras: CreationExtras): T { - if(navigationController.config.isInTest) { + if (navigationController.config.isInTest) { return TestNavigationHandleViewModel( navigationController, instruction @@ -27,7 +32,8 @@ internal class NavigationHandleViewModelFactory( } val scope = NavigationHandleScope( - navigationController + navigationController = navigationController, + savedStateHandle = extras.createSavedStateHandle(), ) return NavigationHandleViewModel( instruction = instruction, @@ -39,14 +45,22 @@ internal class NavigationHandleViewModelFactory( } } -internal fun ViewModelStoreOwner.createNavigationHandleViewModel( +internal fun createNavigationHandleViewModel( + viewModelStoreOwner: ViewModelStoreOwner, + savedStateRegistryOwner: SavedStateRegistryOwner, navigationController: NavigationController, instruction: AnyOpenInstruction ): NavigationHandleViewModel { return ViewModelLazy( viewModelClass = NavigationHandleViewModel::class, - storeProducer = { viewModelStore }, - factoryProducer = { NavigationHandleViewModelFactory(navigationController, instruction) } + storeProducer = { viewModelStoreOwner.viewModelStore }, + factoryProducer = { NavigationHandleViewModelFactory(navigationController, instruction) }, + extrasProducer = { + MutableCreationExtras().apply { + set(SAVED_STATE_REGISTRY_OWNER_KEY, savedStateRegistryOwner) + set(VIEW_MODEL_STORE_OWNER_KEY, viewModelStoreOwner) + } + } ).value } diff --git a/enro-core/src/main/java/dev/enro/core/internal/handle/TestNavigationHandleViewModel.kt b/enro-core/src/main/java/dev/enro/core/internal/handle/TestNavigationHandleViewModel.kt index de395c6e..d7db1946 100644 --- a/enro-core/src/main/java/dev/enro/core/internal/handle/TestNavigationHandleViewModel.kt +++ b/enro-core/src/main/java/dev/enro/core/internal/handle/TestNavigationHandleViewModel.kt @@ -1,5 +1,6 @@ package dev.enro.core.internal.handle +import androidx.lifecycle.SavedStateHandle import dev.enro.core.AnyOpenInstruction import dev.enro.core.NavigationContext import dev.enro.core.NavigationInstruction @@ -21,7 +22,10 @@ internal class TestNavigationHandleViewModel( instruction: AnyOpenInstruction ) : NavigationHandleViewModel( instruction = instruction, - dependencyScope = NavigationHandleScope(controller), + dependencyScope = NavigationHandleScope( + navigationController = controller, + savedStateHandle = SavedStateHandle(), + ), executeOpenInstruction = object: ExecuteOpenInstruction { override fun invoke( navigationContext: NavigationContext, diff --git a/enro-core/src/main/java/dev/enro/core/result/flows/FlowResultChannel.kt b/enro-core/src/main/java/dev/enro/core/result/flows/FlowResultChannel.kt index 7cdad907..fc239326 100644 --- a/enro-core/src/main/java/dev/enro/core/result/flows/FlowResultChannel.kt +++ b/enro-core/src/main/java/dev/enro/core/result/flows/FlowResultChannel.kt @@ -11,6 +11,7 @@ import dev.enro.core.NavigationInstruction import dev.enro.core.NavigationKey import dev.enro.core.container.toBackstack import dev.enro.core.controller.usecase.extras +import dev.enro.core.internal.handle.savedStateHandle import dev.enro.core.onActiveContainer import dev.enro.core.result.NavigationResultChannel import dev.enro.core.result.NavigationResultScope @@ -180,6 +181,22 @@ public class NavigationFlow internal constructor( } } + +@Deprecated("It is no longer required to provide a SavedStateHandle to a registerForFlowResult, please use the registerForFlowResult without the SavedStateHandle parameter.") +public fun ViewModel.registerForFlowResult( + savedStateHandle: SavedStateHandle, + isManuallyStarted: Boolean = false, + flow: NavigationFlowScope.() -> T, + onCompleted: (T) -> Unit, +): PropertyDelegateProvider>> { + return registerForFlowResultInternal( + savedStateHandle = savedStateHandle, + isManuallyStarted = isManuallyStarted, + flow = flow, + onCompleted = onCompleted + ) +} + /** * This method creates a NavigationFlow in the scope of a ViewModel. There can only be one NavigationFlow created within each * NavigationDestination. The [flow] lambda will be invoked multiple times over the lifecycle of the NavigationFlow, and should @@ -193,6 +210,19 @@ public class NavigationFlow internal constructor( * which will then trigger the flow to continue. */ public fun ViewModel.registerForFlowResult( + isManuallyStarted: Boolean = false, + flow: NavigationFlowScope.() -> T, + onCompleted: (T) -> Unit, +): PropertyDelegateProvider>> { + return registerForFlowResultInternal( + savedStateHandle = getNavigationHandle().savedStateHandle(), + isManuallyStarted = isManuallyStarted, + flow = flow, + onCompleted = onCompleted + ) +} + +private fun ViewModel.registerForFlowResultInternal( savedStateHandle: SavedStateHandle, isManuallyStarted: Boolean = false, flow: NavigationFlowScope.() -> T, diff --git a/enro-core/src/main/java/dev/enro/destination/compose/preview/PreviewNavigationHandle.kt b/enro-core/src/main/java/dev/enro/destination/compose/preview/PreviewNavigationHandle.kt index 2a4c70c6..2268a5d0 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/preview/PreviewNavigationHandle.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/preview/PreviewNavigationHandle.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle import dev.enro.core.AnyOpenInstruction import dev.enro.core.EnroException import dev.enro.core.NavigationDirection @@ -23,7 +24,10 @@ internal class PreviewNavigationHandle( ) : NavigationHandle { override val id: String = instruction.instructionId override val key: NavigationKey = instruction.navigationKey - override val dependencyScope: EnroDependencyScope = NavigationHandleScope(NavigationController()).bind(this) + override val dependencyScope: EnroDependencyScope = NavigationHandleScope( + navigationController = NavigationController(), + savedStateHandle = SavedStateHandle(), + ).bind(this) override fun executeInstruction(navigationInstruction: NavigationInstruction) { diff --git a/enro-test/src/main/java/dev/enro/test/FakeNavigationHandle.kt b/enro-test/src/main/java/dev/enro/test/FakeNavigationHandle.kt index c06b8216..741b0973 100644 --- a/enro-test/src/main/java/dev/enro/test/FakeNavigationHandle.kt +++ b/enro-test/src/main/java/dev/enro/test/FakeNavigationHandle.kt @@ -5,6 +5,7 @@ package dev.enro.test import android.annotation.SuppressLint import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.SavedStateHandle import dev.enro.core.NavigationContainerKey import dev.enro.core.NavigationDirection import dev.enro.core.NavigationHandle @@ -52,7 +53,8 @@ internal class FakeNavigationHandle( override val id: String = instruction.instructionId override val key: NavigationKey = key override val dependencyScope: EnroDependencyScope = NavigationHandleScope( - EnroTest.getCurrentNavigationController() + navigationController = EnroTest.getCurrentNavigationController(), + savedStateHandle = SavedStateHandle(), ).bind(this) override fun executeInstruction(navigationInstruction: NavigationInstruction) { From d90c24a7425eb32d35aafb415a1b74ac0445f635 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 19 Sep 2024 20:05:51 +1200 Subject: [PATCH 33/51] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7978e4..454b8f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ * Added `FragmentSharedElements` to provide a way to define shared elements for Fragment navigation, including a compatibility layer for Composable NavigationDestinations that want to use AndroidViews as shared elements with Fragments. See `FragmentsWithSharedElements.kt` in the test application for examples of how to use `FragmentSharedElements` * Added `acceptFromFlow` as a `NavigationContainerFilter` for use on screens that build managed flows using `registerForFlowResult`. This filter will cause the `NavigationContainer` to only accept instructions that have been created as part a managed flow, and will reject instructions that are not part of a managed flow. * Removed `isAnimating` from `ComposableNavigationContainer`, as it was unused internally, did not appear to be useful for external use cases, and was complicating Compose animation code. If this functionality *was* important to your use case, please create a Github issue to discuss your use case. +* Removed the requirement to provide a SavedStateHandle to `registerForFlowResult`. This should not affect any existing code, but if you were passing a SavedStateHandle to `registerForFlowResult`, you can now remove this parameter. + * NavigationHandles now have access to a SavedStateHandle internally, which removes the requirement to pass this through to `registerForFlowResult` * ⚠️ Updated result channel identifiers in preparation for Kotlin 2.0 ⚠️ * Kotlin 2.0 changes the way that lambdas are compiled, which has implications for `registerForNavigationResult` and how result channels are uniquely identified. Activites, Fragments, Composables and ViewModels that use `by registerForNavigationResult` directly will not be affected by this change. However, if you are creating result channels inside of other objects, such as delegates, helper objects, or extension functions, you should verify that these cases continue to work as expected. It is not expected that there will be issues, but if this does result in bugs in your application, please raise them on the Enro GitHub repository. From 803b98147178cd46954ee4f977177916a65f8604 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Fri, 20 Sep 2024 00:09:58 +1200 Subject: [PATCH 34/51] Started adding functionality to allow managed flows as destinations outside of ViewModels --- .../dev/enro/core/NavigationHostFactory.kt | 6 ++ .../java/dev/enro/core/hosts/HostComponent.kt | 1 + .../core/hosts/NavigationHostFactories.kt | 51 +++++++++++++-- .../core/result/flows/FlowResultChannel.kt | 4 +- .../core/result/flows/NavigationFlowScope.kt | 9 ++- .../ComposableNavigationContainer.kt | 14 ++++- .../flow/ManagedFlowDestination.kt | 9 +++ .../flow/ManagedFlowDestinationProvider.kt | 61 ++++++++++++++++++ .../flow/ManagedFlowNavigationBinding.kt | 31 ++++++++++ .../destination/flow/ManagedFlowViewModel.kt | 25 ++++++++ .../flow/host/ComposableHostForManagedFlow.kt | 33 ++++++++++ .../flow/host/FragmentHostForManagedFlow.kt | 62 +++++++++++++++++++ 12 files changed, 292 insertions(+), 14 deletions(-) create mode 100644 enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestination.kt create mode 100644 enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestinationProvider.kt create mode 100644 enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowNavigationBinding.kt create mode 100644 enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowViewModel.kt create mode 100644 enro-core/src/main/java/dev/enro/destination/flow/host/ComposableHostForManagedFlow.kt create mode 100644 enro-core/src/main/java/dev/enro/destination/flow/host/FragmentHostForManagedFlow.kt diff --git a/enro-core/src/main/java/dev/enro/core/NavigationHostFactory.kt b/enro-core/src/main/java/dev/enro/core/NavigationHostFactory.kt index b537d8cd..7c09480f 100644 --- a/enro-core/src/main/java/dev/enro/core/NavigationHostFactory.kt +++ b/enro-core/src/main/java/dev/enro/core/NavigationHostFactory.kt @@ -5,6 +5,12 @@ import dev.enro.core.controller.EnroDependencyScope import dev.enro.core.controller.get import dev.enro.core.controller.usecase.GetNavigationBinding +/** + * A NavigationHostFactory allows for destinations of different types to be interoperable with each other. For example, + * a Fragment destination can host a Composable destination. There are two important functions to register here: + * - supports: This function should return true if the NavigationHostFactory can host the provided NavigationInstruction.Open + * - wrap: This function should return a new NavigationInstruction.Open that is compatible with the HostType + */ @AdvancedEnroApi public abstract class NavigationHostFactory( public val hostType: Class, diff --git a/enro-core/src/main/java/dev/enro/core/hosts/HostComponent.kt b/enro-core/src/main/java/dev/enro/core/hosts/HostComponent.kt index 3b0d2ecb..e8c3ec59 100644 --- a/enro-core/src/main/java/dev/enro/core/hosts/HostComponent.kt +++ b/enro-core/src/main/java/dev/enro/core/hosts/HostComponent.kt @@ -8,6 +8,7 @@ internal val hostNavigationModule = createNavigationModule { navigationHostFactory(ActivityHost()) navigationHostFactory(FragmentHost()) navigationHostFactory(DialogFragmentHost()) + navigationHostFactory(ComposableHost()) binding(createActivityNavigationBinding()) binding(createFragmentNavigationBinding()) diff --git a/enro-core/src/main/java/dev/enro/core/hosts/NavigationHostFactories.kt b/enro-core/src/main/java/dev/enro/core/hosts/NavigationHostFactories.kt index c116ffc7..b24141c8 100644 --- a/enro-core/src/main/java/dev/enro/core/hosts/NavigationHostFactories.kt +++ b/enro-core/src/main/java/dev/enro/core/hosts/NavigationHostFactories.kt @@ -1,14 +1,22 @@ package dev.enro.core.hosts import android.app.Activity -import androidx.compose.material.ExperimentalMaterialApi import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment -import dev.enro.core.* +import dev.enro.core.NavigationContext +import dev.enro.core.NavigationDirection +import dev.enro.core.NavigationHostFactory +import dev.enro.core.NavigationInstruction import dev.enro.core.activity.ActivityNavigationBinding +import dev.enro.core.compose.ComposableDestination import dev.enro.core.compose.ComposableNavigationBinding import dev.enro.core.container.asPresentInstruction import dev.enro.core.fragment.FragmentNavigationBinding +import dev.enro.core.isHiltApplication +import dev.enro.core.isHiltContext +import dev.enro.destination.flow.ManagedFlowNavigationBinding +import dev.enro.destination.flow.host.OpenManagedFlowInFragment +import dev.enro.destination.flow.host.OpenManagedFlowInHiltFragment internal class ActivityHost : NavigationHostFactory(Activity::class.java) { override fun supports( @@ -34,7 +42,6 @@ internal class ActivityHost : NavigationHostFactory(Activity::class.ja } } -@OptIn(ExperimentalMaterialApi::class) internal class DialogFragmentHost : NavigationHostFactory(DialogFragment::class.java) { override fun supports( @@ -42,7 +49,10 @@ internal class DialogFragmentHost : NavigationHostFactory(Dialog instruction: NavigationInstruction.Open<*>, ): Boolean { val binding = requireNavigationBinding(instruction) - val isSupportedBinding = binding is FragmentNavigationBinding || binding is ComposableNavigationBinding + val isSupportedBinding = binding is FragmentNavigationBinding || + binding is ComposableNavigationBinding || + binding is ManagedFlowNavigationBinding<*, *> + return isSupportedBinding && instruction.navigationDirection == NavigationDirection.Present } @@ -67,6 +77,10 @@ internal class DialogFragmentHost : NavigationHostFactory(Dialog navigationContext.isHiltContext -> OpenPresentableFragmentInHiltFragment(instruction.asPresentInstruction()) else -> OpenPresentableFragmentInFragment(instruction.asPresentInstruction()) } + is ManagedFlowNavigationBinding<*, *> -> when { + navigationContext.isHiltContext -> OpenPresentableFragmentInHiltFragment(instruction.asPresentInstruction()) + else -> OpenPresentableFragmentInFragment(instruction.asPresentInstruction()) + } else -> cannotCreateHost(instruction) } return instruction.internal.copy(navigationKey = key) @@ -80,7 +94,9 @@ internal class FragmentHost : NavigationHostFactory(Fragment::class.ja instruction: NavigationInstruction.Open<*>, ): Boolean { val binding = requireNavigationBinding(instruction) - return binding is FragmentNavigationBinding || binding is ComposableNavigationBinding + return binding is FragmentNavigationBinding || + binding is ComposableNavigationBinding || + binding is ManagedFlowNavigationBinding<*, *> } override fun wrap( @@ -97,7 +113,32 @@ internal class FragmentHost : NavigationHostFactory(Fragment::class.ja else -> OpenComposableInFragment(instruction) } ) + is ManagedFlowNavigationBinding<*, *> -> instruction.internal.copy( + navigationKey = when { + navigationContext.isHiltContext -> OpenManagedFlowInHiltFragment(instruction) + else -> OpenManagedFlowInFragment(instruction) + } + ) else -> cannotCreateHost(instruction) } } +} + +internal class ComposableHost : NavigationHostFactory(ComposableDestination::class.java) { + override fun supports( + navigationContext: NavigationContext<*>, + instruction: NavigationInstruction.Open<*>, + ): Boolean { + val binding = requireNavigationBinding(instruction) + return binding is ComposableNavigationBinding || + binding is ManagedFlowNavigationBinding<*, *> + } + + override fun wrap( + navigationContext: NavigationContext<*>, + instruction: NavigationInstruction.Open<*> + ): NavigationInstruction.Open<*> { + if (!supports(navigationContext, instruction)) cannotCreateHost(instruction) + return instruction + } } \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/core/result/flows/FlowResultChannel.kt b/enro-core/src/main/java/dev/enro/core/result/flows/FlowResultChannel.kt index fc239326..9a76e2f6 100644 --- a/enro-core/src/main/java/dev/enro/core/result/flows/FlowResultChannel.kt +++ b/enro-core/src/main/java/dev/enro/core/result/flows/FlowResultChannel.kt @@ -38,8 +38,8 @@ public class NavigationFlow internal constructor( private val resultManager: FlowResultManager, private val coroutineScope: CoroutineScope, private val registerForNavigationResult: CreateResultChannel, - private val flow: NavigationFlowScope.() -> T, - private val onCompleted: (T) -> Unit, + internal var flow: NavigationFlowScope.() -> T, + internal var onCompleted: (T) -> Unit, ) { private var steps: List> = savedStateHandle.get(STEPS_KEY) ?.getParcelableListCompat>(STEPS_KEY) diff --git a/enro-core/src/main/java/dev/enro/core/result/flows/NavigationFlowScope.kt b/enro-core/src/main/java/dev/enro/core/result/flows/NavigationFlowScope.kt index 44da8ac3..0445fa3f 100644 --- a/enro-core/src/main/java/dev/enro/core/result/flows/NavigationFlowScope.kt +++ b/enro-core/src/main/java/dev/enro/core/result/flows/NavigationFlowScope.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.launch -public class NavigationFlowScope internal constructor( +public open class NavigationFlowScope internal constructor( @PublishedApi internal val flow: NavigationFlow<*>, @PublishedApi @@ -17,12 +17,11 @@ public class NavigationFlowScope internal constructor( @PublishedApi internal val resultManager: FlowResultManager, public val navigationFlowReference: NavigationFlowReference, -) { @PublishedApi - internal val steps: MutableList> = mutableListOf() - + internal val steps: MutableList> = mutableListOf(), @PublishedApi - internal val suspendingSteps: MutableList = mutableListOf() + internal val suspendingSteps: MutableList = mutableListOf(), +) { public inline fun push( noinline block: FlowStepBuilderScope.() -> NavigationKey.SupportsPush.WithResult, diff --git a/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt b/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt index ee34bdd9..50832abd 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt @@ -42,6 +42,8 @@ import dev.enro.core.container.merge import dev.enro.core.controller.get import dev.enro.core.controller.interceptor.builder.NavigationInterceptorBuilder import dev.enro.destination.compose.destination.AnimationEvent +import dev.enro.destination.flow.ManagedFlowNavigationBinding +import dev.enro.destination.flow.host.ComposableHostForManagedFlowDestination import java.io.Closeable import kotlin.collections.set @@ -211,10 +213,18 @@ public class ComposableNavigationContainer internal constructor( val rawBinding = controller.bindingForKeyType(composeKey::class) ?: throw EnroException.MissingNavigationBinding(composeKey) - if (rawBinding !is ComposableNavigationBinding<*, *>) { + if (rawBinding !is ComposableNavigationBinding<*, *> && rawBinding !is ManagedFlowNavigationBinding<*, *>) { throw IllegalStateException("Expected ${composeKey::class.java.simpleName} to be bound to a Composable, but was instead bound to a ${rawBinding.baseType.java.simpleName}") } - val destination = rawBinding.constructDestination() + val destination = when (rawBinding) { + is ComposableNavigationBinding<*, *> -> { + rawBinding.constructDestination() + } + is ManagedFlowNavigationBinding<*, *> -> { + ComposableHostForManagedFlowDestination() + } + else -> error("") + } val restoredState = restoredDestinationState.remove(instruction.instructionId) return ComposableDestinationOwner( diff --git a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestination.kt b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestination.kt new file mode 100644 index 00000000..2c661af5 --- /dev/null +++ b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestination.kt @@ -0,0 +1,9 @@ +package dev.enro.destination.flow + +import dev.enro.core.NavigationKey +import dev.enro.core.result.flows.NavigationFlowScope + +public abstract class ManagedFlowDestination { + internal abstract fun NavigationFlowScope.flow(): Result + internal abstract fun onCompleted(result: Result) +} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestinationProvider.kt b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestinationProvider.kt new file mode 100644 index 00000000..ee5ebea6 --- /dev/null +++ b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestinationProvider.kt @@ -0,0 +1,61 @@ +package dev.enro.destination.flow + +import dev.enro.core.NavigationKey +import dev.enro.core.TypedNavigationHandle +import dev.enro.core.result.flows.NavigationFlowScope + +public class ManagedFlowDestinationProvider( + internal val flow: ManagedFlowDestinationScope.() -> Result, + internal val onCompleted: ManagedFlowCompleteScope.(Result) -> Unit, +) { + public fun create(navigation: TypedNavigationHandle): ManagedFlowDestination { + return object : ManagedFlowDestination() { + override fun NavigationFlowScope.flow(): Result { + return flow(ManagedFlowDestinationScope(this, navigation)) + } + + override fun onCompleted(result: Result) { + ManagedFlowCompleteScope(navigation).onCompleted(result) + } + } + } +} + +public class ManagedFlowDestinationScope( + delegate: NavigationFlowScope, + public val navigation: TypedNavigationHandle, +) : NavigationFlowScope( + flow = delegate.flow, + coroutineScope = delegate.coroutineScope, + resultManager = delegate.resultManager, + navigationFlowReference = delegate.navigationFlowReference, + steps = delegate.steps, + suspendingSteps = delegate.suspendingSteps, +) + +public class ManagedFlowCompleteScope( + public val navigation: TypedNavigationHandle, +) + +public class ManagedFlowDestinationBuilder { + public class NeedsFlow { + public fun flow(flow: ManagedFlowDestinationScope.() -> Result): NeedsOnComplete { + return NeedsOnComplete(flow) + } + } + + public class NeedsOnComplete( + private val flow: ManagedFlowDestinationScope.() -> Result, + ) { + public fun onComplete(onComplete: ManagedFlowCompleteScope.(Result) -> Unit): ManagedFlowDestinationProvider { + return ManagedFlowDestinationProvider( + flow = flow, + onCompleted = onComplete, + ) + } + } +} + +public fun managedFlowDestination(): ManagedFlowDestinationBuilder.NeedsFlow { + return ManagedFlowDestinationBuilder.NeedsFlow() +} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowNavigationBinding.kt b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowNavigationBinding.kt new file mode 100644 index 00000000..b4bbafc7 --- /dev/null +++ b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowNavigationBinding.kt @@ -0,0 +1,31 @@ +package dev.enro.destination.flow + +import dev.enro.core.NavigationBinding +import dev.enro.core.NavigationKey +import dev.enro.core.TypedNavigationHandle +import kotlin.reflect.KClass + +public class ManagedFlowNavigationBinding @PublishedApi internal constructor( + override val keyType: KClass, + internal val destination: (TypedNavigationHandle) -> ManagedFlowDestination +) : NavigationBinding> { + override val destinationType: KClass> = ManagedFlowDestination::class + override val baseType: KClass> = ManagedFlowDestination::class +} + +public fun createManagedFlowNavigationBinding( + navigationKeyType: Class, + provider: ManagedFlowDestinationProvider, +): NavigationBinding> = + ManagedFlowNavigationBinding( + keyType = navigationKeyType.kotlin, + destination = provider::create + ) + +public inline fun createManagedFlowNavigationBinding( + provider: ManagedFlowDestinationProvider, +): NavigationBinding> = + ManagedFlowNavigationBinding( + keyType = KeyType::class, + destination = provider::create + ) \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowViewModel.kt b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowViewModel.kt new file mode 100644 index 00000000..f940da84 --- /dev/null +++ b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowViewModel.kt @@ -0,0 +1,25 @@ +package dev.enro.destination.flow + +import androidx.lifecycle.ViewModel +import dev.enro.core.result.flows.registerForFlowResult + +internal class ManagedFlowViewModel : ViewModel() { + private val flow by registerForFlowResult( + isManuallyStarted = true, + flow = { }, + onCompleted = { }, + ) + + internal fun bind( + destination: ManagedFlowDestination<*, *>, + ) { + destination as ManagedFlowDestination<*, Any?> + flow.flow = { + destination.run { flow() } + } + flow.onCompleted = { + destination.onCompleted(it) + } + flow.update() + } +} diff --git a/enro-core/src/main/java/dev/enro/destination/flow/host/ComposableHostForManagedFlow.kt b/enro-core/src/main/java/dev/enro/destination/flow/host/ComposableHostForManagedFlow.kt new file mode 100644 index 00000000..c0e870a8 --- /dev/null +++ b/enro-core/src/main/java/dev/enro/destination/flow/host/ComposableHostForManagedFlow.kt @@ -0,0 +1,33 @@ +package dev.enro.destination.flow.host + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.viewmodel.compose.viewModel +import dev.enro.core.NavigationKey +import dev.enro.core.asTyped +import dev.enro.core.compose.ComposableDestination +import dev.enro.core.compose.destination.navigationController +import dev.enro.core.compose.rememberNavigationContainer +import dev.enro.core.container.EmptyBehavior +import dev.enro.core.container.acceptFromFlow +import dev.enro.destination.flow.ManagedFlowNavigationBinding +import dev.enro.destination.flow.ManagedFlowViewModel +import dev.enro.viewmodel.getNavigationHandle + +internal class ComposableHostForManagedFlowDestination : ComposableDestination() { + @Composable + override fun Render() { + val viewModel = viewModel() + LaunchedEffect(viewModel) { + val key = owner.instruction.navigationKey + val binding = + owner.navigationController.bindingForKeyType(key::class) as ManagedFlowNavigationBinding + viewModel.bind(binding.destination(viewModel.getNavigationHandle().asTyped())) + } + val container = rememberNavigationContainer( + emptyBehavior = EmptyBehavior.CloseParent, + filter = acceptFromFlow(), + ) + container.Render() + } +} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/destination/flow/host/FragmentHostForManagedFlow.kt b/enro-core/src/main/java/dev/enro/destination/flow/host/FragmentHostForManagedFlow.kt new file mode 100644 index 00000000..efce58af --- /dev/null +++ b/enro-core/src/main/java/dev/enro/destination/flow/host/FragmentHostForManagedFlow.kt @@ -0,0 +1,62 @@ +package dev.enro.destination.flow.host + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.fragment.app.Fragment +import dagger.hilt.android.AndroidEntryPoint +import dev.enro.core.AnyOpenInstruction +import dev.enro.core.EnroInternalNavigationKey +import dev.enro.core.NavigationHost +import dev.enro.core.NavigationKey +import dev.enro.core.R +import dev.enro.core.container.EmptyBehavior +import dev.enro.core.container.asPushInstruction +import dev.enro.core.fragment.container.navigationContainer +import dev.enro.core.navigationHandle +import kotlinx.parcelize.Parcelize + +internal abstract class AbstractOpenManagedFlowInFragmentKey : + NavigationKey.SupportsPush, + NavigationKey.SupportsPresent, + EnroInternalNavigationKey { + + abstract val instruction: AnyOpenInstruction +} + +@Parcelize +internal data class OpenManagedFlowInFragment( + override val instruction: AnyOpenInstruction, +) : AbstractOpenManagedFlowInFragmentKey() + +@Parcelize +internal data class OpenManagedFlowInHiltFragment( + override val instruction: AnyOpenInstruction, +) : AbstractOpenManagedFlowInFragmentKey() + +public abstract class AbstractFragmentHostForManagedFlow : Fragment(), NavigationHost { + + private val navigation by navigationHandle() + private val container by navigationContainer( + containerId = R.id.enro_internal_single_fragment_frame_layout, + rootInstruction = { navigation.key.instruction.asPushInstruction() }, + emptyBehavior = EmptyBehavior.CloseParent, + ) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return FrameLayout(requireContext()).apply { + id = R.id.enro_internal_single_fragment_frame_layout + } + } +} + +internal class FragmentHostForManagedFlow : AbstractFragmentHostForManagedFlow() + +@AndroidEntryPoint +internal class HiltFragmentHostForManagedFlow : AbstractFragmentHostForManagedFlow() From 61d11f62fcee7b3cf1fe9ab6a374d18ebeffd28e Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 26 Sep 2024 22:31:42 +1200 Subject: [PATCH 35/51] Compile :tests:application with KAPT as part of the CI build step --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89cf0139..6ade2b6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,27 @@ jobs: - name: Compile example (KAPT) run: ./gradlew :example:assembleDebug -PenroExampleUseKapt + # Compile test application with KAPT; we don't need to compile :tests:application with KSP, + # because it will be compiled with KSP as part of the "Run tests" job. + compile-test-application-kapt: + name: Compile test application (KAPT) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3.9.0 + with: + distribution: 'zulu' + java-version: 17 + + - name: Setup gradle + uses: gradle/gradle-build-action@v2 + + - name: Compile test application (KAPT) + run: ./gradlew :tests:application:assembleDebug -PenroExampleUseKapt + lint: name: Lint runs-on: ubuntu-latest From 971b1d28d855872dc32b73635d505027af8504c2 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 26 Sep 2024 22:32:45 +1200 Subject: [PATCH 36/51] In some cases, destinations that have the same name as Composable functions can cause bugs in KAPT, so we're now not adding imports if the destination import and navigation key import would be the same --- .../generator/ComposableWrapperGenerator.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/enro-processor/src/main/java/dev/enro/processor/generator/ComposableWrapperGenerator.kt b/enro-processor/src/main/java/dev/enro/processor/generator/ComposableWrapperGenerator.kt index 02951416..cea1b0a6 100644 --- a/enro-processor/src/main/java/dev/enro/processor/generator/ComposableWrapperGenerator.kt +++ b/enro-processor/src/main/java/dev/enro/processor/generator/ComposableWrapperGenerator.kt @@ -69,6 +69,8 @@ object ComposableWrapperGenerator { } }.joinToString(separator = "") { "\n $it" } + val elementImportName = element.getElementName(processingEnv) + val keyImportName = keyType.getElementName(processingEnv) processingEnv.filer .createResource( StandardLocation.SOURCE_OUTPUT, @@ -85,9 +87,15 @@ object ComposableWrapperGenerator { import dev.enro.annotations.NavigationDestination $additionalImports - import ${element.getElementName(processingEnv)} + import $elementImportName import ${ClassNames.Java.composableDestination} - import ${keyType.getElementName(processingEnv)} + ${ + if(keyImportName != elementImportName) { + "import $keyImportName" + } else { + "" + } + } $additionalAnnotations public class $composableWrapperName : ComposableDestination()$additionalInterfaces { From 8be8f102dd3eee1274988aa16a3c8f4c87cf15eb Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 26 Sep 2024 22:33:08 +1200 Subject: [PATCH 37/51] Fix issue with incorrect navigation binding in test application --- .../enro/tests/application/compose/SyntheticViewModelAccess.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/application/src/main/java/dev/enro/tests/application/compose/SyntheticViewModelAccess.kt b/tests/application/src/main/java/dev/enro/tests/application/compose/SyntheticViewModelAccess.kt index e46e5abb..6ba9b790 100644 --- a/tests/application/src/main/java/dev/enro/tests/application/compose/SyntheticViewModelAccess.kt +++ b/tests/application/src/main/java/dev/enro/tests/application/compose/SyntheticViewModelAccess.kt @@ -75,7 +75,7 @@ internal val accessValidViewModel = syntheticDestination { require(navigationContext.instruction?.navigationKey is SyntheticViewModelAccess) From 34afb73548c5a94c0df384915bf622d9fe8eb8e7 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 26 Sep 2024 22:33:31 +1200 Subject: [PATCH 38/51] Suppress unchecked cast --- .../main/java/dev/enro/destination/flow/ManagedFlowViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowViewModel.kt b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowViewModel.kt index f940da84..bb2e090a 100644 --- a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowViewModel.kt +++ b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowViewModel.kt @@ -13,7 +13,9 @@ internal class ManagedFlowViewModel : ViewModel() { internal fun bind( destination: ManagedFlowDestination<*, *>, ) { + @Suppress("UNCHECKED_CAST") destination as ManagedFlowDestination<*, Any?> + flow.flow = { destination.run { flow() } } From 130aafc553a1ced0bfd3611d08f55baeaa818b7a Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 26 Sep 2024 22:36:12 +1200 Subject: [PATCH 39/51] Minor updates and documentation for managed flow destinations, and update processor to allow these to be bound with @NavigationDestination --- .../java/dev/enro/core/hosts/HostComponent.kt | 9 ++ .../ComposableNavigationContainer.kt | 12 ++- .../flow/ManagedFlowDestination.kt | 2 +- .../flow/ManagedFlowDestinationBuilder.kt | 99 +++++++++++++++++++ .../flow/ManagedFlowDestinationProvider.kt | 46 ++------- .../flow/ManagedFlowNavigationBinding.kt | 9 +- .../flow/host/ComposableHostForManagedFlow.kt | 7 +- .../flow/host/FragmentHostForManagedFlow.kt | 2 +- .../processor/domain/DestinationReference.kt | 27 ++++- .../enro/processor/extensions/ClassNames.kt | 5 + .../NavigationDestinationGenerator.kt | 24 +++++ 11 files changed, 192 insertions(+), 50 deletions(-) create mode 100644 enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestinationBuilder.kt diff --git a/enro-core/src/main/java/dev/enro/core/hosts/HostComponent.kt b/enro-core/src/main/java/dev/enro/core/hosts/HostComponent.kt index e8c3ec59..2403d3d9 100644 --- a/enro-core/src/main/java/dev/enro/core/hosts/HostComponent.kt +++ b/enro-core/src/main/java/dev/enro/core/hosts/HostComponent.kt @@ -3,6 +3,10 @@ package dev.enro.core.hosts import dev.enro.core.activity.createActivityNavigationBinding import dev.enro.core.controller.createNavigationModule import dev.enro.core.fragment.createFragmentNavigationBinding +import dev.enro.destination.flow.host.FragmentHostForManagedFlow +import dev.enro.destination.flow.host.HiltFragmentHostForManagedFlow +import dev.enro.destination.flow.host.OpenManagedFlowInFragment +import dev.enro.destination.flow.host.OpenManagedFlowInHiltFragment internal val hostNavigationModule = createNavigationModule { navigationHostFactory(ActivityHost()) @@ -13,6 +17,7 @@ internal val hostNavigationModule = createNavigationModule { binding(createActivityNavigationBinding()) binding(createFragmentNavigationBinding()) binding(createFragmentNavigationBinding()) + binding(createFragmentNavigationBinding()) // These Hilt based navigation bindings will fail to be created if Hilt is not on the class path, // which is acceptable/allowed, so we'll attempt to add them, but not worry if they fail to be added @@ -27,4 +32,8 @@ internal val hostNavigationModule = createNavigationModule { runCatching { binding(createFragmentNavigationBinding()) } + + runCatching { + binding(createFragmentNavigationBinding()) + } } \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt b/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt index 50832abd..34fe4fa8 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/container/ComposableNavigationContainer.kt @@ -216,6 +216,12 @@ public class ComposableNavigationContainer internal constructor( if (rawBinding !is ComposableNavigationBinding<*, *> && rawBinding !is ManagedFlowNavigationBinding<*, *>) { throw IllegalStateException("Expected ${composeKey::class.java.simpleName} to be bound to a Composable, but was instead bound to a ${rawBinding.baseType.java.simpleName}") } + // TODO: + // Instead of managing destination construction here, we should move this to the NavigationHostFactory, + // and let the NavigationHostFactory manage the destination construction. This means more significant changes + // to the way that the NavigationHostFactory works, so this is a future improvement. + // The cost of delaying this improvement is small at the moment, as the ComposableNavigationContainer is the only + // container that needs to manage destination construction in this way. val destination = when (rawBinding) { is ComposableNavigationBinding<*, *> -> { rawBinding.constructDestination() @@ -250,15 +256,15 @@ public class ComposableNavigationContainer internal constructor( } val presented = transition.activeBackstack.takeLastWhile { it.navigationDirection is NavigationDirection.Present }.toSet() - val activePush = transition.activeBackstack.lastOrNull { it.navigationDirection !is NavigationDirection.Present } - val activePresented = presented.lastOrNull() + val activePush = transition.activeBackstack.lastOrNull { it.navigationDirection !is NavigationDirection.Present }?.instructionId + val activePresented = presented.lastOrNull()?.instructionId destinationOwners.forEach { destinationOwner -> val instruction = destinationOwner.instruction val isPushedDialogOrBottomSheet = ((destinationOwner.destination is DialogDestination || destinationOwner.destination is BottomSheetDestination) && activePresented != null) - val target = when (instruction) { + val target = when (instruction.instructionId) { activePresented -> !isParentBeingRemoved activePush -> !isParentBeingRemoved && !isPushedDialogOrBottomSheet else -> false diff --git a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestination.kt b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestination.kt index 2c661af5..419bf682 100644 --- a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestination.kt +++ b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestination.kt @@ -3,7 +3,7 @@ package dev.enro.destination.flow import dev.enro.core.NavigationKey import dev.enro.core.result.flows.NavigationFlowScope -public abstract class ManagedFlowDestination { +public abstract class ManagedFlowDestination internal constructor() { internal abstract fun NavigationFlowScope.flow(): Result internal abstract fun onCompleted(result: Result) } \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestinationBuilder.kt b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestinationBuilder.kt new file mode 100644 index 00000000..b609dadd --- /dev/null +++ b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestinationBuilder.kt @@ -0,0 +1,99 @@ +package dev.enro.destination.flow + +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.core.NavigationKey +import dev.enro.core.TypedNavigationHandle +import dev.enro.core.result.flows.NavigationFlowScope + +/** + * A [ManagedFlowDestinationBuilder] is used to build a [ManagedFlowDestinationProvider]. + * + * To create a [ManagedFlowDestinationProvider], use the [managedFlowDestination] function, then call + * [ManagedFlowDestinationBuilder.NeedsFlow.flow] to define the flow for the destination, then call + * [ManagedFlowDestinationBuilder.NeedsOnComplete.onComplete] to define the completion block for the destination, + * which will return a [ManagedFlowDestinationProvider] which can be bound as a navigation destination. + */ +@ExperimentalEnroApi +public class ManagedFlowDestinationBuilder internal constructor() { + + /** + * A [NeedsFlow] is a class that is used to define the flow for a [ManagedFlowDestinationProvider] using [managedFlowDestination]. + * It provides a [flow] function that takes a lambda that defines the flow for the destination, and returns a + * [NeedsOnComplete] that can be used to define the completion block for the destination. + */ + @ExperimentalEnroApi + public class NeedsFlow internal constructor() { + public fun flow(flow: ManagedFlowDestinationScope.() -> Result): NeedsOnComplete { + return NeedsOnComplete(flow) + } + } + + /** + * A [NeedsOnComplete] is a class that is used to define the completion block for a [ManagedFlowDestinationProvider] using + * [managedFlowDestination]. It provides an [onComplete] function that takes a lambda that defines the completion block for + * the destination, and returns a [ManagedFlowDestinationProvider] that can be bound as a navigation destination. + */ + @ExperimentalEnroApi + public class NeedsOnComplete internal constructor( + private val flow: ManagedFlowDestinationScope.() -> Result, + ) { + public fun onComplete(onComplete: ManagedFlowCompleteScope.(Result) -> Unit): ManagedFlowDestinationProvider { + return ManagedFlowDestinationProvider( + flow = flow, + onCompleted = onComplete, + ) + } + } +} + +/** + * A [ManagedFlowDestinationScope] is an extension of [NavigationFlowScope] that is used when building a [ManagedFlowDestination] + * using [managedFlowDestination] and [ManagedFlowDestinationBuilder.NeedsFlow]. It provides access to a [TypedNavigationHandle] + * for the destination, and provides all the same functionality as a [NavigationFlowScope]. + */ +@ExperimentalEnroApi +public class ManagedFlowDestinationScope internal constructor( + delegate: NavigationFlowScope, + public val navigation: TypedNavigationHandle, +) : NavigationFlowScope( + flow = delegate.flow, + coroutineScope = delegate.coroutineScope, + resultManager = delegate.resultManager, + navigationFlowReference = delegate.navigationFlowReference, + steps = delegate.steps, + suspendingSteps = delegate.suspendingSteps, +) + +/** + * A [ManagedFlowCompleteScope] is a scope that is used to provide a [TypedNavigationHandle] for a [ManagedFlowDestination] + * using [managedFlowDestination] and [ManagedFlowDestinationBuilder.NeedsOnComplete]. It provides access to a + * [TypedNavigationHandle] for the destination. + */ +@ExperimentalEnroApi +public class ManagedFlowCompleteScope internal constructor( + public val navigation: TypedNavigationHandle, +) + +/** + * [managedFlowDestination] is used to create a [ManagedFlowDestinationProvider]/[ManagedFlowDestination] to be bound to a + * [NavigationKey]. + * + * This function returns a [ManagedFlowDestinationBuilder.NeedsFlow]. By calling [ManagedFlowDestinationBuilder.NeedsFlow.flow] + * on this object, you can define the flow for the destination. Calling flow will return a + * [ManagedFlowDestinationBuilder.NeedsOnComplete], and by calling [ManagedFlowDestinationBuilder.NeedsOnComplete.onComplete], + * you are able to provide a completion block for the managed flow destination. + * [ManagedFlowDestination.NeedsOnComplete.onComplete] will return a [ManagedFlowDestinationProvider] that can be + * bound as a navigation destination. + * + * Example: + * ``` + * @NavigationDestination(ExampleKey::class) + * val exampleDestination = managedFlowDestination() + * .flow { ... } // define the flow + * .onComplete { ... } // define the completion block + * ``` + */ +@ExperimentalEnroApi +public fun managedFlowDestination(): ManagedFlowDestinationBuilder.NeedsFlow { + return ManagedFlowDestinationBuilder.NeedsFlow() +} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestinationProvider.kt b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestinationProvider.kt index ee5ebea6..7d10ac04 100644 --- a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestinationProvider.kt +++ b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowDestinationProvider.kt @@ -4,7 +4,12 @@ import dev.enro.core.NavigationKey import dev.enro.core.TypedNavigationHandle import dev.enro.core.result.flows.NavigationFlowScope -public class ManagedFlowDestinationProvider( +/** + * A ManagedFlowDestinationProvider is a class that provides a [ManagedFlowDestination] for a specific [NavigationKey]. + * + * To create a [ManagedFlowDestinationProvider], use the [managedFlowDestination] function. + */ +public class ManagedFlowDestinationProvider internal constructor( internal val flow: ManagedFlowDestinationScope.() -> Result, internal val onCompleted: ManagedFlowCompleteScope.(Result) -> Unit, ) { @@ -20,42 +25,3 @@ public class ManagedFlowDestinationProvider( } } } - -public class ManagedFlowDestinationScope( - delegate: NavigationFlowScope, - public val navigation: TypedNavigationHandle, -) : NavigationFlowScope( - flow = delegate.flow, - coroutineScope = delegate.coroutineScope, - resultManager = delegate.resultManager, - navigationFlowReference = delegate.navigationFlowReference, - steps = delegate.steps, - suspendingSteps = delegate.suspendingSteps, -) - -public class ManagedFlowCompleteScope( - public val navigation: TypedNavigationHandle, -) - -public class ManagedFlowDestinationBuilder { - public class NeedsFlow { - public fun flow(flow: ManagedFlowDestinationScope.() -> Result): NeedsOnComplete { - return NeedsOnComplete(flow) - } - } - - public class NeedsOnComplete( - private val flow: ManagedFlowDestinationScope.() -> Result, - ) { - public fun onComplete(onComplete: ManagedFlowCompleteScope.(Result) -> Unit): ManagedFlowDestinationProvider { - return ManagedFlowDestinationProvider( - flow = flow, - onCompleted = onComplete, - ) - } - } -} - -public fun managedFlowDestination(): ManagedFlowDestinationBuilder.NeedsFlow { - return ManagedFlowDestinationBuilder.NeedsFlow() -} \ No newline at end of file diff --git a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowNavigationBinding.kt b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowNavigationBinding.kt index b4bbafc7..8c803906 100644 --- a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowNavigationBinding.kt +++ b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowNavigationBinding.kt @@ -3,6 +3,7 @@ package dev.enro.destination.flow import dev.enro.core.NavigationBinding import dev.enro.core.NavigationKey import dev.enro.core.TypedNavigationHandle +import dev.enro.core.controller.NavigationModuleScope import kotlin.reflect.KClass public class ManagedFlowNavigationBinding @PublishedApi internal constructor( @@ -28,4 +29,10 @@ public inline fun createManagedFlowNav ManagedFlowNavigationBinding( keyType = KeyType::class, destination = provider::create - ) \ No newline at end of file + ) + +public inline fun NavigationModuleScope.managedFlowDestination( + provider: ManagedFlowDestinationProvider, +) { + binding(createManagedFlowNavigationBinding(provider)) +} diff --git a/enro-core/src/main/java/dev/enro/destination/flow/host/ComposableHostForManagedFlow.kt b/enro-core/src/main/java/dev/enro/destination/flow/host/ComposableHostForManagedFlow.kt index c0e870a8..79052b27 100644 --- a/enro-core/src/main/java/dev/enro/destination/flow/host/ComposableHostForManagedFlow.kt +++ b/enro-core/src/main/java/dev/enro/destination/flow/host/ComposableHostForManagedFlow.kt @@ -20,8 +20,11 @@ internal class ComposableHostForManagedFlowDestination : ComposableDestination() val viewModel = viewModel() LaunchedEffect(viewModel) { val key = owner.instruction.navigationKey - val binding = - owner.navigationController.bindingForKeyType(key::class) as ManagedFlowNavigationBinding + val binding = owner.navigationController.bindingForKeyType(key::class) + + @Suppress("UNCHECKED_CAST") + binding as ManagedFlowNavigationBinding + viewModel.bind(binding.destination(viewModel.getNavigationHandle().asTyped())) } val container = rememberNavigationContainer( diff --git a/enro-core/src/main/java/dev/enro/destination/flow/host/FragmentHostForManagedFlow.kt b/enro-core/src/main/java/dev/enro/destination/flow/host/FragmentHostForManagedFlow.kt index efce58af..b2d56da8 100644 --- a/enro-core/src/main/java/dev/enro/destination/flow/host/FragmentHostForManagedFlow.kt +++ b/enro-core/src/main/java/dev/enro/destination/flow/host/FragmentHostForManagedFlow.kt @@ -36,7 +36,7 @@ internal data class OpenManagedFlowInHiltFragment( override val instruction: AnyOpenInstruction, ) : AbstractOpenManagedFlowInFragmentKey() -public abstract class AbstractFragmentHostForManagedFlow : Fragment(), NavigationHost { +internal abstract class AbstractFragmentHostForManagedFlow : Fragment(), NavigationHost { private val navigation by navigationHandle() private val container by navigationContainer( diff --git a/enro-processor/src/main/java/dev/enro/processor/domain/DestinationReference.kt b/enro-processor/src/main/java/dev/enro/processor/domain/DestinationReference.kt index 1b87ce87..e4489c85 100644 --- a/enro-processor/src/main/java/dev/enro/processor/domain/DestinationReference.kt +++ b/enro-processor/src/main/java/dev/enro/processor/domain/DestinationReference.kt @@ -42,6 +42,9 @@ sealed class DestinationReference { val isSyntheticProvider = declaration is KSPropertyDeclaration && declaration.type.resolve().declaration.qualifiedName?.asString() == "dev.enro.core.synthetic.SyntheticDestinationProvider" + val isManagedFlowProvider = declaration is KSPropertyDeclaration && + declaration.type.resolve().declaration.qualifiedName?.asString() == "dev.enro.destination.flow.ManagedFlowDestinationProvider" + val isComposable = declaration is KSFunctionDeclaration && declaration.annotations .any { it.shortName.asString() == "Composable" } @@ -98,6 +101,24 @@ sealed class DestinationReference { } }.getOrNull() != null + val isManagedFlowProvider = element is ExecutableElement && runCatching { + val parent = (element.enclosingElement as TypeElement) + val actualName = element.simpleName.removeSuffix("\$annotations") + val managedFlowElement = parent.enclosedElements + .filterIsInstance() + .firstOrNull { actualName == it.simpleName.toString() && it != element } + + val managedFlowProviderMirror = processingEnv.elementUtils + .getTypeElement("dev.enro.destination.flow.ManagedFlowDestinationProvider") + .asType() + val erasedManagedFlowProvider = processingEnv.typeUtils.erasure(managedFlowProviderMirror) + val erasedReturnType = processingEnv.typeUtils.erasure(managedFlowElement!!.returnType) + + managedFlowElement.takeIf { + processingEnv.typeUtils.isSameType(erasedReturnType, erasedManagedFlowProvider) + } + }.getOrNull() != null + val isComposable = element is ExecutableElement && element.annotationMirrors .firstOrNull { @@ -116,10 +137,12 @@ sealed class DestinationReference { .let { "_${it}_GeneratedNavigationBinding" } val originalElement = element - val element = element.enclosingElement.takeIf { isSyntheticProvider } ?: element + val element = element.enclosingElement.takeIf { + isSyntheticProvider || isManagedFlowProvider + } ?: element init { - if (isComposable || isSyntheticProvider) { + if (isComposable || isSyntheticProvider || isManagedFlowProvider) { val isStatic = element.modifiers.contains(Modifier.STATIC) val parentIsObject = element.enclosingElement.enclosedElements .any { it.simpleName.toString() == "INSTANCE" } diff --git a/enro-processor/src/main/java/dev/enro/processor/extensions/ClassNames.kt b/enro-processor/src/main/java/dev/enro/processor/extensions/ClassNames.kt index ec511bf7..767123fc 100644 --- a/enro-processor/src/main/java/dev/enro/processor/extensions/ClassNames.kt +++ b/enro-processor/src/main/java/dev/enro/processor/extensions/ClassNames.kt @@ -48,6 +48,11 @@ object ClassNames { "SyntheticNavigationBindingKt" ) + val managedFlowNavigationBindingKt = JavaClassName.get( + "dev.enro.destination.flow", + "ManagedFlowNavigationBindingKt" + ) + val composableDestination = JavaClassName.get( "dev.enro.core.compose", "ComposableDestination" diff --git a/enro-processor/src/main/java/dev/enro/processor/generator/NavigationDestinationGenerator.kt b/enro-processor/src/main/java/dev/enro/processor/generator/NavigationDestinationGenerator.kt index 094e6c42..568025d6 100644 --- a/enro-processor/src/main/java/dev/enro/processor/generator/NavigationDestinationGenerator.kt +++ b/enro-processor/src/main/java/dev/enro/processor/generator/NavigationDestinationGenerator.kt @@ -117,6 +117,10 @@ object NavigationDestinationGenerator { "navigationModuleScope.syntheticDestination(%L)", requireNotNull(destination.declaration.simpleName).asString(), ) + destination.isManagedFlowProvider -> addCode( + "navigationModuleScope.managedFlowDestination(%L)", + requireNotNull(destination.declaration.simpleName).asString(), + ) destination.isComposable -> when { destination.isLegacyDialog -> addCode( "navigationModuleScope.legacyComposableDialogDestination<%T> { %L() }", @@ -259,6 +263,18 @@ object NavigationDestinationGenerator { JavaClassName.get(destination.keyType), JavaClassName.get(destination.element as TypeElement) ) + destination.isManagedFlowProvider -> JavaCodeBlock.of( + """ + navigationModuleScope.binding( + createManagedFlowNavigationBinding( + $1T.class, + $2T.${destination.originalElement.simpleName.removeSuffix("\$annotations")}() + ) + ) + """.trimIndent(), + JavaClassName.get(destination.keyType), + JavaClassName.get(destination.element as TypeElement) + ) destination.isComposable -> { val composableWrapper = ComposableWrapperGenerator.generate( processingEnv = processingEnv, @@ -309,6 +325,10 @@ fun JavaFile.Builder.addImportsForBinding(): JavaFile.Builder { ClassNames.Java.syntheticNavigationBindingKt, "createSyntheticNavigationBinding" ) + .addStaticImport( + ClassNames.Java.managedFlowNavigationBindingKt, + "createManagedFlowNavigationBinding" + ) .addStaticImport( ClassNames.Java.composeNavigationBindingKt, "createComposableNavigationBinding" @@ -333,6 +353,10 @@ fun FileSpec.Builder.addImportsForBinding(): FileSpec.Builder { "dev.enro.core.synthetic", "syntheticDestination" ) + .addImport( + "dev.enro.destination.flow", + "managedFlowDestination" + ) .addImport( "dev.enro.core.compose", "composableDestination" From b9408118e7c78c196569fadc23e65e9091364faf Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 26 Sep 2024 23:49:54 +1200 Subject: [PATCH 40/51] Manually trigger ManagedFlowViewModel's update based on the state of the container (to prevent re-delivering results every time the destination becomes visible) --- .../enro/destination/flow/ManagedFlowViewModel.kt | 3 +++ .../flow/host/ComposableHostForManagedFlow.kt | 12 ++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowViewModel.kt b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowViewModel.kt index bb2e090a..f53c32fa 100644 --- a/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowViewModel.kt +++ b/enro-core/src/main/java/dev/enro/destination/flow/ManagedFlowViewModel.kt @@ -22,6 +22,9 @@ internal class ManagedFlowViewModel : ViewModel() { flow.onCompleted = { destination.onCompleted(it) } + } + + internal fun updateFlow() { flow.update() } } diff --git a/enro-core/src/main/java/dev/enro/destination/flow/host/ComposableHostForManagedFlow.kt b/enro-core/src/main/java/dev/enro/destination/flow/host/ComposableHostForManagedFlow.kt index 79052b27..a4469fde 100644 --- a/enro-core/src/main/java/dev/enro/destination/flow/host/ComposableHostForManagedFlow.kt +++ b/enro-core/src/main/java/dev/enro/destination/flow/host/ComposableHostForManagedFlow.kt @@ -18,6 +18,10 @@ internal class ComposableHostForManagedFlowDestination : ComposableDestination() @Composable override fun Render() { val viewModel = viewModel() + val container = rememberNavigationContainer( + emptyBehavior = EmptyBehavior.CloseParent, + filter = acceptFromFlow(), + ) LaunchedEffect(viewModel) { val key = owner.instruction.navigationKey val binding = owner.navigationController.bindingForKeyType(key::class) @@ -26,11 +30,11 @@ internal class ComposableHostForManagedFlowDestination : ComposableDestination() binding as ManagedFlowNavigationBinding viewModel.bind(binding.destination(viewModel.getNavigationHandle().asTyped())) + // If the backstack is empty, we manually update the flow to start it, because we don't always want to + // update the flow when this destination is rendered, because that can cause a completed flow to + // immediately re-deliver its result. + if (container.backstack.isEmpty()) viewModel.updateFlow() } - val container = rememberNavigationContainer( - emptyBehavior = EmptyBehavior.CloseParent, - filter = acceptFromFlow(), - ) container.Render() } } \ No newline at end of file From 681ddad151207c25a15414995c088eb96c115d0b Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 26 Sep 2024 23:51:03 +1200 Subject: [PATCH 41/51] AbstractFragmentHostForManagedFlow didn't actually work, so fixed it by copying behaviour from the FragmentHostForComposable --- .../flow/host/FragmentHostForManagedFlow.kt | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/enro-core/src/main/java/dev/enro/destination/flow/host/FragmentHostForManagedFlow.kt b/enro-core/src/main/java/dev/enro/destination/flow/host/FragmentHostForManagedFlow.kt index b2d56da8..f3027e22 100644 --- a/enro-core/src/main/java/dev/enro/destination/flow/host/FragmentHostForManagedFlow.kt +++ b/enro-core/src/main/java/dev/enro/destination/flow/host/FragmentHostForManagedFlow.kt @@ -4,17 +4,23 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import dagger.hilt.android.AndroidEntryPoint import dev.enro.core.AnyOpenInstruction import dev.enro.core.EnroInternalNavigationKey +import dev.enro.core.NavigationContainerKey import dev.enro.core.NavigationHost import dev.enro.core.NavigationKey import dev.enro.core.R +import dev.enro.core.compose.rememberNavigationContainer import dev.enro.core.container.EmptyBehavior -import dev.enro.core.container.asPushInstruction -import dev.enro.core.fragment.container.navigationContainer +import dev.enro.core.container.acceptNone +import dev.enro.core.container.backstackOf import dev.enro.core.navigationHandle import kotlinx.parcelize.Parcelize @@ -39,19 +45,29 @@ internal data class OpenManagedFlowInHiltFragment( internal abstract class AbstractFragmentHostForManagedFlow : Fragment(), NavigationHost { private val navigation by navigationHandle() - private val container by navigationContainer( - containerId = R.id.enro_internal_single_fragment_frame_layout, - rootInstruction = { navigation.key.instruction.asPushInstruction() }, - emptyBehavior = EmptyBehavior.CloseParent, - ) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - return FrameLayout(requireContext()).apply { - id = R.id.enro_internal_single_fragment_frame_layout + val initialBackstack = navigation.key.instruction + return ComposeView(requireContext()).apply { + id = R.id.enro_internal_compose_fragment_view_id + setContent { + val composableContainer = rememberNavigationContainer( + key = NavigationContainerKey.FromName("FragmentHostForManagedFlow"), + initialBackstack = backstackOf(initialBackstack), + filter = acceptNone(), + emptyBehavior = EmptyBehavior.CloseParent, + ) + Box(modifier = Modifier.fillMaxSize()) { + composableContainer.Render() + } + SideEffect { + composableContainer.setActive() + } + } } } } From 9de4f2fba901445133cbca0a963374fb11bbac4e Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 26 Sep 2024 23:51:31 +1200 Subject: [PATCH 42/51] Updated documentation and method names for navigation interceptor builder related classes --- .../builder/NavigationInterceptorBuilder.kt | 41 +++++++++++++++++++ .../OnNavigationKeyClosedInterceptor.kt | 15 ++++++- ...avigationKeyClosedWithResultInterceptor.kt | 20 ++++++++- .../OnNavigationKeyOpenedInterceptor.kt | 9 ++++ .../compose/EmbeddedNavigationDestination.kt | 2 +- 5 files changed, 83 insertions(+), 4 deletions(-) diff --git a/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/NavigationInterceptorBuilder.kt b/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/NavigationInterceptorBuilder.kt index ae2ef405..9dd8c2ad 100644 --- a/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/NavigationInterceptorBuilder.kt +++ b/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/NavigationInterceptorBuilder.kt @@ -2,6 +2,7 @@ package dev.enro.core.controller.interceptor.builder import dev.enro.core.NavigationKey import dev.enro.core.controller.interceptor.NavigationInstructionInterceptor +import kotlin.reflect.KClass public class NavigationInterceptorBuilder internal constructor() { @@ -25,6 +26,9 @@ public class NavigationInterceptorBuilder internal constructor() { } } + /** + * Register an interceptor that will be called when a navigation key of KeyType is closed. + */ public inline fun onClosed( crossinline block: OnNavigationKeyClosedScope.(KeyType) -> InterceptorBehavior.ForClose ) { @@ -41,6 +45,9 @@ public class NavigationInterceptorBuilder internal constructor() { } } + /** + * Register an interceptor that will be called when a navigation key of KeyType is closed with a result. + */ public inline fun , reified T : Any> onResult( crossinline block: OnNavigationKeyClosedWithResultScope.(key: KeyType, result: T) -> InterceptorBehavior.ForResult ) { @@ -57,6 +64,40 @@ public class NavigationInterceptorBuilder internal constructor() { } } + /** + * Register an interceptor that will be called when a result is returned from a navigation key of KeyType. + * + * onResultFrom exists as a shortcut to avoid having to specify both the KeyType and the Result type when using onResult. + * + * For example with a navigation key "ExampleKey : NavigationKey.WithResult", instead of calling: + * ``` + * onResult { key, result -> ... } + * ``` + * + * you can instead call: + * ``` + * onResultFrom(ExampleKey::class) { key, result -> ... } + * ``` + * + * @see onResult + */ + public inline fun , reified T : Any> onResultFrom( + keyType: KClass, + crossinline block: OnNavigationKeyClosedWithResultScope.(key: KeyType, result: T) -> InterceptorBehavior.ForResult + ) { + interceptorBuilders += { + OnNavigationKeyClosedWithResultInterceptor( + matcher = { + it is KeyType + }, + action = { key, result -> + key as KeyType + block(key, result) + }, + ) + } + } + internal fun build(): NavigationInstructionInterceptor { val interceptors = interceptorBuilders.map { builder -> builder.invoke() diff --git a/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/OnNavigationKeyClosedInterceptor.kt b/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/OnNavigationKeyClosedInterceptor.kt index 8ed40864..acbd006a 100644 --- a/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/OnNavigationKeyClosedInterceptor.kt +++ b/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/OnNavigationKeyClosedInterceptor.kt @@ -1,15 +1,28 @@ package dev.enro.core.controller.interceptor.builder -import dev.enro.core.* +import dev.enro.core.AnyOpenInstruction +import dev.enro.core.NavigationContext +import dev.enro.core.NavigationInstruction +import dev.enro.core.NavigationKey import dev.enro.core.controller.interceptor.NavigationInstructionInterceptor +import dev.enro.core.readOpenInstruction public sealed class OnNavigationKeyClosedScope { + /** + * Cancel the close instruction, preventing the destination from being closed. + */ public fun cancelClose(): InterceptorBehavior.Cancel = InterceptorBehavior.Cancel() + /** + * Allow the close instruction to continue as normal. + */ public fun continueWithClose(): InterceptorBehavior.Continue = InterceptorBehavior.Continue() + /** + * Cancel the close instruction and instead execute the provide NavigationInstruction.Open + */ public fun replaceCloseWith(instruction: AnyOpenInstruction): InterceptorBehavior.ReplaceWith = InterceptorBehavior.ReplaceWith(instruction) } diff --git a/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/OnNavigationKeyClosedWithResultInterceptor.kt b/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/OnNavigationKeyClosedWithResultInterceptor.kt index a0ad3dc0..0af94dd5 100644 --- a/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/OnNavigationKeyClosedWithResultInterceptor.kt +++ b/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/OnNavigationKeyClosedWithResultInterceptor.kt @@ -1,20 +1,36 @@ package dev.enro.core.controller.interceptor.builder -import dev.enro.core.* +import dev.enro.core.AnyOpenInstruction +import dev.enro.core.NavigationContext +import dev.enro.core.NavigationInstruction +import dev.enro.core.NavigationKey import dev.enro.core.controller.get import dev.enro.core.controller.interceptor.NavigationInstructionInterceptor import dev.enro.core.controller.usecase.AddPendingResult +import dev.enro.core.readOpenInstruction public sealed class OnNavigationKeyClosedWithResultScope { - public fun cancelResult(): InterceptorBehavior.Cancel = + /** + * Cancel the close instruction, preventing the destination from being closed, and cancel the result being delivered. + */ + public fun cancelCloseAndResult(): InterceptorBehavior.Cancel = InterceptorBehavior.Cancel() + /** + * Cancel the close instruction, preventing the destination from being closed, but allow the result to be delivered. + */ public fun deliverResultAndCancelClose(): InterceptorBehavior.DeliverResultAndCancel = InterceptorBehavior.DeliverResultAndCancel() + /** + * Allow the close instruction to execute as normal, and the result to be delivered as normal. + */ public fun continueWithClose(): InterceptorBehavior.Continue = InterceptorBehavior.Continue() + /** + * Cancel the close instruction and prevent the result being delivered, and instead execute the provided NavigationInstruction.Open + */ public fun replaceCloseWith(instruction: AnyOpenInstruction): InterceptorBehavior.ReplaceWith = InterceptorBehavior.ReplaceWith(instruction) } diff --git a/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/OnNavigationKeyOpenedInterceptor.kt b/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/OnNavigationKeyOpenedInterceptor.kt index 6bf09237..7150b9bb 100644 --- a/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/OnNavigationKeyOpenedInterceptor.kt +++ b/enro-core/src/main/java/dev/enro/core/controller/interceptor/builder/OnNavigationKeyOpenedInterceptor.kt @@ -7,12 +7,21 @@ import dev.enro.core.NavigationKey import dev.enro.core.controller.interceptor.NavigationInstructionInterceptor public sealed class OnNavigationKeyOpenedScope { + /** + * Cancel the open instruction, preventing the destination from being opened. + */ public fun cancelNavigation(): InterceptorBehavior.Cancel = InterceptorBehavior.Cancel() + /** + * Allow the open instruction to continue as normal. + */ public fun continueWithNavigation(): InterceptorBehavior.Continue = InterceptorBehavior.Continue() + /** + * Cancel the open instruction and instead execute the provided NavigationInstruction.Open + */ public fun replaceNavigationWith(instruction: AnyOpenInstruction): InterceptorBehavior.ReplaceWith = InterceptorBehavior.ReplaceWith(instruction) } diff --git a/enro-core/src/main/java/dev/enro/destination/compose/EmbeddedNavigationDestination.kt b/enro-core/src/main/java/dev/enro/destination/compose/EmbeddedNavigationDestination.kt index 7c221392..5af09a8a 100644 --- a/enro-core/src/main/java/dev/enro/destination/compose/EmbeddedNavigationDestination.kt +++ b/enro-core/src/main/java/dev/enro/destination/compose/EmbeddedNavigationDestination.kt @@ -59,7 +59,7 @@ public inline fun EmbeddedNavigationDestination( onResult, T> { key, result -> if (key != navigationKey) return@onResult continueWithClose() rememberedOnResult.value.invoke(result) - cancelResult() + cancelCloseAndResult() } } ) From a4b38a5a58d68106a5b258c5579483b03f4fdcf7 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Fri, 27 Sep 2024 00:30:13 +1200 Subject: [PATCH 43/51] Added examples and tests to the test application for ManagedFlowDestinations --- .../application/SelectDestinationRobot.kt | 20 ++ .../ManagedFlowInComposableRobot.kt | 48 ++++ .../ManagedFlowInComposableTest.kt | 59 +++++ .../managedflow/ManagedFlowInFragmentRobot.kt | 48 ++++ .../managedflow/ManagedFlowInFragmentTest.kt | 60 +++++ .../managedflow/UserInformationRobot.kt | 64 +++++ .../application/src/main/AndroidManifest.xml | 1 + .../managedflow/ManagedFlowInComposable.kt | 61 +++++ .../managedflow/ManagedFlowInFragment.kt | 87 ++++++ .../managedflow/UserInformationFlow.kt | 248 ++++++++++++++++++ .../managed_flow_in_fragment_activity.xml | 12 + 11 files changed, 708 insertions(+) create mode 100644 tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInComposableRobot.kt create mode 100644 tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInComposableTest.kt create mode 100644 tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInFragmentRobot.kt create mode 100644 tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInFragmentTest.kt create mode 100644 tests/application/src/androidTest/java/dev/enro/test/application/managedflow/UserInformationRobot.kt create mode 100644 tests/application/src/main/java/dev/enro/tests/application/managedflow/ManagedFlowInComposable.kt create mode 100644 tests/application/src/main/java/dev/enro/tests/application/managedflow/ManagedFlowInFragment.kt create mode 100644 tests/application/src/main/java/dev/enro/tests/application/managedflow/UserInformationFlow.kt create mode 100644 tests/application/src/main/res/layout/managed_flow_in_fragment_activity.xml diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt b/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt index 16e76201..638932eb 100644 --- a/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt +++ b/tests/application/src/androidTest/java/dev/enro/test/application/SelectDestinationRobot.kt @@ -21,6 +21,8 @@ import dev.enro.test.application.compose.results.ComposeMixedResultTypesRobot import dev.enro.test.application.compose.results.ComposeNestedResultsRobot import dev.enro.test.application.compose.results.ResultsWithExtraRobot import dev.enro.test.application.fragment.UnboundBottomSheetRobot +import dev.enro.test.application.managedflow.ManagedFlowInComposableRobot +import dev.enro.test.application.managedflow.ManagedFlowInFragmentRobot import dev.enro.tests.application.SelectDestination class SelectDestinationRobot( @@ -182,4 +184,22 @@ class SelectDestinationRobot( .performClick() return ComposeMixedResultTypesRobot(composeRule) } + + fun openManagedFlowInComposable(): ManagedFlowInComposableRobot { + composeRule.onNode(hasText("Managed Flow In Composable")) + .performScrollTo() + .onSiblings() + .filterToOne(hasText("Push")) + .performClick() + return ManagedFlowInComposableRobot(composeRule) + } + + fun openManagedFlowInFragment(): ManagedFlowInFragmentRobot { + composeRule.onNode(hasText("Managed Flow In Fragment")) + .performScrollTo() + .onSiblings() + .filterToOne(hasText("Present")) + .performClick() + return ManagedFlowInFragmentRobot(composeRule) + } } \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInComposableRobot.kt b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInComposableRobot.kt new file mode 100644 index 00000000..dd37502e --- /dev/null +++ b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInComposableRobot.kt @@ -0,0 +1,48 @@ +package dev.enro.test.application.managedflow + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithText +import dev.enro.test.application.waitForNavigationHandle +import dev.enro.tests.application.managedflow.ManagedFlowInComposable + +class ManagedFlowInComposableRobot( + private val composeRule: ComposeTestRule +) { + init { + composeRule.waitForNavigationHandle { + it.key is ManagedFlowInComposable + } + } + + fun getUserInformationFlow(): UserInformationRobot { + return UserInformationRobot(composeRule) + } + + fun getDisplayUserInformationFlow(): DisplayUserInformationRobot { + return DisplayUserInformationRobot(composeRule) + } + + class DisplayUserInformationRobot( + private val composeRule: ComposeTestRule + ) { + init { + composeRule.waitForNavigationHandle { + it.key is ManagedFlowInComposable.DisplayUserInformation + } + } + + fun assertUserInformationDisplayed( + name: String, + email: String, + age: String + ) : DisplayUserInformationRobot { + composeRule.onNodeWithText("Name: $name") + .assertExists() + composeRule.onNodeWithText("Email: $email") + .assertExists() + composeRule.onNodeWithText("Age: $age") + .assertExists() + return this + } + } +} \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInComposableTest.kt b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInComposableTest.kt new file mode 100644 index 00000000..6931f390 --- /dev/null +++ b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInComposableTest.kt @@ -0,0 +1,59 @@ +package dev.enro.test.application.managedflow + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.espresso.Espresso +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.enro.test.application.SelectDestinationRobot +import dev.enro.tests.application.TestActivity +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ManagedFlowInComposableTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun test() { + SelectDestinationRobot(composeRule) + .openManagedFlowInComposable() + .apply { + getUserInformationFlow() + .enterName("John Doe") + .continueToNextStep() + .enterEmail("asdasd") + .continueToNextStep() + .dismissErrorDialog() + .enterEmail("john@doe.com") + .continueToNextStep() + .enterAge("twenty five") + .continueToNextStep() + .dismissErrorDialog() + .enterAge("25") + .continueToNextStep() + } + .apply { + getDisplayUserInformationFlow() + .assertUserInformationDisplayed( + name = "John Doe", + email = "john@doe.com", + age = "25", + ) + } + .apply { + Espresso.pressBack() + getUserInformationFlow() + .enterAge("35") + .continueToNextStep() + } + .apply { + getDisplayUserInformationFlow() + .assertUserInformationDisplayed( + name = "John Doe", + email = "john@doe.com", + age = "35", + ) + } + } +} \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInFragmentRobot.kt b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInFragmentRobot.kt new file mode 100644 index 00000000..e831a2b2 --- /dev/null +++ b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInFragmentRobot.kt @@ -0,0 +1,48 @@ +package dev.enro.test.application.managedflow + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithText +import dev.enro.test.application.waitForNavigationHandle +import dev.enro.tests.application.managedflow.ManagedFlowInFragment + +class ManagedFlowInFragmentRobot( + private val composeRule: ComposeTestRule +) { + init { + composeRule.waitForNavigationHandle { + it.key is ManagedFlowInFragment + } + } + + fun getUserInformationFlow(): UserInformationRobot { + return UserInformationRobot(composeRule) + } + + fun getResultFragment(): ResultFragmentRobot { + return ResultFragmentRobot(composeRule) + } + + class ResultFragmentRobot( + private val composeRule: ComposeTestRule + ) { + init { + composeRule.waitForNavigationHandle { + it.key is ManagedFlowInFragment.ResultFragment + } + } + + fun assertUserInformationDisplayed( + name: String, + email: String, + age: String + ): ResultFragmentRobot { + composeRule.onNodeWithText("Name: $name") + .assertExists() + composeRule.onNodeWithText("Email: $email") + .assertExists() + composeRule.onNodeWithText("Age: $age") + .assertExists() + return this + } + } +} \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInFragmentTest.kt b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInFragmentTest.kt new file mode 100644 index 00000000..192e544a --- /dev/null +++ b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInFragmentTest.kt @@ -0,0 +1,60 @@ +package dev.enro.test.application.managedflow + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.espresso.Espresso +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.enro.test.application.SelectDestinationRobot +import dev.enro.tests.application.TestActivity +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + + +@RunWith(AndroidJUnit4::class) +class ManagedFlowInFragmentTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun test() { + SelectDestinationRobot(composeRule) + .openManagedFlowInFragment() + .apply { + getUserInformationFlow() + .enterName("John Doe") + .continueToNextStep() + .enterEmail("asdasd") + .continueToNextStep() + .dismissErrorDialog() + .enterEmail("john@doe.com") + .continueToNextStep() + .enterAge("twenty five") + .continueToNextStep() + .dismissErrorDialog() + .enterAge("25") + .continueToNextStep() + } + .apply { + getResultFragment() + .assertUserInformationDisplayed( + name = "John Doe", + email = "john@doe.com", + age = "25", + ) + } + .apply { + Espresso.pressBack() + getUserInformationFlow() + .enterAge("35") + .continueToNextStep() + } + .apply { + getResultFragment() + .assertUserInformationDisplayed( + name = "John Doe", + email = "john@doe.com", + age = "35", + ) + } + } +} \ No newline at end of file diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/UserInformationRobot.kt b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/UserInformationRobot.kt new file mode 100644 index 00000000..770f730c --- /dev/null +++ b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/UserInformationRobot.kt @@ -0,0 +1,64 @@ +@file:OptIn(ExperimentalTestApi::class) + +package dev.enro.test.application.managedflow + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import dev.enro.test.application.waitForNavigationHandle +import dev.enro.tests.application.managedflow.UserInformationFlow + +class UserInformationRobot( + private val composeRule: ComposeTestRule +) { + init { + composeRule.waitForNavigationHandle { + it.key is UserInformationFlow + } + } + + fun enterName( + name: String + ): UserInformationRobot { + composeRule.waitUntilAtLeastOneExists(hasText("What's your name?")) + composeRule.onNodeWithTag("UserInformationFlow.GetName.TextField") + .performTextReplacement(name) + return this + } + + fun enterEmail( + email: String + ): UserInformationRobot { + composeRule.waitUntilAtLeastOneExists(hasText("What's your email?")) + composeRule.onNodeWithTag("UserInformationFlow.GetEmail.TextField") + .performTextReplacement(email) + return this + } + + fun enterAge( + age: String + ): UserInformationRobot { + composeRule.waitUntilAtLeastOneExists(hasText("How old are you?")) + composeRule.onNodeWithTag("UserInformationFlow.GetAge.TextField") + .performTextReplacement(age) + return this + } + + fun dismissErrorDialog(): UserInformationRobot { + composeRule.waitUntilAtLeastOneExists(hasTestTag("UserInformationFlow.ErrorDialog.OK")) + composeRule.onNodeWithTag("UserInformationFlow.ErrorDialog.OK") + .performClick() + return this + } + + fun continueToNextStep(): UserInformationRobot { + composeRule.onNodeWithText("Continue") + .performClick() + return this + } +} \ No newline at end of file diff --git a/tests/application/src/main/AndroidManifest.xml b/tests/application/src/main/AndroidManifest.xml index 929728e3..d83e4377 100644 --- a/tests/application/src/main/AndroidManifest.xml +++ b/tests/application/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ + \ No newline at end of file diff --git a/tests/application/src/main/java/dev/enro/tests/application/managedflow/ManagedFlowInComposable.kt b/tests/application/src/main/java/dev/enro/tests/application/managedflow/ManagedFlowInComposable.kt new file mode 100644 index 00000000..d0c37480 --- /dev/null +++ b/tests/application/src/main/java/dev/enro/tests/application/managedflow/ManagedFlowInComposable.kt @@ -0,0 +1,61 @@ +package dev.enro.tests.application.managedflow + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.enro.annotations.NavigationDestination +import dev.enro.core.NavigationKey +import dev.enro.core.asPush +import dev.enro.core.compose.navigationHandle +import dev.enro.core.compose.rememberNavigationContainer +import dev.enro.core.container.EmptyBehavior +import dev.enro.core.container.accept +import dev.enro.tests.application.compose.common.TitledColumn +import kotlinx.parcelize.Parcelize + +@Parcelize +object ManagedFlowInComposable : NavigationKey.SupportsPush { + @Parcelize + internal class DisplayUserInformation( + val userInformation: UserInformation, + ) : NavigationKey.SupportsPush +} + +@Composable +@NavigationDestination(ManagedFlowInComposable::class) +fun ManagedFlowInComposableScreen() { + val container = rememberNavigationContainer( + emptyBehavior = EmptyBehavior.CloseParent, + root = UserInformationFlow(), + filter = accept { + key() + key() + }, + interceptor = { + onResult { _, result -> + replaceCloseWith( + ManagedFlowInComposable.DisplayUserInformation(result).asPush() + ) + } + } + ) + Box(modifier = Modifier.fillMaxSize()) { + container.Render() + } +} + +@NavigationDestination(ManagedFlowInComposable.DisplayUserInformation::class) +@Composable +fun DisplayUserInformationScreen() { + val navigation = navigationHandle() + TitledColumn( + title = "User Information", + modifier = Modifier.fillMaxSize() + ) { + Text("Name: ${navigation.key.userInformation.name}") + Text("Email: ${navigation.key.userInformation.email}") + Text("Age: ${navigation.key.userInformation.age}") + } +} \ No newline at end of file diff --git a/tests/application/src/main/java/dev/enro/tests/application/managedflow/ManagedFlowInFragment.kt b/tests/application/src/main/java/dev/enro/tests/application/managedflow/ManagedFlowInFragment.kt new file mode 100644 index 00000000..1bc8c8ee --- /dev/null +++ b/tests/application/src/main/java/dev/enro/tests/application/managedflow/ManagedFlowInFragment.kt @@ -0,0 +1,87 @@ +package dev.enro.tests.application.managedflow + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import dev.enro.annotations.NavigationDestination +import dev.enro.core.NavigationKey +import dev.enro.core.container.EmptyBehavior +import dev.enro.core.container.accept +import dev.enro.core.fragment.container.navigationContainer +import dev.enro.core.navigationHandle +import dev.enro.core.push +import dev.enro.core.result.registerForNavigationResult +import dev.enro.test.application.R +import dev.enro.tests.application.compose.common.TitledColumn +import kotlinx.parcelize.Parcelize + +@Parcelize +object ManagedFlowInFragment : NavigationKey.SupportsPresent { + @Parcelize + internal class ResultFragment( + val userInformation: UserInformation, + ) : NavigationKey.SupportsPush +} + +@NavigationDestination(ManagedFlowInFragment::class) +class ManagedFlowInFragmentActivity : AppCompatActivity() { + private val navigation by navigationHandle() + private val container by navigationContainer( + containerId = R.id.fragment_container, + root = { null }, + emptyBehavior = EmptyBehavior.CloseParent, + filter = accept { + key() + key() + }, + interceptor = { + onResult { _, _ -> + deliverResultAndCancelClose() + } + } + ) + + private val getUserInformation by registerForNavigationResult { + navigation.push(ManagedFlowInFragment.ResultFragment(it)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.managed_flow_in_fragment_activity) + } + + override fun onResume() { + super.onResume() + if (container.backstack.isEmpty()) { + getUserInformation.push(UserInformationFlow()) + } + } +} + +@NavigationDestination(ManagedFlowInFragment.ResultFragment::class) +class UserInformationResultFragment : Fragment() { + + private val navigation by navigationHandle() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return ComposeView(requireContext()).apply { + setContent { + TitledColumn( + title = "User Information", + modifier = Modifier.fillMaxSize() + ) { + Text("Name: ${navigation.key.userInformation.name}") + Text("Email: ${navigation.key.userInformation.email}") + Text("Age: ${navigation.key.userInformation.age}") + } + } + } + } +} \ No newline at end of file diff --git a/tests/application/src/main/java/dev/enro/tests/application/managedflow/UserInformationFlow.kt b/tests/application/src/main/java/dev/enro/tests/application/managedflow/UserInformationFlow.kt new file mode 100644 index 00000000..d1a78d70 --- /dev/null +++ b/tests/application/src/main/java/dev/enro/tests/application/managedflow/UserInformationFlow.kt @@ -0,0 +1,248 @@ +package dev.enro.tests.application.managedflow + +import android.os.Parcelable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import dev.enro.annotations.ExperimentalEnroApi +import dev.enro.annotations.NavigationDestination +import dev.enro.core.NavigationKey +import dev.enro.core.close +import dev.enro.core.closeWithResult +import dev.enro.core.compose.dialog.DialogDestination +import dev.enro.core.compose.navigationHandle +import dev.enro.core.present +import dev.enro.destination.flow.managedFlowDestination +import dev.enro.tests.application.compose.common.TitledColumn +import kotlinx.parcelize.Parcelize + +@Parcelize +internal data class UserInformation( + val name: String, + val email: String, + val age: Int, +) : Parcelable + +@Parcelize +internal class UserInformationFlow : NavigationKey.SupportsPush.WithResult { + @Parcelize + internal class GetName : NavigationKey.SupportsPush.WithResult + + @Parcelize + internal class GetEmail : NavigationKey.SupportsPush.WithResult + + @Parcelize + internal class GetAge : NavigationKey.SupportsPush.WithResult + + @Parcelize + internal class ErrorDialog( + internal val message: String + ) : NavigationKey.SupportsPresent +} + +@OptIn(ExperimentalEnroApi::class) +@NavigationDestination(UserInformationFlow::class) +internal val userInformationFlow = managedFlowDestination() + .flow { + val name = push { UserInformationFlow.GetName() } + val email = push { UserInformationFlow.GetEmail() } + val age = push { UserInformationFlow.GetAge() } + + UserInformation(name, email, age) + } + .onComplete { result -> + navigation.closeWithResult(result) + } + +@Composable +@NavigationDestination(UserInformationFlow.GetName::class) +internal fun GetNameScreen() { + val navigation = navigationHandle() + var text by rememberSaveable { mutableStateOf("") } + + fun onDone() { + if (text.length < 3) { + navigation.present(UserInformationFlow.ErrorDialog("Name must be at least 3 characters")) + return + } + + navigation.closeWithResult(text) + } + + TitledColumn("What's your name?") { + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .testTag("UserInformationFlow.GetName.TextField"), + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { onDone() } + ), + value = text, + onValueChange = { + text = it + } + ) + + Button( + onClick = { onDone() } + ) { + Text("Continue") + } + } +} + +@Composable +@NavigationDestination(UserInformationFlow.GetEmail::class) +internal fun GetEmailScreen() { + val navigation = navigationHandle() + var text by rememberSaveable { mutableStateOf("") } + fun onDone() { + if (text.count { it == '@' } != 1) { + navigation.present(UserInformationFlow.ErrorDialog("Email must contain one @ symbol")) + return + } + if (text.count { it.isWhitespace() } > 0) { + navigation.present(UserInformationFlow.ErrorDialog("Email must not contain whitespace")) + return + } + + val beforeAt = text.split("@").first() + if (beforeAt.isEmpty()) { + navigation.present(UserInformationFlow.ErrorDialog("Email must contain characters before the @ symbol")) + return + } + + val afterAt = text.split("@").last() + val afterAtSplit = afterAt.split(".") + if (afterAtSplit.size < 2) { + navigation.present(UserInformationFlow.ErrorDialog("Email must contain a '.' after the @ symbol")) + return + } + if (afterAtSplit.any { it.isEmpty() }) { + navigation.present(UserInformationFlow.ErrorDialog("After the @ symbol, there must be characters before and after each '.', and you cannot repeat '.' symbols")) + return + } + + navigation.closeWithResult(text) + } + + TitledColumn("What's your email?") { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .testTag("UserInformationFlow.GetEmail.TextField"), + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { onDone() } + ), + value = text, + onValueChange = { + text = it + } + ) + + Button( + onClick = { onDone() } + ) { + Text("Continue") + } + } +} + +@Composable +@NavigationDestination(UserInformationFlow.GetAge::class) +internal fun GetAgeScreen() { + val navigation = navigationHandle() + var text by rememberSaveable { mutableStateOf("") } + + fun onDone() { + val age = text.toIntOrNull() + if (age == null) { + navigation.present(UserInformationFlow.ErrorDialog("Age must be a number")) + return + } + if (age < 0) { + navigation.present(UserInformationFlow.ErrorDialog("Age must be a positive number")) + return + } + if (age < 16) { + navigation.present(UserInformationFlow.ErrorDialog("You should probably be 16 or older to use this app")) + return + } + navigation.closeWithResult(age) + } + + TitledColumn("How old are you?") { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .testTag("UserInformationFlow.GetAge.TextField"), + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { onDone() } + ), + value = text, + onValueChange = { + text = it + } + ) + + Button( + onClick = { onDone() } + ) { + Text("Continue") + } + } +} + +@Composable +@NavigationDestination(UserInformationFlow.ErrorDialog::class) +internal fun ErrorDialogScreen() { + val navigation = navigationHandle() + DialogDestination { + AlertDialog( + onDismissRequest = { + navigation.close() + }, + title = { + Text("Error") + }, + text = { + Text(navigation.key.message) + }, + confirmButton = { + Button( + modifier = Modifier.testTag("UserInformationFlow.ErrorDialog.OK"), + onClick = { + navigation.close() + } + ) { + Text("OK") + } + } + ) + } +} diff --git a/tests/application/src/main/res/layout/managed_flow_in_fragment_activity.xml b/tests/application/src/main/res/layout/managed_flow_in_fragment_activity.xml new file mode 100644 index 00000000..87300766 --- /dev/null +++ b/tests/application/src/main/res/layout/managed_flow_in_fragment_activity.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file From 6838615d66ffbd4c44fe4a4f6ccec965c14bab1c Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Fri, 27 Sep 2024 00:34:30 +1200 Subject: [PATCH 44/51] Update CHANGELOG.md for `managedFlowDestination` --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 454b8f3d..1f997c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ * Removed `isAnimating` from `ComposableNavigationContainer`, as it was unused internally, did not appear to be useful for external use cases, and was complicating Compose animation code. If this functionality *was* important to your use case, please create a Github issue to discuss your use case. * Removed the requirement to provide a SavedStateHandle to `registerForFlowResult`. This should not affect any existing code, but if you were passing a SavedStateHandle to `registerForFlowResult`, you can now remove this parameter. * NavigationHandles now have access to a SavedStateHandle internally, which removes the requirement to pass this through to `registerForFlowResult` +* Added `managedFlowDestination` as a way to create a managed flow as a standalone destination + * `managedFlowDestination` works in the same way you'd use `registerForFlowResult` to create a managed flow, but allows you to define the flow as a standalone destination that can be pushed or presented from other destinations, without the need to define a ViewModel and regular destination for the flow. + * `managedFlowDestination` is currently marked as an `@ExperimentalEnroApi`, and may be subject to change in future versions of Enro. + * For an example of a `managedFlowDestination`, see `dev.enro.tests.application.managedflow.UserInformationFlow` in the test application * ⚠️ Updated result channel identifiers in preparation for Kotlin 2.0 ⚠️ * Kotlin 2.0 changes the way that lambdas are compiled, which has implications for `registerForNavigationResult` and how result channels are uniquely identified. Activites, Fragments, Composables and ViewModels that use `by registerForNavigationResult` directly will not be affected by this change. However, if you are creating result channels inside of other objects, such as delegates, helper objects, or extension functions, you should verify that these cases continue to work as expected. It is not expected that there will be issues, but if this does result in bugs in your application, please raise them on the Enro GitHub repository. From ad892151d5aadac8acb4bb3195aaf0b6440884dc Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Fri, 27 Sep 2024 00:55:42 +1200 Subject: [PATCH 45/51] Skip ManagedFlowInComposableTests for API 30 --- .../application/managedflow/ManagedFlowInComposableTest.kt | 5 +++++ .../application/managedflow/ManagedFlowInFragmentTest.kt | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInComposableTest.kt b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInComposableTest.kt index 6931f390..0c186714 100644 --- a/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInComposableTest.kt +++ b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInComposableTest.kt @@ -1,5 +1,6 @@ package dev.enro.test.application.managedflow +import android.os.Build import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.espresso.Espresso import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -16,6 +17,10 @@ class ManagedFlowInComposableTest { @Test fun test() { + // This test appears flaky on SDK 30, but passes locally + if (Build.VERSION.SDK_INT == 30) { + return + } SelectDestinationRobot(composeRule) .openManagedFlowInComposable() .apply { diff --git a/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInFragmentTest.kt b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInFragmentTest.kt index 192e544a..d871839e 100644 --- a/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInFragmentTest.kt +++ b/tests/application/src/androidTest/java/dev/enro/test/application/managedflow/ManagedFlowInFragmentTest.kt @@ -1,5 +1,6 @@ package dev.enro.test.application.managedflow +import android.os.Build import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.espresso.Espresso import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -17,6 +18,10 @@ class ManagedFlowInFragmentTest { @Test fun test() { + // This test appears flaky on SDK 30, but passes locally + if (Build.VERSION.SDK_INT == 30) { + return + } SelectDestinationRobot(composeRule) .openManagedFlowInFragment() .apply { From eaaa6310752df6a6d1842c8026787d3ba73b6892 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 26 Sep 2024 13:21:21 +0000 Subject: [PATCH 46/51] Released 2.8.0-rc04 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 24c4ac67..98385273 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -versionName=2.8.0-rc03 -versionCode=108 \ No newline at end of file +versionName=2.8.0-rc04 +versionCode=109 \ No newline at end of file From 08b89d20cd529f0c5ead2e10d9c18606441ae106 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Thu, 24 Oct 2024 07:20:15 +1300 Subject: [PATCH 47/51] Updated NavigationFlowScope to use a "FlowStepLambda" anonymous class for lambda identification. --- .../core/result/flows/NavigationFlowScope.kt | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/enro-core/src/main/java/dev/enro/core/result/flows/NavigationFlowScope.kt b/enro-core/src/main/java/dev/enro/core/result/flows/NavigationFlowScope.kt index 0445fa3f..ee1febad 100644 --- a/enro-core/src/main/java/dev/enro/core/result/flows/NavigationFlowScope.kt +++ b/enro-core/src/main/java/dev/enro/core/result/flows/NavigationFlowScope.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.launch +import java.io.Serializable public open class NavigationFlowScope internal constructor( @PublishedApi @@ -27,7 +28,11 @@ public open class NavigationFlowScope internal constructor( noinline block: FlowStepBuilderScope.() -> NavigationKey.SupportsPush.WithResult, ): T = step( direction = NavigationDirection.Push, - block = block, + block = object : FlowStepLambda { + override fun FlowStepBuilderScope.invoke(): NavigationKey.WithResult { + return block() + } + }, ) public inline fun pushWithExtras( @@ -41,7 +46,11 @@ public open class NavigationFlowScope internal constructor( noinline block: FlowStepBuilderScope.() -> NavigationKey.SupportsPresent.WithResult, ): T = step( direction = NavigationDirection.Present, - block = block, + block = object : FlowStepLambda { + override fun FlowStepBuilderScope.invoke(): NavigationKey.WithResult { + return block() + } + }, ) public inline fun presentWithExtras( @@ -135,12 +144,12 @@ public open class NavigationFlowScope internal constructor( @PublishedApi internal inline fun step( direction: NavigationDirection, - noinline block: FlowStepBuilderScope.() -> NavigationKey.WithResult, + block: FlowStepLambda, ) : T { val baseId = block::class.java.name val count = steps.count { it.stepId.startsWith(baseId) } val builder = FlowStepBuilder() - val key = builder.scope.run(block) + val key = block.run { builder.scope.invoke() } val step = builder.build( stepId = "$baseId@$count", navigationKey = key, @@ -189,3 +198,12 @@ public open class NavigationFlowScope internal constructor( @PublishedApi internal class Escape : RuntimeException() } + + +// TODO create a re-usable identifiable lambda class for more than just flow steps +@PublishedApi +internal interface FlowStepLambda : Serializable { + fun FlowStepBuilderScope.invoke(): NavigationKey.WithResult +} + + From 30bb6cb8a527c57296ac76ddda29ae55ff520122 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Wed, 23 Oct 2024 18:38:41 +0000 Subject: [PATCH 48/51] Released 2.8.0-rc05 --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index 98385273..2cdeed7a 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -versionName=2.8.0-rc04 -versionCode=109 \ No newline at end of file +versionName=2.8.0-rc05 +versionCode=110 \ No newline at end of file From 1b80ce2a881eb6720c598574c17b2d980b4886ad Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Wed, 13 Nov 2024 17:38:06 +1300 Subject: [PATCH 49/51] Update JVM target related things --- .run/Enro [disableConnectedDeviceAnimations].run.xml | 1 + .run/Enro [enableConnectedDeviceAnimations].run.xml | 1 + buildSrc/build.gradle.kts | 7 +++++++ buildSrc/src/main/kotlin/configureAndroid.kt | 11 ++++++----- buildSrc/src/main/kotlin/configureExplicitApi.kt | 6 ++++-- enro-test/build.gradle.kts | 7 +++++-- enro/build.gradle.kts | 7 +++++-- 7 files changed, 29 insertions(+), 11 deletions(-) diff --git a/.run/Enro [disableConnectedDeviceAnimations].run.xml b/.run/Enro [disableConnectedDeviceAnimations].run.xml index b67e95ec..fd9d1c87 100644 --- a/.run/Enro [disableConnectedDeviceAnimations].run.xml +++ b/.run/Enro [disableConnectedDeviceAnimations].run.xml @@ -18,6 +18,7 @@ true true false + false \ No newline at end of file diff --git a/.run/Enro [enableConnectedDeviceAnimations].run.xml b/.run/Enro [enableConnectedDeviceAnimations].run.xml index b95cb72d..acbb097e 100644 --- a/.run/Enro [enableConnectedDeviceAnimations].run.xml +++ b/.run/Enro [enableConnectedDeviceAnimations].run.xml @@ -18,6 +18,7 @@ true true false + false \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index aad9e119..10bed707 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + repositories { mavenLocal() google() @@ -12,6 +14,11 @@ java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } +tasks.withType() { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} dependencies { implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) diff --git a/buildSrc/src/main/kotlin/configureAndroid.kt b/buildSrc/src/main/kotlin/configureAndroid.kt index 0670f7fc..54c2d71b 100644 --- a/buildSrc/src/main/kotlin/configureAndroid.kt +++ b/buildSrc/src/main/kotlin/configureAndroid.kt @@ -8,6 +8,7 @@ import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.the import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.FileInputStream import java.util.Properties @@ -72,17 +73,17 @@ private fun Project.commonAndroidConfig( } tasks.withType() { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) // We want to disable the automatic inclusion of the `dev.enro.annotations.AdvancedEnroApi` and `dev.enro.annotations.ExperimentalEnroApi` // opt-ins when we're compiling the test application, so that we're not accidentally making changes that might break the public API by // requiring the opt-ins. if (path.startsWith(":tests:application")) { - return@kotlinOptions + return@compilerOptions } - freeCompilerArgs += "-Xopt-in=dev.enro.annotations.AdvancedEnroApi" - freeCompilerArgs += "-Xopt-in=dev.enro.annotations.ExperimentalEnroApi" + freeCompilerArgs.add("-Xopt-in=dev.enro.annotations.AdvancedEnroApi") + freeCompilerArgs.add("-Xopt-in=dev.enro.annotations.ExperimentalEnroApi") } } diff --git a/buildSrc/src/main/kotlin/configureExplicitApi.kt b/buildSrc/src/main/kotlin/configureExplicitApi.kt index 81847d99..b97384b5 100644 --- a/buildSrc/src/main/kotlin/configureExplicitApi.kt +++ b/buildSrc/src/main/kotlin/configureExplicitApi.kt @@ -1,12 +1,14 @@ import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile fun Project.configureExplicitApi() { tasks.withType() { - kotlinOptions { - freeCompilerArgs += "-Xexplicit-api=strict" + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.add("-Xexplicit-api=strict") } } } \ No newline at end of file diff --git a/enro-test/build.gradle.kts b/enro-test/build.gradle.kts index 40da8183..ed7a49d4 100644 --- a/enro-test/build.gradle.kts +++ b/enro-test/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id("com.android.library") id("kotlin-android") @@ -6,8 +8,9 @@ configureAndroidLibrary("dev.enro.test") configureAndroidPublishing("dev.enro:enro-test") tasks.withType() { - kotlinOptions { - freeCompilerArgs += "-Xfriend-paths=../enro-core/src/main" + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.add("-Xfriend-paths=../enro-core/src/main") } } diff --git a/enro/build.gradle.kts b/enro/build.gradle.kts index 69c174fb..b899834b 100644 --- a/enro/build.gradle.kts +++ b/enro/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id("com.google.devtools.ksp") id("com.android.library") @@ -24,8 +26,9 @@ android { } tasks.withType() { - kotlinOptions { - freeCompilerArgs += "-Xfriend-paths=../enro-core/src/main" + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.add("-Xfriend-paths=../enro-core/src/main") } } From f55f1457c54de8a55c7b1f73f4e55b65c5371809 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Fri, 15 Nov 2024 18:06:30 +1300 Subject: [PATCH 50/51] Update JVM targets that were missed in previous changes --- enro-lint/build.gradle.kts | 8 +++++++- enro-processor/build.gradle.kts | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/enro-lint/build.gradle.kts b/enro-lint/build.gradle.kts index 26e07c67..bc71155c 100644 --- a/enro-lint/build.gradle.kts +++ b/enro-lint/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id("java-library") id("kotlin") @@ -16,4 +18,8 @@ java { attributes("Lint-Registry-v2" to "dev.enro.lint.EnroIssueRegistry") } } - +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} diff --git a/enro-processor/build.gradle.kts b/enro-processor/build.gradle.kts index 5bc1d659..31b80841 100644 --- a/enro-processor/build.gradle.kts +++ b/enro-processor/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id("java-library") id("kotlin") @@ -30,4 +32,10 @@ afterEvaluate { java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } } \ No newline at end of file From 2de8c1c0440780c2a74e518c30b0131e49088b42 Mon Sep 17 00:00:00 2001 From: Isaac Udy Date: Fri, 15 Nov 2024 18:06:54 +1300 Subject: [PATCH 51/51] Update warning message for "MissingNavigationDestinationAnnotation" --- enro-lint/src/main/java/dev/enro/lint/EnroIssueDetector.kt | 2 +- enro-lint/src/main/java/dev/enro/lint/Issues.kt | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/enro-lint/src/main/java/dev/enro/lint/EnroIssueDetector.kt b/enro-lint/src/main/java/dev/enro/lint/EnroIssueDetector.kt index 6b7c469a..e03169c5 100644 --- a/enro-lint/src/main/java/dev/enro/lint/EnroIssueDetector.kt +++ b/enro-lint/src/main/java/dev/enro/lint/EnroIssueDetector.kt @@ -120,7 +120,7 @@ class EnroIssueDetector : Detector(), Detector.UastScanner { context.report( issue = missingNavigationDestinationAnnotationCompose, location = context.getLocation(node), - message = "${composableParent.name} is not marked as a NavigationDestination.\nRequesting a TypedNavigationHandle in this way may be cause a crash if ${composableParent.name} is called from a NavigationDestination that is not bound to '${navigationHandleGenericType.presentableText}'", + message = "@Composable function '${composableParent.name}' is not annotated with '@NavigationDestination(${navigationHandleGenericType.presentableText})'", quickfixData = fix() .name("Add NavigationDestination to ${composableParent.name}") .replace() diff --git a/enro-lint/src/main/java/dev/enro/lint/Issues.kt b/enro-lint/src/main/java/dev/enro/lint/Issues.kt index d4288c09..8b3a8f18 100644 --- a/enro-lint/src/main/java/dev/enro/lint/Issues.kt +++ b/enro-lint/src/main/java/dev/enro/lint/Issues.kt @@ -31,7 +31,12 @@ val missingNavigationDestinationAnnotation = Issue.create( val missingNavigationDestinationAnnotationCompose = Issue.create( id = "MissingNavigationDestinationAnnotation", briefDescription = "Missing Navigation Destination Annotation", - explanation = "Attempting to create a NavigationHandleProperty inside a Composable that is not marked as a NavigationDestination", + explanation = "Requesting a TypedNavigationHandle here may cause a crash, " + + "as there is no guarantee that the nearest NavigationHandle has a NavigationKey of the requested type.\n\n" + + "This is not always an error, as there may be higher-level program logic that ensures this will succeed, " + + "but it is important to understand that this works in essentially the same way as an unchecked cast. " + + "If you do not need a TypedNavigationHandle, you can request an untyped NavigationHandle by removing the type" + + "arguments provided to the `navigationHandle` function", category = Category.PRODUCTIVITY, priority = 5, severity = Severity.WARNING,