From 5aa88acd55ba70f0f661f822b43882cfba547afb Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Tue, 5 Sep 2023 18:05:20 +0300 Subject: [PATCH] Use new Gesture Navigation CircuitX library (#1494) --- gradle/libs.versions.toml | 1 + ui/root/build.gradle.kts | 3 +- .../app/tivi/home/GestureNavDecoration.kt | 254 --------------- .../app/tivi/home/GestureNavDecoration.kt | 13 - .../commonMain/kotlin/app/tivi/home/Home.kt | 7 +- .../kotlin/app/tivi/home/TiviContent.kt | 1 - .../app/tivi/home/GestureNavDecoration.kt | 301 ------------------ .../app/tivi/home/GestureNavDecoration.kt | 14 - 8 files changed, 5 insertions(+), 589 deletions(-) delete mode 100644 ui/root/src/androidMain/kotlin/app/tivi/home/GestureNavDecoration.kt delete mode 100644 ui/root/src/commonMain/kotlin/app/tivi/home/GestureNavDecoration.kt delete mode 100644 ui/root/src/iosMain/kotlin/app/tivi/home/GestureNavDecoration.kt delete mode 100644 ui/root/src/jvmMain/kotlin/app/tivi/home/GestureNavDecoration.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3f05dff9d5..dabb46426f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ appauth = "net.openid:appauth:0.8.1" chucker-library = { module = "com.github.chuckerteam.chucker:library", version.ref = "chucker" } circuit-foundation = { module = "com.slack.circuit:circuit-foundation", version.ref = "circuit" } +circuit-gestureNavigation = { module = "com.slack.circuit:circuitx-gesture-navigation", version.ref = "circuit" } circuit-overlay = { module = "com.slack.circuit:circuit-overlay", version.ref = "circuit" } circuit-runtime = { module = "com.slack.circuit:circuit-runtime", version.ref = "circuit" } diff --git a/ui/root/build.gradle.kts b/ui/root/build.gradle.kts index 4714dcbece..1f0c8e1156 100644 --- a/ui/root/build.gradle.kts +++ b/ui/root/build.gradle.kts @@ -25,13 +25,12 @@ kotlin { implementation(projects.common.ui.screens) implementation(libs.circuit.foundation) + implementation(libs.circuit.gestureNavigation) implementation(libs.circuit.overlay) implementation(projects.common.ui.circuitOverlay) implementation(compose.foundation) - implementation(compose.material) implementation(compose.materialIconsExtended) - implementation(compose.animation) } } diff --git a/ui/root/src/androidMain/kotlin/app/tivi/home/GestureNavDecoration.kt b/ui/root/src/androidMain/kotlin/app/tivi/home/GestureNavDecoration.kt deleted file mode 100644 index 6b968f94a8..0000000000 --- a/ui/root/src/androidMain/kotlin/app/tivi/home/GestureNavDecoration.kt +++ /dev/null @@ -1,254 +0,0 @@ -// Copyright 2023, Christopher Banes and the Tivi project contributors -// SPDX-License-Identifier: Apache-2.0 - -package app.tivi.home - -import android.os.Build -import android.window.BackEvent -import android.window.OnBackAnimationCallback -import android.window.OnBackInvokedDispatcher -import androidx.annotation.RequiresApi -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.dp -import app.tivi.animations.lerp -import app.tivi.util.Logger -import com.slack.circuit.backstack.NavDecoration -import com.slack.circuit.foundation.NavigatorDefaults -import com.slack.circuit.runtime.Navigator -import kotlin.math.absoluteValue -import kotlinx.collections.immutable.ImmutableList - -internal actual class GestureNavDecoration actual constructor( - private val navigator: Navigator, - logger: Logger, -) : NavDecoration { - - @Composable - override fun DecoratedContent( - args: ImmutableList, - backStackDepth: Int, - modifier: Modifier, - content: @Composable (T) -> Unit, - ) { - if (Build.VERSION.SDK_INT < 34) { - // on API 33 and below, we just use the default decoration - NavigatorDefaults.DefaultDecoration.DecoratedContent( - args = args, - backStackDepth = backStackDepth, - modifier = modifier, - content = content, - ) - } else { - GestureDecoratedContent( - args = args, - backStackDepth = backStackDepth, - modifier = modifier, - content = content, - ) - } - } - - @RequiresApi(34) - @Composable - private fun GestureDecoratedContent( - args: ImmutableList, - backStackDepth: Int, - modifier: Modifier, - content: @Composable (T) -> Unit, - ) { - val current = args.first() - val previous = args.getOrNull(1) - - Box(modifier = modifier) { - var showPrevious by remember { mutableStateOf(false) } - var recordPoppedFromGesture by remember { mutableStateOf(null) } - - if (previous != null) { - PreviousContent(isVisible = { showPrevious }) { - content(previous) - } - } - - // Remember the previous stack depth so we know if the navigation is going "back". - var prevStackDepth by rememberSaveable { mutableIntStateOf(backStackDepth) } - SideEffect { - prevStackDepth = backStackDepth - } - - val transition = updateTransition(targetState = current, label = "GestureNavDecoration") - - LaunchedEffect(transition.currentState) { - // When the current state has changed (i.e. any transition has completed), - // clear out any transient state - showPrevious = false - recordPoppedFromGesture = null - } - - transition.AnimatedContent( - modifier = modifier, - transitionSpec = { - // Mirror the forward and backward transitions of activities in Android 33 - when { - // adding to back stack - backStackDepth > prevStackDepth -> { - (slideInHorizontally(tween(), SlightlyRight) + fadeIn()) togetherWith - (slideOutHorizontally(tween(), SlightlyLeft) + fadeOut()) - } - - // come back from back stack - backStackDepth < prevStackDepth -> { - if (recordPoppedFromGesture == initialState) { - EnterTransition.None togetherWith - scaleOut(targetScale = 0.8f) + fadeOut() - } else { - slideInHorizontally(tween(), SlightlyLeft) + fadeIn() togetherWith - slideOutHorizontally(tween(), SlightlyRight) + fadeOut() - }.apply { - targetContentZIndex = -1f - } - } - - // Root reset. Crossfade - else -> fadeIn() togetherWith fadeOut() - } - }, - ) { record -> - var swipeProgress by remember { mutableFloatStateOf(0f) } - - if (backStackDepth > 1) { - BackHandler( - onBackProgress = { progress -> - showPrevious = progress != 0f - swipeProgress = progress - }, - onBackInvoked = { - if (swipeProgress != 0f) { - // If back has been invoked, and the swipe progress isn't zero, - // mark this record as 'popped via gesture' so we can - // use a different transition - recordPoppedFromGesture = record - } - navigator.pop() - }, - ) - } - - Box( - modifier = Modifier.predictiveBackMotion( - shape = MaterialTheme.shapes.extraLarge, - progress = { swipeProgress }, - ), - ) { - content(record) - } - } - } - } -} - -private const val FIVE_PERCENT = 0.05f -private val SlightlyRight = { width: Int -> (width * FIVE_PERCENT).toInt() } -private val SlightlyLeft = { width: Int -> 0 - (width * FIVE_PERCENT).toInt() } - -/** - * Implements most of the treatment specified at - * https://developer.android.com/design/ui/mobile/guides/patterns/predictive-back#designing-gesture - */ -private fun Modifier.predictiveBackMotion( - shape: Shape, - progress: () -> Float, -): Modifier = graphicsLayer { - val p = progress() - // If we're at progress 0f, skip setting any parameters - if (p == 0f) return@graphicsLayer - - translationX = -(8.dp * p).toPx() - shadowElevation = 6.dp.toPx() - - val scale = lerp(1f, 0.9f, p.absoluteValue) - scaleX = scale - scaleY = scale - transformOrigin = TransformOrigin( - pivotFractionX = if (p > 0) 1f else 0f, - pivotFractionY = 0.5f, - ) - - // TODO: interpolate from rectangle to shape? - this.shape = shape - clip = true -} - -@RequiresApi(34) -@Composable -private fun BackHandler( - onBackProgress: (Float) -> Unit, - animatedEnabled: Boolean = true, - onBackInvoked: () -> Unit, -) { - val onBackInvokedDispatcher = LocalView.current.findOnBackInvokedDispatcher() - val lastAnimatedEnabled by rememberUpdatedState(animatedEnabled) - val lastOnBackProgress by rememberUpdatedState(onBackProgress) - val lastOnBackInvoked by rememberUpdatedState(onBackInvoked) - - DisposableEffect(onBackInvokedDispatcher) { - val callback = object : OnBackAnimationCallback { - override fun onBackStarted(backEvent: BackEvent) { - if (lastAnimatedEnabled) { - lastOnBackProgress(0f) - } - } - - override fun onBackProgressed(backEvent: BackEvent) { - if (lastAnimatedEnabled) { - lastOnBackProgress( - when (backEvent.swipeEdge) { - BackEvent.EDGE_LEFT -> backEvent.progress - else -> -backEvent.progress - }, - ) - } - } - - override fun onBackInvoked() = lastOnBackInvoked() - } - - onBackInvokedDispatcher?.registerOnBackInvokedCallback( - // Circuit adds its own BackHandler() at the root, so we need to add a callback - // with a higher priority. - OnBackInvokedDispatcher.PRIORITY_DEFAULT + 10, - callback, - ) - - onDispose { - onBackInvokedDispatcher?.unregisterOnBackInvokedCallback(callback) - } - } -} diff --git a/ui/root/src/commonMain/kotlin/app/tivi/home/GestureNavDecoration.kt b/ui/root/src/commonMain/kotlin/app/tivi/home/GestureNavDecoration.kt deleted file mode 100644 index d1d5fe2e94..0000000000 --- a/ui/root/src/commonMain/kotlin/app/tivi/home/GestureNavDecoration.kt +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2023, Christopher Banes and the Tivi project contributors -// SPDX-License-Identifier: Apache-2.0 - -package app.tivi.home - -import app.tivi.util.Logger -import com.slack.circuit.backstack.NavDecoration -import com.slack.circuit.runtime.Navigator - -internal expect class GestureNavDecoration( - navigator: Navigator, - logger: Logger, -) : NavDecoration diff --git a/ui/root/src/commonMain/kotlin/app/tivi/home/Home.kt b/ui/root/src/commonMain/kotlin/app/tivi/home/Home.kt index ada65ce6e7..a6e8089525 100644 --- a/ui/root/src/commonMain/kotlin/app/tivi/home/Home.kt +++ b/ui/root/src/commonMain/kotlin/app/tivi/home/Home.kt @@ -51,7 +51,6 @@ import app.tivi.screens.DiscoverScreen import app.tivi.screens.LibraryScreen import app.tivi.screens.SearchScreen import app.tivi.screens.UpNextScreen -import app.tivi.util.Logger import com.moriatsushi.insetsx.navigationBars import com.moriatsushi.insetsx.safeContentPadding import com.moriatsushi.insetsx.statusBars @@ -62,12 +61,12 @@ import com.slack.circuit.foundation.NavigableCircuitContent import com.slack.circuit.overlay.ContentWithOverlays import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen +import com.slack.circuitx.gesturenavigation.GestureNavigationDecoration @Composable internal fun Home( backstack: SaveableBackStack, navigator: Navigator, - logger: Logger, modifier: Modifier = Modifier, ) { val windowSizeClass = LocalWindowSizeClass.current @@ -134,8 +133,8 @@ internal fun Home( NavigableCircuitContent( navigator = navigator, backstack = backstack, - decoration = remember(navigator, logger) { - GestureNavDecoration(navigator, logger) + decoration = remember(navigator) { + GestureNavigationDecoration(onBackInvoked = navigator::pop) }, modifier = Modifier .weight(1f) diff --git a/ui/root/src/commonMain/kotlin/app/tivi/home/TiviContent.kt b/ui/root/src/commonMain/kotlin/app/tivi/home/TiviContent.kt index e7605d020c..5352cb83af 100644 --- a/ui/root/src/commonMain/kotlin/app/tivi/home/TiviContent.kt +++ b/ui/root/src/commonMain/kotlin/app/tivi/home/TiviContent.kt @@ -94,7 +94,6 @@ fun TiviContent( Home( backstack = backstack, navigator = tiviNavigator, - logger = logger, modifier = modifier, ) } diff --git a/ui/root/src/iosMain/kotlin/app/tivi/home/GestureNavDecoration.kt b/ui/root/src/iosMain/kotlin/app/tivi/home/GestureNavDecoration.kt deleted file mode 100644 index 62dd3242ce..0000000000 --- a/ui/root/src/iosMain/kotlin/app/tivi/home/GestureNavDecoration.kt +++ /dev/null @@ -1,301 +0,0 @@ -// Copyright 2023, Christopher Banes and the Tivi project contributors -// SPDX-License-Identifier: Apache-2.0 - -package app.tivi.home - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.updateTransition -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.offset -import androidx.compose.material.DismissDirection -import androidx.compose.material.DismissState -import androidx.compose.material.DismissValue -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.FractionalThreshold -import androidx.compose.material.ResistanceConfig -import androidx.compose.material.SwipeableDefaults -import androidx.compose.material.ThresholdConfig -import androidx.compose.material.swipeable -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.Velocity -import androidx.compose.ui.unit.dp -import app.tivi.common.compose.thenIf -import app.tivi.util.Logger -import com.slack.circuit.backstack.NavDecoration -import com.slack.circuit.runtime.Navigator -import kotlin.math.absoluteValue -import kotlin.math.roundToInt -import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.flow.filter - -@ExperimentalMaterialApi -@Immutable -class SwipeProperties( - val enterOffsetFraction: Float = 0.25f, - val swipeThreshold: ThresholdConfig = FractionalThreshold(0.4f), - val swipeAreaWidth: Dp = 16.dp, -) - -@OptIn(ExperimentalMaterialApi::class) -internal actual class GestureNavDecoration @ExperimentalMaterialApi constructor( - private val navigator: Navigator, - private val logger: Logger, - private val swipeProperties: SwipeProperties, -) : NavDecoration { - - @OptIn(ExperimentalMaterialApi::class) - actual constructor( - navigator: Navigator, - logger: Logger, - ) : this(navigator, logger, SwipeProperties()) - - @Composable - override fun DecoratedContent( - args: ImmutableList, - backStackDepth: Int, - modifier: Modifier, - content: @Composable (T) -> Unit, - ) { - val current = args.first() - val previous = args.getOrNull(1) - - SideEffect { - logger.d { - "DecoratedContent. arg: $current. previous: $previous. backStackDepth: $backStackDepth" - } - } - - Box(modifier = modifier) { - // Remember the previous stack depth so we know if the navigation is going "back". - var prevStackDepth by rememberSaveable { mutableStateOf(backStackDepth) } - SideEffect { - prevStackDepth = backStackDepth - } - - val dismissState = rememberDismissState(current) - var offsetWhenPopped by remember { mutableStateOf(0f) } - - LaunchedEffect(dismissState) { - snapshotFlow { dismissState.isDismissed(DismissDirection.StartToEnd) } - .filter { it } - .collect { - navigator.pop() - offsetWhenPopped = dismissState.offset.value - } - } - - val transition = updateTransition(targetState = current, label = "GestureNavDecoration") - - if (previous != null) { - // Previous content is only visible if the swipe-dismiss offset != 0 - val showPrevious by remember(dismissState) { - derivedStateOf { dismissState.offset.value != 0f || transition.isRunning } - } - - PreviousContent( - isVisible = { showPrevious }, - modifier = Modifier.graphicsLayer { - translationX = when { - // If we're running in a transition, let it handle any translation - transition.isRunning -> 0f - else -> { - // Otherwise we'll react to the swipe dismiss state - (dismissState.offset.value.absoluteValue - size.width) * - swipeProperties.enterOffsetFraction - } - } - }, - content = { content(previous) }, - ) - } - - transition.AnimatedContent( - transitionSpec = { - when { - // adding to back stack - backStackDepth > prevStackDepth -> { - slideInHorizontally( - initialOffsetX = End, - ).togetherWith( - slideOutHorizontally { width -> - -(swipeProperties.enterOffsetFraction * width).roundToInt() - }, - ) - } - - // come back from back stack - backStackDepth < prevStackDepth -> { - if (offsetWhenPopped != 0f) { - // If the record change was caused by a swipe gesture, let's - // jump cut - EnterTransition.None togetherWith ExitTransition.None - } else { - slideInHorizontally { width -> - -(swipeProperties.enterOffsetFraction * width).roundToInt() - }.togetherWith( - slideOutHorizontally(targetOffsetX = End), - ).apply { - targetContentZIndex = -1f - } - } - } - - // Root reset. Crossfade - else -> fadeIn() togetherWith fadeOut() - } - }, - modifier = modifier, - ) { record -> - SwipeableContent( - state = dismissState, - swipeEnabled = backStackDepth > 1, - swipeAreaWidth = swipeProperties.swipeAreaWidth, - dismissThreshold = swipeProperties.swipeThreshold, - content = { content(record) }, - ) - } - - LaunchedEffect(current) { - // Reset the offsetWhenPopped when the top record changes - offsetWhenPopped = 0f - } - } - } -} - -private val End: (Int) -> Int = { it } - -/** - * This is basically [androidx.compose.material.SwipeToDismiss] but simplified for our - * use case. - */ -@Composable -@ExperimentalMaterialApi -internal fun SwipeableContent( - state: DismissState, - @Suppress("UNUSED_PARAMETER") swipeAreaWidth: Dp, - dismissThreshold: ThresholdConfig, - modifier: Modifier = Modifier, - swipeEnabled: Boolean = true, - content: @Composable () -> Unit, -) { - BoxWithConstraints(modifier) { - val width = constraints.maxWidth - - val nestedScrollConnection = remember(state) { - SwipeDismissNestedScrollConnection(state) - } - - Box( - modifier = Modifier - .thenIf(swipeEnabled) { nestedScroll(nestedScrollConnection) } - .swipeable( - state = state, - anchors = mapOf( - 0f to DismissValue.Default, - width.toFloat() to DismissValue.DismissedToEnd, - ), - thresholds = { _, _ -> dismissThreshold }, - orientation = Orientation.Horizontal, - enabled = swipeEnabled, - reverseDirection = LocalLayoutDirection.current == LayoutDirection.Rtl, - resistance = ResistanceConfig( - basis = width.toFloat(), - factorAtMin = SwipeableDefaults.StiffResistanceFactor, - factorAtMax = SwipeableDefaults.StandardResistanceFactor, - ), - ), - ) { - Box( - modifier = Modifier - .offset { IntOffset(x = state.offset.value.roundToInt(), y = 0) }, - ) { - content() - } - } - } -} - -@OptIn(ExperimentalMaterialApi::class) -private class SwipeDismissNestedScrollConnection( - private val state: DismissState, -) : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource, - ): Offset = when { - available.x < 0 && source == NestedScrollSource.Drag -> { - // If we're being swiped back to origin, let the SwipeDismiss handle it first - Offset(x = state.performDrag(available.x), y = 0f) - } - - else -> Offset.Zero - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource, - ): Offset = when (source) { - NestedScrollSource.Drag -> Offset(x = state.performDrag(available.x), y = 0f) - else -> Offset.Zero - } - - override suspend fun onPreFling(available: Velocity): Velocity = when { - available.x > 0 && state.offset.value > 0 -> { - state.performFling(velocity = available.x) - available - } - - else -> Velocity.Zero - } - - override suspend fun onPostFling( - consumed: Velocity, - available: Velocity, - ): Velocity { - state.performFling(velocity = available.x) - return available - } -} - -@Composable -@ExperimentalMaterialApi -private fun rememberDismissState( - vararg inputs: Any?, - initialValue: DismissValue = DismissValue.Default, - confirmStateChange: (DismissValue) -> Boolean = { true }, -): DismissState { - return rememberSaveable(inputs, saver = DismissState.Saver(confirmStateChange)) { - DismissState(initialValue, confirmStateChange) - } -} diff --git a/ui/root/src/jvmMain/kotlin/app/tivi/home/GestureNavDecoration.kt b/ui/root/src/jvmMain/kotlin/app/tivi/home/GestureNavDecoration.kt deleted file mode 100644 index 781d7b077f..0000000000 --- a/ui/root/src/jvmMain/kotlin/app/tivi/home/GestureNavDecoration.kt +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2023, Christopher Banes and the Tivi project contributors -// SPDX-License-Identifier: Apache-2.0 - -package app.tivi.home - -import app.tivi.util.Logger -import com.slack.circuit.backstack.NavDecoration -import com.slack.circuit.foundation.NavigatorDefaults -import com.slack.circuit.runtime.Navigator - -internal actual class GestureNavDecoration actual constructor( - navigator: Navigator, - logger: Logger, -) : NavDecoration by NavigatorDefaults.DefaultDecoration