diff --git a/extensions-compose-jetbrains/api/android/extensions-compose-jetbrains.api b/extensions-compose-jetbrains/api/android/extensions-compose-jetbrains.api index 26ca4566c..ef01c0bf3 100644 --- a/extensions-compose-jetbrains/api/android/extensions-compose-jetbrains.api +++ b/extensions-compose-jetbrains/api/android/extensions-compose-jetbrains.api @@ -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; @@ -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 { diff --git a/extensions-compose-jetbrains/api/jvm/extensions-compose-jetbrains.api b/extensions-compose-jetbrains/api/jvm/extensions-compose-jetbrains.api index a519761cf..f6203c05b 100644 --- a/extensions-compose-jetbrains/api/jvm/extensions-compose-jetbrains.api +++ b/extensions-compose-jetbrains/api/jvm/extensions-compose-jetbrains.api @@ -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; @@ -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 { diff --git a/extensions-compose-jetbrains/src/androidMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/LayoutCorners.kt b/extensions-compose-jetbrains/src/androidMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/LayoutCorners.kt new file mode 100644 index 000000000..a0711ae3f --- /dev/null +++ b/extensions-compose-jetbrains/src/androidMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/LayoutCorners.kt @@ -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(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, +) diff --git a/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/LayoutCorners.kt b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/LayoutCorners.kt new file mode 100644 index 000000000..9a1ef6122 --- /dev/null +++ b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/LayoutCorners.kt @@ -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, +) diff --git a/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/MaterialPredictiveBackAnimatable.kt b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/MaterialPredictiveBackAnimatable.kt new file mode 100644 index 000000000..967cfa53d --- /dev/null +++ b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/MaterialPredictiveBackAnimatable.kt @@ -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) }, + ) + } + } +} diff --git a/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable.kt b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable.kt index 95ca88b62..8f8170b52 100644 --- a/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable.kt +++ b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimatable.kt @@ -1,14 +1,7 @@ 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 androidx.compose.ui.graphics.Shape import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.essenty.backhandler.BackEvent @@ -61,33 +54,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)) - } diff --git a/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimation.kt b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimation.kt index a14edfe99..b941d1e6f 100644 --- a/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimation.kt +++ b/extensions-compose-jetbrains/src/commonMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/PredictiveBackAnimation.kt @@ -42,7 +42,7 @@ fun predictiveBackAnimation( exitChild: Child.Created, enterChild: Child.Created, ) -> PredictiveBackAnimatable = { initialBackEvent, _, _ -> - predictiveBackAnimatable(initialBackEvent = initialBackEvent) + materialPredictiveBackAnimatable(initialBackEvent = initialBackEvent) }, onBack: () -> Unit, ): StackAnimation = diff --git a/extensions-compose-jetbrains/src/nonAndroidMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/LayoutCorners.kt b/extensions-compose-jetbrains/src/nonAndroidMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/LayoutCorners.kt new file mode 100644 index 000000000..ee14e7b17 --- /dev/null +++ b/extensions-compose-jetbrains/src/nonAndroidMain/kotlin/com/arkivanov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/LayoutCorners.kt @@ -0,0 +1,6 @@ +package com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.predictiveback + +import androidx.compose.ui.Modifier + +internal actual fun Modifier.withLayoutCorners(block: Modifier.(LayoutCorners) -> Modifier): Modifier = + block(LayoutCorners()) diff --git a/extensions-compose-jetpack/api/extensions-compose-jetpack.api b/extensions-compose-jetpack/api/extensions-compose-jetpack.api index a4063a739..bed924ad9 100644 --- a/extensions-compose-jetpack/api/extensions-compose-jetpack.api +++ b/extensions-compose-jetpack/api/extensions-compose-jetpack.api @@ -112,6 +112,11 @@ public final class com/arkivanov/decompose/extensions/compose/jetpack/stack/anim public static synthetic fun stackAnimator$default (Landroidx/compose/animation/core/FiniteAnimationSpec;Lkotlin/jvm/functions/Function5;ILjava/lang/Object;)Lcom/arkivanov/decompose/extensions/compose/jetpack/stack/animation/StackAnimator; } +public final class com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/MaterialPredictiveBackAnimatableKt { + public static final fun materialPredictiveBackAnimatable (Lcom/arkivanov/essenty/backhandler/BackEvent;Lkotlin/jvm/functions/Function2;)Lcom/arkivanov/decompose/extensions/compose/jetpack/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/jetpack/stack/animation/predictiveback/PredictiveBackAnimatable; +} + public abstract interface class com/arkivanov/decompose/extensions/compose/jetpack/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; @@ -121,7 +126,6 @@ public abstract interface class com/arkivanov/decompose/extensions/compose/jetpa public final class com/arkivanov/decompose/extensions/compose/jetpack/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/jetpack/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/jetpack/stack/animation/predictiveback/PredictiveBackAnimatable; } public final class com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/PredictiveBackAnimationKt { diff --git a/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/LayoutCorners.kt b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/LayoutCorners.kt new file mode 100644 index 000000000..6e4d1209a --- /dev/null +++ b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/LayoutCorners.kt @@ -0,0 +1,135 @@ +package com.arkivanov.decompose.extensions.compose.jetpack.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.compose.ui.unit.dp +import androidx.core.content.ContextCompat.getSystemService + +internal 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(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, + ) +} + +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, +) + +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, +) diff --git a/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/MaterialPredictiveBackAnimatable.kt b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/MaterialPredictiveBackAnimatable.kt new file mode 100644 index 000000000..f4537872a --- /dev/null +++ b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/MaterialPredictiveBackAnimatable.kt @@ -0,0 +1,134 @@ +package com.arkivanov.decompose.extensions.compose.jetpack.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) }, + ) + } + } +} diff --git a/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/PredictiveBackAnimatable.kt b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/PredictiveBackAnimatable.kt index a6dc4e1be..a1e51eac8 100644 --- a/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/PredictiveBackAnimatable.kt +++ b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/PredictiveBackAnimatable.kt @@ -1,14 +1,6 @@ package com.arkivanov.decompose.extensions.compose.jetpack.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 @@ -61,33 +53,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)) - } diff --git a/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/PredictiveBackAnimation.kt b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/PredictiveBackAnimation.kt index d3cc6624a..c025a9ce9 100644 --- a/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/PredictiveBackAnimation.kt +++ b/extensions-compose-jetpack/src/main/java/com/arkivanov/decompose/extensions/compose/jetpack/stack/animation/predictiveback/PredictiveBackAnimation.kt @@ -42,7 +42,7 @@ fun predictiveBackAnimation( exitChild: Child.Created, enterChild: Child.Created, ) -> PredictiveBackAnimatable = { initialBackEvent, _, _ -> - predictiveBackAnimatable(initialBackEvent = initialBackEvent) + materialPredictiveBackAnimatable(initialBackEvent = initialBackEvent) }, onBack: () -> Unit, ): StackAnimation =