Skip to content

Commit

Permalink
Merge pull request #533 from arkivanov/material-back-gesture
Browse files Browse the repository at this point in the history
Added materialPredictiveBackAnimatable as default
  • Loading branch information
arkivanov authored Nov 25, 2023
2 parents 4013a7d + 5612d52 commit b91e260
Show file tree
Hide file tree
Showing 14 changed files with 579 additions and 70 deletions.
4 changes: 3 additions & 1 deletion docs/extensions/compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,9 @@ Please refer to the predefined animators (`fade`, `slide`, etc.) for implementat
!!!warning
Predictive Back Gesture support is experimental, the API is subject to change. For now, please use version 2.1.x.

`Child Stack` supports the new [Android Predictive Back Gesture](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture) on all platforms. To enable the gesture, first implement `BackHandlerOwner` interface in your component with `Child Stack`, then just pass `predictiveBackAnimation` to the `Children` function.
`Child Stack` supports the new [Android Predictive Back Gesture](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture) on all platforms. By default, the gesture animation resembles the [predictive back design for Android](https://developer.android.com/design/ui/mobile/guides/patterns/predictive-back), but it's customizable.

To enable the gesture, first implement `BackHandlerOwner` interface in your component with `Child Stack`, then just pass `predictiveBackAnimation` to the `Children` function.

```kotlin title="RootComponent"
interface RootComponent : BackHandlerOwner {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/an
public static synthetic fun stackAnimator$default (Landroidx/compose/animation/core/FiniteAnimationSpec;Lkotlin/jvm/functions/Function5;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimator;
}

public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/MaterialPredictiveBackAnimatableKt {
public static final fun materialPredictiveBackAnimatable (Lcom/arkivanov/essenty/backhandler/BackEvent;Lkotlin/jvm/functions/Function2;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable;
public static synthetic fun materialPredictiveBackAnimatable$default (Lcom/arkivanov/essenty/backhandler/BackEvent;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable;
}

public abstract interface class com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable {
public abstract fun animate (Lcom/arkivanov/essenty/backhandler/BackEvent;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun finish (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Expand All @@ -121,7 +126,6 @@ public abstract interface class com/arkivanov/decompose/extensions/compose/jetbr

public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatableKt {
public static final fun predictiveBackAnimatable (Lcom/arkivanov/essenty/backhandler/BackEvent;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable;
public static synthetic fun predictiveBackAnimatable$default (Lcom/arkivanov/essenty/backhandler/BackEvent;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable;
}

public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimationKt {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/an
public static synthetic fun stackAnimator$default (Landroidx/compose/animation/core/FiniteAnimationSpec;Lkotlin/jvm/functions/Function5;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/StackAnimator;
}

public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/MaterialPredictiveBackAnimatableKt {
public static final fun materialPredictiveBackAnimatable (Lcom/arkivanov/essenty/backhandler/BackEvent;Lkotlin/jvm/functions/Function2;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable;
public static synthetic fun materialPredictiveBackAnimatable$default (Lcom/arkivanov/essenty/backhandler/BackEvent;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable;
}

public abstract interface class com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable {
public abstract fun animate (Lcom/arkivanov/essenty/backhandler/BackEvent;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun finish (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Expand All @@ -133,7 +138,6 @@ public abstract interface class com/arkivanov/decompose/extensions/compose/jetbr

public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatableKt {
public static final fun predictiveBackAnimatable (Lcom/arkivanov/essenty/backhandler/BackEvent;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable;
public static synthetic fun predictiveBackAnimatable$default (Lcom/arkivanov/essenty/backhandler/BackEvent;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable;
}

public final class com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimationKt {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.predictiveback

import android.content.Context
import android.os.Build
import android.view.RoundedCorner
import android.view.View
import android.view.WindowManager
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.core.content.ContextCompat.getSystemService

internal actual fun Modifier.withLayoutCorners(block: Modifier.(LayoutCorners) -> Modifier): Modifier =
composed {
val context = LocalContext.current
val density = LocalDensity.current
val screenInfo = remember(context) { context.getScreenInfo(density) }

if (screenInfo != null) {
val rootView = LocalView.current
val layoutDirection = LocalLayoutDirection.current
var positionOnScreen by remember { mutableStateOf<Rect?>(null) }
val corners = getLayoutCorners(screenInfo, positionOnScreen, layoutDirection)

onGloballyPositioned { coords ->
positionOnScreen = getBoundsOnScreen(rootView = rootView, boundsInRoot = coords.boundsInRoot())
}.block(corners)
} else {
block(LayoutCorners())
}
}

private fun Context.getScreenInfo(density: Density): ScreenInfo? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return null
}

val windowMetrics = requireNotNull(getSystemService(this, WindowManager::class.java)).maximumWindowMetrics
val insets = windowMetrics.windowInsets

return with(density) {
ScreenInfo(
cornerRadii = CornerRadii(
topLeft = insets.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT)?.radius?.toDp(),
topRight = insets.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT)?.radius?.toDp(),
bottomRight = insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)?.radius?.toDp(),
bottomLeft = insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT)?.radius?.toDp(),
),
width = windowMetrics.bounds.width(),
height = windowMetrics.bounds.height(),
)
}
}

private fun getLayoutCorners(
screenInfo: ScreenInfo,
positionOnScreen: Rect?,
layoutDirection: LayoutDirection,
): LayoutCorners {
if (positionOnScreen == null) {
return LayoutCorners()
}

val (cornerRadii, screenWidth, screenHeight) = screenInfo
val (left, top, right, bottom) = positionOnScreen

val topLeft = getLayoutCorner(radius = cornerRadii.topLeft, isFixed = (left <= 0) && (top <= 0))
val topRight = getLayoutCorner(radius = cornerRadii.topRight, isFixed = (right >= screenWidth) && (top <= 0))
val bottomRight = getLayoutCorner(radius = cornerRadii.bottomRight, isFixed = (right >= screenWidth) && (bottom >= screenHeight))
val bottomLeft = getLayoutCorner(radius = cornerRadii.bottomLeft, isFixed = (left <= 0) && (bottom >= screenHeight))

return when (layoutDirection) {
LayoutDirection.Ltr -> LayoutCorners(topStart = topLeft, topEnd = topRight, bottomEnd = bottomRight, bottomStart = bottomLeft)
LayoutDirection.Rtl -> LayoutCorners(topStart = topRight, topEnd = topLeft, bottomEnd = bottomLeft, bottomStart = bottomRight)
}
}

private fun getLayoutCorner(radius: Dp?, isFixed: Boolean): LayoutCorner =
if (radius == null) {
LayoutCorner()
} else {
LayoutCorner(radius = radius, isFixed = isFixed)
}

private fun getBoundsOnScreen(rootView: View, boundsInRoot: Rect): Rect {
val rootViewLeftTopOnScreen = IntArray(2)
rootView.getLocationOnScreen(rootViewLeftTopOnScreen)
val (rootViewLeftOnScreen, rootViewTopOnScreen) = rootViewLeftTopOnScreen

return Rect(
left = rootViewLeftOnScreen + boundsInRoot.left,
top = rootViewTopOnScreen + boundsInRoot.top,
right = rootViewLeftOnScreen + boundsInRoot.right,
bottom = rootViewTopOnScreen + boundsInRoot.bottom,
)
}

private data class ScreenInfo(
val cornerRadii: CornerRadii,
val width: Int,
val height: Int,
)

private data class CornerRadii(
val topLeft: Dp? = null,
val topRight: Dp? = null,
val bottomRight: Dp? = null,
val bottomLeft: Dp? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.predictiveback

import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

internal expect fun Modifier.withLayoutCorners(block: Modifier.(LayoutCorners) -> Modifier): Modifier

internal data class LayoutCorners(
val topStart: LayoutCorner = LayoutCorner(),
val topEnd: LayoutCorner = LayoutCorner(),
val bottomEnd: LayoutCorner = LayoutCorner(),
val bottomStart: LayoutCorner = LayoutCorner(),
)

internal data class LayoutCorner(
val radius: Dp = 16.dp,
val isFixed: Boolean = true,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.predictiveback

import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.GraphicsLayerScope
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.essenty.backhandler.BackEvent
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch

/**
* Creates an implementation of [PredictiveBackAnimatable] that resembles the
* [predictive back design for Android](https://developer.android.com/design/ui/mobile/guides/patterns/predictive-back).
*
* @param initialBackEvent an initial [BackEvent] of the predictive back gesture.
* @param shape an optional clipping shape of the child being removed (the currently active child).
* If not supplied then a [RoundedCornerShape][androidx.compose.foundation.shape.RoundedCornerShape] will be applied.
*/
@ExperimentalDecomposeApi
fun materialPredictiveBackAnimatable(
initialBackEvent: BackEvent,
shape: ((progress: Float, edge: BackEvent.SwipeEdge) -> Shape)? = null,
): PredictiveBackAnimatable =
MaterialPredictiveBackAnimatable(
initialEvent = initialBackEvent,
shape = shape,
)

@ExperimentalDecomposeApi
private class MaterialPredictiveBackAnimatable(
private val initialEvent: BackEvent,
private val shape: ((progress: Float, edge: BackEvent.SwipeEdge) -> Shape)? = null,
) : PredictiveBackAnimatable {

private val finishProgressAnimatable = Animatable(initialValue = 1F)
private val finishProgress by derivedStateOf { finishProgressAnimatable.value }
private val progressAnimatable = Animatable(initialValue = initialEvent.progress)
private val progress by derivedStateOf { progressAnimatable.value }
private var edge by mutableStateOf(initialEvent.swipeEdge)
private var touchY by mutableFloatStateOf(initialEvent.touchY)

override val exitModifier: Modifier
get() =
if (shape == null) {
Modifier.withLayoutCorners { corners ->
graphicsLayer { setupExitGraphicLayer(corners.toShape()) }
}
} else {
Modifier.graphicsLayer {
setupExitGraphicLayer(this@MaterialPredictiveBackAnimatable.shape.invoke(progress, edge))
}
}

override val enterModifier: Modifier
get() =
Modifier.drawWithContent {
drawContent()
drawRect(color = Color.Black.copy(alpha = finishProgress * 0.25F))
}

private fun GraphicsLayerScope.setupExitGraphicLayer(layoutShape: Shape) {
val pivotFractionX =
when (edge) {
BackEvent.SwipeEdge.LEFT -> 1F
BackEvent.SwipeEdge.RIGHT -> 0F
BackEvent.SwipeEdge.UNKNOWN -> 0.5F
}

transformOrigin = TransformOrigin(pivotFractionX = pivotFractionX, pivotFractionY = 0.5F)

val scale = 1F - progress / 10F
scaleX = scale
scaleY = scale

val translationXLimit =
when (edge) {
BackEvent.SwipeEdge.LEFT -> -8.dp.toPx()
BackEvent.SwipeEdge.RIGHT -> 8.dp.toPx()
BackEvent.SwipeEdge.UNKNOWN -> 0F
}

translationX = translationXLimit * progress

val translationYLimit = size.height / 20F - 8.dp.toPx()
val translationYFactor = ((touchY - initialEvent.touchY) / size.height) * (progress * 3F).coerceAtMost(1f)
translationY = translationYLimit * translationYFactor

alpha = finishProgress
shape = layoutShape
clip = true
}

private fun LayoutCorners.toShape(): RoundedCornerShape =
RoundedCornerShape(
topStart = topStart.getProgressRadius(),
topEnd = topEnd.getProgressRadius(),
bottomEnd = bottomEnd.getProgressRadius(),
bottomStart = bottomStart.getProgressRadius(),
)

private fun LayoutCorner.getProgressRadius(): Dp =
if (isFixed) radius else radius * progress

override suspend fun animate(event: BackEvent) {
edge = event.swipeEdge
touchY = event.touchY
progressAnimatable.animateTo(event.progress)
}

override suspend fun finish() {
val velocityFactor = progressAnimatable.velocity.coerceAtMost(1F) / 1F
val progress = progressAnimatable.value
coroutineScope {
joinAll(
launch { progressAnimatable.animateTo(progress + (1F - progress) * velocityFactor) },
launch { finishProgressAnimatable.animateTo(targetValue = 0F) },
)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
package com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.predictiveback

import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.essenty.backhandler.BackEvent

Expand Down Expand Up @@ -51,6 +43,11 @@ interface PredictiveBackAnimatable {

/**
* Creates a default implementation of [PredictiveBackAnimatable] with customisable exit and enter [Modifier]s.
* The gesture progress follows the events from the system. Automatically animates the progress towards 1.0
* once the gesture is confirmed.
*
* If the behaviour of the returned default [PredictiveBackAnimatable] is undesired or the API is not suitable for
* your use case, then consider implementing [PredictiveBackAnimatable] manually.
*
* @param initialBackEvent an initial [BackEvent] of the predictive back gesture.
* @param exitModifier a function that returns a [Modifier] for every gesture event, for
Expand All @@ -61,33 +58,11 @@ interface PredictiveBackAnimatable {
@ExperimentalDecomposeApi
fun predictiveBackAnimatable(
initialBackEvent: BackEvent,
exitModifier: (progress: Float, edge: BackEvent.SwipeEdge) -> Modifier = { progress, edge ->
Modifier.exitModifier(progress = progress, edge = edge)
},
enterModifier: (progress: Float, edge: BackEvent.SwipeEdge) -> Modifier = { progress, _ ->
Modifier.enterModifier(progress = progress)
},
exitModifier: (progress: Float, edge: BackEvent.SwipeEdge) -> Modifier,
enterModifier: (progress: Float, edge: BackEvent.SwipeEdge) -> Modifier,
): PredictiveBackAnimatable =
DefaultPredictiveBackAnimatable(
initialBackEvent = initialBackEvent,
getExitModifier = exitModifier,
getEnterModifier = enterModifier,
)

private fun Modifier.exitModifier(progress: Float, edge: BackEvent.SwipeEdge): Modifier =
scale(1F - progress * 0.25F)
.absoluteOffset(
x = when (edge) {
BackEvent.SwipeEdge.LEFT -> 32.dp * progress
BackEvent.SwipeEdge.RIGHT -> (-32).dp * progress
BackEvent.SwipeEdge.UNKNOWN -> 0.dp
},
)
.alpha(((1F - progress) * 2F).coerceAtMost(1F))
.clip(RoundedCornerShape(size = 64.dp * progress))

private fun Modifier.enterModifier(progress: Float): Modifier =
drawWithContent {
drawContent()
drawRect(color = Color(red = 0F, green = 0F, blue = 0F, alpha = (1F - progress) / 4F))
}
Loading

0 comments on commit b91e260

Please sign in to comment.