diff --git a/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/ComposeExtensions.kt b/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/ComposeExtensions.kt index ba4177ac..66e0d911 100644 --- a/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/ComposeExtensions.kt +++ b/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/ComposeExtensions.kt @@ -2,15 +2,24 @@ package com.ryanmoelter.magellanx.compose import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.ryanmoelter.magellanx.core.Displayable +import com.ryanmoelter.magellanx.core.lifecycle.LifecycleAware import com.ryanmoelter.magellanx.core.lifecycle.LifecycleOwner import com.ryanmoelter.magellanx.core.lifecycle.LifecycleState.Resumed import com.ryanmoelter.magellanx.core.lifecycle.LifecycleState.Started +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext @Composable @Suppress("ktlint:standard:function-naming") @@ -20,6 +29,21 @@ public fun Displayable<@Composable () -> Unit>.Content(modifier: Modifier = Modi } } +@Composable +@Suppress("ktlint:standard:function-naming") +public fun Displayable< + @Composable ( + Arg, + ) -> Unit, +>.Content( + arg: Arg, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + view!!(arg) + } +} + @Composable @Suppress("ktlint:standard:function-naming") public fun Displayable( @@ -54,3 +78,53 @@ public fun LifecycleOwner.WhenResumed(content: @Composable () -> Unit) { content() } } + +/** + * Like [collectAsState], but stops emitting values when this ComposeStep is not Resumed. This is + * primarily useful for preventing values from updating during transitions. + * + * Note that this keeps a subscription to the underlying [Flow] even while not Resumed; any + * emitted values are simply ignored until this ComposeStep is Resumed, at which point the most + * recent value will be emitted before any subsequent values. + */ +@Suppress("StateFlowValueCalledInComposition") +@Composable +public fun StateFlow.collectAsStateWhileResumed( + lifecycleComponent: L, + context: CoroutineContext = EmptyCoroutineContext, +): State where L : LifecycleAware, L : LifecycleOwner = + collectAsStateWhileResumed(value, lifecycleComponent, context) + +/** + * Like [collectAsState], but stops emitting values when this ComposeStep is not Resumed. This is + * primarily useful for preventing values from updating during transitions. + * + * Note that this keeps a subscription to the underlying [Flow] even while not Resumed; any + * emitted values are simply ignored until this ComposeStep is Resumed, at which point the most + * recent value will be emitted before any subsequent values. + */ +@Composable +public fun Flow.collectAsStateWhileResumed( + initial: R, + lifecycleComponent: L, + context: CoroutineContext = EmptyCoroutineContext, +): State where L : LifecycleAware, L : LifecycleOwner = + produceState(initial, this, context) { + if (context == EmptyCoroutineContext) { + combine(lifecycleComponent.currentStateFlow) { it, currentState -> it to currentState } + .collect { (it, currentState) -> + if (currentState == Resumed) { + value = it + } + } + } else { + withContext(context) { + combine(lifecycleComponent.currentStateFlow) { it, currentState -> it to currentState } + .collect { (it, currentState) -> + if (currentState == Resumed) { + value = it + } + } + } + } + } diff --git a/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/ComposeJourney.kt b/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/ComposeJourney.kt index 9d4537f3..f7ff29c2 100644 --- a/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/ComposeJourney.kt +++ b/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/ComposeJourney.kt @@ -5,8 +5,14 @@ import com.ryanmoelter.magellanx.compose.navigation.ComposeNavigator import com.ryanmoelter.magellanx.core.lifecycle.attachFieldToLifecycle public abstract class ComposeJourney : ComposeStep() { - protected var navigator: ComposeNavigator by attachFieldToLifecycle(ComposeNavigator()) + protected var navigator: ComposeNavigator by attachFieldToLifecycle( + ComposeNavigator(::interceptBack, ::interceptBackGestureAnimation), + ) @Composable protected override fun Content(): Unit = navigator.Content() + + protected open fun interceptBack(performBack: () -> Unit): Unit = performBack() + + protected open fun interceptBackGestureAnimation(): Boolean = false } diff --git a/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/ComposeStep.kt b/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/ComposeStep.kt index e5fd57fc..48cf9aba 100644 --- a/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/ComposeStep.kt +++ b/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/ComposeStep.kt @@ -4,7 +4,6 @@ import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.produceState import com.ryanmoelter.magellanx.core.Displayable import com.ryanmoelter.magellanx.core.Navigable import com.ryanmoelter.magellanx.core.coroutines.CreatedLifecycleScope @@ -12,15 +11,12 @@ import com.ryanmoelter.magellanx.core.coroutines.ResumedLifecycleScope import com.ryanmoelter.magellanx.core.coroutines.ShownLifecycleScope import com.ryanmoelter.magellanx.core.coroutines.StartedLifecycleScope import com.ryanmoelter.magellanx.core.lifecycle.LifecycleAwareComponent -import com.ryanmoelter.magellanx.core.lifecycle.LifecycleState import com.ryanmoelter.magellanx.core.lifecycle.attachFieldToLifecycle import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.withContext public abstract class ComposeStep : ComposeSection(), Navigable<@Composable () -> Unit> @@ -76,23 +72,9 @@ public abstract class ComposeSection : initial: R, context: CoroutineContext = EmptyCoroutineContext, ): State = - produceState(initial, this, context) { - if (context == EmptyCoroutineContext) { - combine(lifecycleRegistry.currentStateFlow) { it, currentState -> it to currentState } - .collect { (it, currentState) -> - if (currentState == LifecycleState.Resumed) { - value = it - } - } - } else { - withContext(context) { - combine(lifecycleRegistry.currentStateFlow) { it, currentState -> it to currentState } - .collect { (it, currentState) -> - if (currentState == LifecycleState.Resumed) { - value = it - } - } - } - } - } + collectAsStateWhileResumed( + initial = initial, + lifecycleComponent = this@ComposeSection, + context = context, + ) } diff --git a/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/navigation/ComposeNavigator.kt b/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/navigation/ComposeNavigator.kt index 25dec88a..7058072f 100644 --- a/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/navigation/ComposeNavigator.kt +++ b/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/navigation/ComposeNavigator.kt @@ -2,6 +2,7 @@ package com.ryanmoelter.magellanx.compose.navigation +import androidx.activity.BackEventCompat import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -14,6 +15,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.ryanmoelter.magellanx.compose.Content import com.ryanmoelter.magellanx.compose.WhenStarted +import com.ryanmoelter.magellanx.compose.navigation.BackHandlerStatus.DISABLED +import com.ryanmoelter.magellanx.compose.navigation.BackHandlerStatus.ENABLED import com.ryanmoelter.magellanx.compose.navigation.Direction.BACKWARD import com.ryanmoelter.magellanx.compose.navigation.Direction.FORWARD import com.ryanmoelter.magellanx.compose.transitions.MagellanComposeTransition @@ -25,12 +28,19 @@ import com.ryanmoelter.magellanx.core.lifecycle.LifecycleAwareComponent import com.ryanmoelter.magellanx.core.lifecycle.LifecycleLimit import com.ryanmoelter.magellanx.core.lifecycle.LifecycleOwner import com.ryanmoelter.magellanx.core.lifecycle.LifecycleState +import com.ryanmoelter.magellanx.core.lifecycle.attachFieldToLifecycle import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map -public open class ComposeNavigator : - LifecycleAwareComponent(), Displayable<@Composable () -> Unit> { +public open class ComposeNavigator( + private val interceptBack: (performBack: () -> Unit) -> Unit = { performBack -> performBack() }, + private val interceptBackGestureAnimation: () -> Boolean = { false }, +) : LifecycleAwareComponent(), Displayable<@Composable () -> Unit> { + private val backHandler by attachFieldToLifecycle( + ComposePredictiveBackHandler(::handlePredictiveBack), + ) + /** * The backstack. The last item in each list is the top of the stack. */ @@ -41,6 +51,9 @@ public open class ComposeNavigator : */ public open val backStack: List get() = backStackFlow.value + public open val backStackStatusFlow: Flow = + backStackFlow + .map { if (it.size <= 1) DISABLED else ENABLED } /** * Get a snapshot of the current navigable, i.e. the last item of the current [backStack]. @@ -57,6 +70,8 @@ public open class ComposeNavigator : MutableStateFlow(defaultTransition) private val directionFlow: MutableStateFlow = MutableStateFlow(FORWARD) + private val backSwipeProgressFlow = MutableStateFlow(0f) + override val view: (@Composable () -> Unit) get() = { WhenStarted { @@ -71,6 +86,9 @@ public open class ComposeNavigator : val currentNavigable by currentNavigableFlow.collectAsState(null) val currentTransitionSpec by transitionFlow.collectAsState() val currentDirection by directionFlow.collectAsState() + val backstackStatus by backStackStatusFlow.collectAsState(initial = DISABLED) + + backHandler.Content(backstackStatus) AnimatedContent( targetState = currentNavigable, @@ -101,10 +119,12 @@ public open class ComposeNavigator : // The navigable on top of the backstack is Shown if not visible lifecycleRegistry.updateMaxState(navigable, LifecycleLimit.SHOWN) } + backStack.map { it.navigable }.contains(navigable) -> { // Any navigable still in the backstack but not visible is Created lifecycleRegistry.updateMaxState(navigable, LifecycleLimit.CREATED) } + else -> { // Any navigable that's been removed from the backstack should also be removed // from this LifecycleOwner @@ -155,14 +175,29 @@ public open class ComposeNavigator : } } - public open fun goBack(): Boolean { - return if (!atRoot()) { - navigate(BACKWARD) { backStack -> - backStack - backStack.last() + private suspend fun handlePredictiveBack(backEventFlow: Flow) { + require(backStack.size > 1) { "Backstack is too small for predictive back gesture" } + // TODO: handle case where backstack is changed during the predictive back animation + // TODO provide an intercept opportunity before the animation starts; tie to other intercept method? + if (interceptBackGestureAnimation()) { + backEventFlow.collect { /* Avoid animating, do nothing */ } + } else { + backEventFlow.collect { backEvent -> + backSwipeProgressFlow.value = backEvent.progress + // TODO: update Content() to handle backSwipeProgress } - true + } + goBack() + } + + public open fun goBack() { + if (!atRoot()) { + interceptBack { + navigate(BACKWARD) { backStack -> backStack - backStack.last() } + } + backSwipeProgressFlow.value = 0f } else { - false + throw IllegalStateException("goBack() shouldn't be called with an empty backstack") } } @@ -232,8 +267,6 @@ public open class ComposeNavigator : } } - override fun onBackPressed(): Boolean = currentNavigable?.backPressed() ?: false || goBack() - public open fun atRoot(): Boolean = backStack.size <= 1 } diff --git a/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/navigation/ComposePredictiveBackHandler.kt b/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/navigation/ComposePredictiveBackHandler.kt new file mode 100644 index 00000000..f4b98510 --- /dev/null +++ b/magellanx-compose/src/main/java/com/ryanmoelter/magellanx/compose/navigation/ComposePredictiveBackHandler.kt @@ -0,0 +1,33 @@ +package com.ryanmoelter.magellanx.compose.navigation + +import androidx.activity.BackEventCompat +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.runtime.Composable +import com.ryanmoelter.magellanx.compose.WhenStarted +import com.ryanmoelter.magellanx.core.Displayable +import com.ryanmoelter.magellanx.core.lifecycle.LifecycleAwareComponent +import kotlinx.coroutines.flow.Flow + +/** + * Handle back events proactively, to work with Android's upcoming predictive back system. + * + * Note that you need to call [view] (or the Content(arg) extension function) in order for this to + * work. + * + * @see [Android's upcoming predictive back system](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture) + */ +public class ComposePredictiveBackHandler( + private val backStarted: suspend (Flow) -> Unit, +) : LifecycleAwareComponent(), Displayable<@Composable (BackHandlerStatus) -> Unit> { + override val view: @Composable (BackHandlerStatus) -> Unit + get() = { backHandlerStatus -> + WhenStarted { + PredictiveBackHandler(enabled = backHandlerStatus.enabled, onBack = backStarted) + } + } +} + +public enum class BackHandlerStatus(public val enabled: Boolean) { + ENABLED(true), + DISABLED(false), +} diff --git a/magellanx-core/src/main/java/com/ryanmoelter/magellanx/core/lifecycle/LifecycleAware.kt b/magellanx-core/src/main/java/com/ryanmoelter/magellanx/core/lifecycle/LifecycleAware.kt index f1cbeeab..0cbdf0ad 100644 --- a/magellanx-core/src/main/java/com/ryanmoelter/magellanx/core/lifecycle/LifecycleAware.kt +++ b/magellanx-core/src/main/java/com/ryanmoelter/magellanx/core/lifecycle/LifecycleAware.kt @@ -16,6 +16,4 @@ public interface LifecycleAware { public fun hide() {} public fun destroy() {} - - public fun backPressed(): Boolean = false } diff --git a/magellanx-core/src/main/java/com/ryanmoelter/magellanx/core/lifecycle/LifecycleAwareComponent.kt b/magellanx-core/src/main/java/com/ryanmoelter/magellanx/core/lifecycle/LifecycleAwareComponent.kt index de4292e4..fb7fd727 100644 --- a/magellanx-core/src/main/java/com/ryanmoelter/magellanx/core/lifecycle/LifecycleAwareComponent.kt +++ b/magellanx-core/src/main/java/com/ryanmoelter/magellanx/core/lifecycle/LifecycleAwareComponent.kt @@ -99,10 +99,6 @@ public abstract class LifecycleAwareComponent : LifecycleAware, LifecycleOwner { lifecycleRegistry.destroy() } - override fun backPressed(): Boolean { - return lifecycleRegistry.backPressed() || onBackPressed() - } - public fun attachToLifecycle(lifecycleAware: LifecycleAware) { attachToLifecycle(lifecycleAware, LifecycleState.Destroyed) } @@ -140,6 +136,4 @@ public abstract class LifecycleAwareComponent : LifecycleAware, LifecycleOwner { protected open fun onHide() {} protected open fun onDestroy() {} - - protected open fun onBackPressed(): Boolean = false } diff --git a/magellanx-core/src/main/java/com/ryanmoelter/magellanx/core/lifecycle/LifecycleRegistry.kt b/magellanx-core/src/main/java/com/ryanmoelter/magellanx/core/lifecycle/LifecycleRegistry.kt index 60f7588a..ff7c27f5 100644 --- a/magellanx-core/src/main/java/com/ryanmoelter/magellanx/core/lifecycle/LifecycleRegistry.kt +++ b/magellanx-core/src/main/java/com/ryanmoelter/magellanx/core/lifecycle/LifecycleRegistry.kt @@ -1,7 +1,6 @@ package com.ryanmoelter.magellanx.core.lifecycle import com.ryanmoelter.magellanx.core.lifecycle.LifecycleLimit.NO_LIMIT -import com.ryanmoelter.magellanx.core.lifecycle.LifecycleLimit.SHOWN import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -124,16 +123,6 @@ public class LifecycleRegistry : LifecycleAware, LifecycleOwner { override fun destroy() { currentState = LifecycleState.Destroyed } - - override fun backPressed(): Boolean = onAllActiveListenersUntilTrue { it.backPressed() } - - private fun onAllActiveListenersUntilTrue(action: (LifecycleAware) -> Boolean): Boolean = - listenersToMaxStates - .asSequence() - .filter { it.value >= SHOWN } - .map { it.key } - .map(action) - .any { it } } public enum class LifecycleLimit(internal val maxLifecycleState: LifecycleState) { diff --git a/magellanx-sample-app/src/main/AndroidManifest.xml b/magellanx-sample-app/src/main/AndroidManifest.xml index 4ccafc79..701ec40f 100644 --- a/magellanx-sample-app/src/main/AndroidManifest.xml +++ b/magellanx-sample-app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ @@ -13,6 +14,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.MagellanX" + android:enableOnBackInvokedCallback="true" + tools:targetApi="tiramisu" > - if (backHandled) { - doggoRatings = doggoRatings.dropLast(1) - } - } + override fun interceptBack(performBack: () -> Unit) { + doggoRatings = doggoRatings.dropLast(1) + performBack() + } } data class DoggoRating( diff --git a/magellanx-sample-app/src/main/java/com/ryanmoelter/magellanx/doggos/home/HomeStep.kt b/magellanx-sample-app/src/main/java/com/ryanmoelter/magellanx/doggos/home/HomeStep.kt index 1900c25a..deb29648 100644 --- a/magellanx-sample-app/src/main/java/com/ryanmoelter/magellanx/doggos/home/HomeStep.kt +++ b/magellanx-sample-app/src/main/java/com/ryanmoelter/magellanx/doggos/home/HomeStep.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward import androidx.compose.material.icons.rounded.ArrowForward import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -70,7 +71,7 @@ fun ListItem( ) { Text(title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) Icon( - imageVector = Icons.Rounded.ArrowForward, + imageVector = Icons.AutoMirrored.Rounded.ArrowForward, contentDescription = "Open a new page", tint = MaterialTheme.colorScheme.primary, ) diff --git a/magellanx-sample-app/src/main/java/com/ryanmoelter/magellanx/doggos/randomimages/ChooseBreedStep.kt b/magellanx-sample-app/src/main/java/com/ryanmoelter/magellanx/doggos/randomimages/ChooseBreedStep.kt index 107f484e..adeee4bc 100644 --- a/magellanx-sample-app/src/main/java/com/ryanmoelter/magellanx/doggos/randomimages/ChooseBreedStep.kt +++ b/magellanx-sample-app/src/main/java/com/ryanmoelter/magellanx/doggos/randomimages/ChooseBreedStep.kt @@ -21,10 +21,10 @@ import kotlinx.coroutines.launch class ChooseBreedStep( val chooseBreed: (String) -> Unit, ) : ComposeStep() { - val loadableBreedListFlow: MutableStateFlow>> = + private val loadableBreedListFlow: MutableStateFlow>> = MutableStateFlow(Loadable.Loading()) - val doggoApi = injector.doggoApi + private val doggoApi = injector.doggoApi @Composable override fun Content() {