-
-
Notifications
You must be signed in to change notification settings - Fork 85
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #533 from arkivanov/material-back-gesture
Added materialPredictiveBackAnimatable as default
- Loading branch information
Showing
14 changed files
with
579 additions
and
70 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
122 changes: 122 additions & 0 deletions
122
...ov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/LayoutCorners.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
19 changes: 19 additions & 0 deletions
19
...ov/decompose/extensions/compose/jetbrains/stack/animation/predictiveback/LayoutCorners.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
134 changes: 134 additions & 0 deletions
134
...ions/compose/jetbrains/stack/animation/predictiveback/MaterialPredictiveBackAnimatable.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) }, | ||
) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.