Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -20,6 +29,21 @@ public fun Displayable<@Composable () -> Unit>.Content(modifier: Modifier = Modi
}
}

@Composable
@Suppress("ktlint:standard:function-naming")
public fun <Arg> Displayable<
@Composable (
Arg,
) -> Unit,
>.Content(
arg: Arg,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
view!!(arg)
}
}

@Composable
@Suppress("ktlint:standard:function-naming")
public fun Displayable(
Expand Down Expand Up @@ -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 <T, L> StateFlow<T>.collectAsStateWhileResumed(
lifecycleComponent: L,
context: CoroutineContext = EmptyCoroutineContext,
): State<T> 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 <T : R, R, L> Flow<T>.collectAsStateWhileResumed(
initial: R,
lifecycleComponent: L,
context: CoroutineContext = EmptyCoroutineContext,
): State<R> 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
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,19 @@ 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
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>

Expand Down Expand Up @@ -76,23 +72,9 @@ public abstract class ComposeSection :
initial: R,
context: CoroutineContext = EmptyCoroutineContext,
): State<R> =
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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
*/
Expand All @@ -41,6 +51,9 @@ public open class ComposeNavigator :
*/
public open val backStack: List<ComposeNavigationEvent>
get() = backStackFlow.value
public open val backStackStatusFlow: Flow<BackHandlerStatus> =
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].
Expand All @@ -57,6 +70,8 @@ public open class ComposeNavigator :
MutableStateFlow(defaultTransition)
private val directionFlow: MutableStateFlow<Direction> = MutableStateFlow(FORWARD)

private val backSwipeProgressFlow = MutableStateFlow(0f)

override val view: (@Composable () -> Unit)
get() = {
WhenStarted {
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<BackEventCompat>) {
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")
}
}

Expand Down Expand Up @@ -232,8 +267,6 @@ public open class ComposeNavigator :
}
}

override fun onBackPressed(): Boolean = currentNavigable?.backPressed() ?: false || goBack()

public open fun atRoot(): Boolean = backStack.size <= 1
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<BackEventCompat>) -> 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),
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,4 @@ public interface LifecycleAware {
public fun hide() {}

public fun destroy() {}

public fun backPressed(): Boolean = false
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -140,6 +136,4 @@ public abstract class LifecycleAwareComponent : LifecycleAware, LifecycleOwner {
protected open fun onHide() {}

protected open fun onDestroy() {}

protected open fun onBackPressed(): Boolean = false
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions magellanx-sample-app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
Expand All @@ -13,6 +14,8 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MagellanX"
android:enableOnBackInvokedCallback="true"
tools:targetApi="tiramisu"
>
<activity
android:name=".MainActivity"
Expand Down
Loading