Add next / prev animations

This commit is contained in:
Oleksandr Balan
2022-06-30 17:32:03 +02:00
parent 973c54f39c
commit 840093c2e7
5 changed files with 265 additions and 162 deletions

View File

@@ -48,14 +48,12 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.overlayControls(
next = {
scope.launch {
val next = (state.current + 1).coerceAtMost(state.max - 1)
state.snapTo(next)
state.next()
}
},
prev = {
scope.launch {
val prev = (state.current - 1).coerceAtLeast(0)
state.snapTo(prev)
state.prev()
}
},
center = {

View File

@@ -11,22 +11,20 @@ import eu.wewox.pagecurl.ExperimentalPageCurlApi
@ExperimentalPageCurlApi
public data class PageCurlConfig(
val curl: CurlConfig = CurlConfig(),
val interaction: InteractionConfig = InteractionConfig()
val direction: PageCurlDirection = PageCurlDirection.StartToEnd,
val interaction: InteractionConfig = InteractionConfig(forward = direction.forward()),
)
@ExperimentalPageCurlApi
public data class InteractionConfig(
val forward: CurlDirection = CurlDirection(
Rect(Offset(0.5f, 0.0f), Offset(1.0f, 1.0f)),
Rect(Offset(0.0f, 0.0f), Offset(0.5f, 1.0f)),
),
val backward: CurlDirection = CurlDirection(forward.end, forward.start),
val forward: DragDirection,
val backward: DragDirection = DragDirection(forward.end, forward.start),
)
@ExperimentalPageCurlApi
public data class CurlDirection(
public data class DragDirection(
val start: Rect,
val end: Rect
val end: Rect,
)
@ExperimentalPageCurlApi
@@ -48,3 +46,20 @@ public data class ShadowConfig(
val radius: Dp = 15.dp,
val offset: DpOffset = DpOffset((-5).dp, 0.dp),
)
@ExperimentalPageCurlApi
public enum class PageCurlDirection {
StartToEnd,
// TODO (Alex) Add support for reversed end-to-start direction
// EndToStart,
}
private fun left(): Rect = Rect(Offset(0.0f, 0.0f), Offset(0.5f, 1.0f))
private fun right(): Rect = Rect(Offset(0.5f, 0.0f), Offset(1.0f, 1.0f))
@ExperimentalPageCurlApi
private fun PageCurlDirection.forward(): DragDirection =
when (this) {
PageCurlDirection.StartToEnd -> DragDirection(right(), left())
}

View File

@@ -1,5 +1,7 @@
package eu.wewox.pagecurl.page
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector4D
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.calculateTargetValue
import androidx.compose.animation.splineBasedDecay
@@ -13,15 +15,60 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.unit.IntSize
import eu.wewox.pagecurl.ExperimentalPageCurlApi
import eu.wewox.pagecurl.config.CurlDirection
import eu.wewox.pagecurl.config.DragDirection
import eu.wewox.pagecurl.utils.rotate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.PI
@ExperimentalPageCurlApi
internal fun Modifier.curlGesture(
state: PageCurlState.InternalState,
enabled: Boolean,
scope: CoroutineScope,
direction: DragDirection,
start: Edge,
end: Edge,
edge: Animatable<Edge, AnimationVector4D>,
onChange: () -> Unit,
): Modifier =
curlGesture(
key = state,
enabled = enabled,
direction = direction,
onStart = {
scope.launch {
state.animateJob?.cancel()
edge.snapTo(start)
}
},
onCurl = { a, b ->
scope.launch {
edge.animateTo(Edge(a, b))
}
},
onEnd = {
scope.launch {
try {
edge.animateTo(end)
} finally {
onChange()
edge.snapTo(start)
}
}
},
onCancel = {
scope.launch {
edge.animateTo(start)
}
},
)
@ExperimentalPageCurlApi
internal fun Modifier.curlGesture(
key: Any?,
enabled: Boolean,
direction: CurlDirection,
direction: DragDirection,
onStart: () -> Unit,
onCurl: (Offset, Offset) -> Unit,
onEnd: () -> Unit,

View File

@@ -1,27 +1,14 @@
package eu.wewox.pagecurl.page
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector4D
import androidx.compose.animation.core.TwoWayConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Constraints
import eu.wewox.pagecurl.ExperimentalPageCurlApi
import eu.wewox.pagecurl.config.CurlDirection
import eu.wewox.pagecurl.config.PageCurlConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ExperimentalPageCurlApi
@Composable
@@ -42,23 +29,23 @@ public fun PageCurl(
Box(
Modifier
.curlGesture(
key = internalState,
state = internalState,
enabled = updatedCurrent < state.max - 1,
scope = scope,
direction = config.interaction.forward,
start = internalState.rightCurl,
end = internalState.leftCurl,
animatable = internalState.forward,
start = internalState.rightEdge,
end = internalState.leftEdge,
edge = internalState.forward,
onChange = { state.current = updatedCurrent + 1 }
)
.curlGesture(
key = internalState,
state = internalState,
enabled = updatedCurrent > 0,
scope = scope,
direction = config.interaction.backward,
start = internalState.leftCurl,
end = internalState.rightCurl,
animatable = internalState.backward,
start = internalState.leftEdge,
end = internalState.rightEdge,
edge = internalState.backward,
onChange = { state.current = updatedCurrent - 1 }
)
) {
@@ -67,141 +54,16 @@ public fun PageCurl(
}
if (updatedCurrent < state.max) {
Box(Modifier.drawCurl(config.curl, internalState.forward.value.a, internalState.forward.value.b)) {
Box(Modifier.drawCurl(config.curl, internalState.forward.value.top, internalState.forward.value.bottom)) {
content(updatedCurrent)
}
}
if (updatedCurrent > 0) {
Box(Modifier.drawCurl(config.curl, internalState.backward.value.a, internalState.backward.value.b)) {
Box(Modifier.drawCurl(config.curl, internalState.backward.value.top, internalState.backward.value.bottom)) {
content(updatedCurrent - 1)
}
}
}
}
}
@ExperimentalPageCurlApi
@Composable
public fun rememberPageCurlState(
max: Int,
initialCurrent: Int = 0,
): PageCurlState =
rememberSaveable(
max, initialCurrent,
saver = Saver(
save = { it.current },
restore = {
PageCurlState(
max = it,
initialCurrent = initialCurrent
)
}
)
) {
PageCurlState(
max = max,
initialCurrent = initialCurrent
)
}
@ExperimentalPageCurlApi
public class PageCurlState(
public val max: Int,
initialCurrent: Int = 0,
) {
public var current: Int by mutableStateOf(initialCurrent)
internal set
internal var internalState: InternalState? by mutableStateOf(null)
internal fun setup(constraints: Constraints) {
if (internalState?.constraints == constraints) {
return
}
val maxWidthPx = constraints.maxWidth.toFloat()
val maxHeightPx = constraints.maxHeight.toFloat()
val left = Curl(Offset(0f, 0f), Offset(0f, maxHeightPx))
val right = Curl(Offset(maxWidthPx, 0f), Offset(maxWidthPx, maxHeightPx))
val forward = Animatable(right, Curl.VectorConverter, Curl.VisibilityThreshold)
val backward = Animatable(left, Curl.VectorConverter, Curl.VisibilityThreshold)
internalState = InternalState(constraints, left, right, forward, backward)
}
public suspend fun snapTo(value: Int) {
internalState?.reset()
current = value
}
internal data class InternalState(
val constraints: Constraints,
val leftCurl: Curl,
val rightCurl: Curl,
val forward: Animatable<Curl, AnimationVector4D>,
val backward: Animatable<Curl, AnimationVector4D>,
) {
internal suspend fun reset() {
forward.snapTo(rightCurl)
backward.snapTo(leftCurl)
}
}
}
@ExperimentalPageCurlApi
private fun Modifier.curlGesture(
key: Any?,
enabled: Boolean,
scope: CoroutineScope,
direction: CurlDirection,
start: Curl,
end: Curl,
animatable: Animatable<Curl, AnimationVector4D>,
onChange: () -> Unit,
): Modifier =
curlGesture(
key = key,
enabled = enabled,
direction = direction,
onStart = {
scope.launch {
animatable.snapTo(start)
}
},
onCurl = { a, b ->
scope.launch {
animatable.animateTo(Curl(a, b))
}
},
onEnd = {
scope.launch {
try {
animatable.animateTo(end)
} finally {
onChange()
animatable.snapTo(start)
}
}
},
onCancel = {
scope.launch {
animatable.animateTo(start)
}
},
)
internal data class Curl(val a: Offset, val b: Offset) {
companion object {
val VectorConverter: TwoWayConverter<Curl, AnimationVector4D> =
TwoWayConverter(
convertToVector = { AnimationVector4D(it.a.x, it.a.y, it.b.x, it.b.y) },
convertFromVector = { Curl(Offset(it.v1, it.v2), Offset(it.v3, it.v4)) }
)
val VisibilityThreshold: Curl =
Curl(Offset.VisibilityThreshold, Offset.VisibilityThreshold)
}
}

View File

@@ -0,0 +1,181 @@
package eu.wewox.pagecurl.page
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector4D
import androidx.compose.animation.core.TwoWayConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.keyframes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.unit.Constraints
import eu.wewox.pagecurl.ExperimentalPageCurlApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ExperimentalPageCurlApi
@Composable
public fun rememberPageCurlState(
max: Int,
initialCurrent: Int = 0,
): PageCurlState =
rememberSaveable(
max, initialCurrent,
saver = Saver(
save = { it.current },
restore = {
PageCurlState(
max = it,
initialCurrent = initialCurrent
)
}
)
) {
PageCurlState(
max = max,
initialCurrent = initialCurrent
)
}
@ExperimentalPageCurlApi
public class PageCurlState(
public val max: Int,
initialCurrent: Int = 0,
) {
public var current: Int by mutableStateOf(initialCurrent)
internal set
internal var internalState: InternalState? by mutableStateOf(null)
internal fun setup(constraints: Constraints) {
if (internalState?.constraints == constraints) {
return
}
val maxWidthPx = constraints.maxWidth.toFloat()
val maxHeightPx = constraints.maxHeight.toFloat()
val left = Edge(Offset(0f, 0f), Offset(0f, maxHeightPx))
val right = Edge(Offset(maxWidthPx, 0f), Offset(maxWidthPx, maxHeightPx))
val forward = Animatable(right, Edge.VectorConverter, Edge.VisibilityThreshold)
val backward = Animatable(left, Edge.VectorConverter, Edge.VisibilityThreshold)
internalState = InternalState(constraints, left, right, forward, backward)
}
public suspend fun snapTo(value: Int) {
current = value.coerceIn(0, max - 1)
internalState?.reset()
}
public suspend fun next(block: suspend Animatable<Edge, AnimationVector4D>.(Size) -> Unit = DefaultNext) {
internalState?.animateTo(
target = { current + 1 },
animate = { forward.block(it) }
)
}
public suspend fun prev(block: suspend Animatable<Edge, AnimationVector4D>.(Size) -> Unit = DefaultPrev) {
internalState?.animateTo(
target = { current - 1 },
animate = { backward.block(it) }
)
}
internal inner class InternalState(
val constraints: Constraints,
val leftEdge: Edge,
val rightEdge: Edge,
val forward: Animatable<Edge, AnimationVector4D>,
val backward: Animatable<Edge, AnimationVector4D>,
) {
var animateJob: Job? = null
suspend fun reset() {
forward.snapTo(rightEdge)
backward.snapTo(leftEdge)
}
suspend fun animateTo(
target: () -> Int,
animate: suspend InternalState.(Size) -> Unit
) {
animateJob?.cancel()
val targetIndex = target()
if (targetIndex < 0 || targetIndex >= max) {
return
}
coroutineScope {
animateJob = launch {
try {
reset()
animate(Size(constraints.maxWidth.toFloat(), constraints.maxHeight.toFloat()))
} finally {
withContext(NonCancellable) {
snapTo(target())
}
}
}
}
}
}
}
public data class Edge(val top: Offset, val bottom: Offset) {
internal companion object {
val VectorConverter: TwoWayConverter<Edge, AnimationVector4D> =
TwoWayConverter(
convertToVector = { AnimationVector4D(it.top.x, it.top.y, it.bottom.x, it.bottom.y) },
convertFromVector = { Edge(Offset(it.v1, it.v2), Offset(it.v3, it.v4)) }
)
val VisibilityThreshold: Edge =
Edge(Offset.VisibilityThreshold, Offset.VisibilityThreshold)
}
}
private val DefaultNext: suspend Animatable<Edge, AnimationVector4D>.(Size) -> Unit = { size ->
animateTo(
targetValue = size.start,
animationSpec = keyframes {
durationMillis = DefaultAnimDuration
size.end at 0
size.middle at DefaultMidPointDuration
}
)
}
private val DefaultPrev: suspend Animatable<Edge, AnimationVector4D>.(Size) -> Unit = { size ->
animateTo(
targetValue = size.end,
animationSpec = keyframes {
durationMillis = DefaultAnimDuration
size.start at 0
size.middle at DefaultAnimDuration - DefaultMidPointDuration
}
)
}
private const val DefaultAnimDuration: Int = 450
private const val DefaultMidPointDuration: Int = 150
private val Size.start: Edge
get() = Edge(Offset(0f, 0f), Offset(0f, height))
private val Size.middle: Edge
get() = Edge(Offset(width, height / 2f), Offset(width / 2f, height))
private val Size.end: Edge
get() = Edge(Offset(width, height), Offset(width, height))