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( modifier = Modifier.overlayControls(
next = { next = {
scope.launch { scope.launch {
val next = (state.current + 1).coerceAtMost(state.max - 1) state.next()
state.snapTo(next)
} }
}, },
prev = { prev = {
scope.launch { scope.launch {
val prev = (state.current - 1).coerceAtLeast(0) state.prev()
state.snapTo(prev)
} }
}, },
center = { center = {

View File

@@ -11,22 +11,20 @@ import eu.wewox.pagecurl.ExperimentalPageCurlApi
@ExperimentalPageCurlApi @ExperimentalPageCurlApi
public data class PageCurlConfig( public data class PageCurlConfig(
val curl: CurlConfig = CurlConfig(), val curl: CurlConfig = CurlConfig(),
val interaction: InteractionConfig = InteractionConfig() val direction: PageCurlDirection = PageCurlDirection.StartToEnd,
val interaction: InteractionConfig = InteractionConfig(forward = direction.forward()),
) )
@ExperimentalPageCurlApi @ExperimentalPageCurlApi
public data class InteractionConfig( public data class InteractionConfig(
val forward: CurlDirection = CurlDirection( val forward: DragDirection,
Rect(Offset(0.5f, 0.0f), Offset(1.0f, 1.0f)), val backward: DragDirection = DragDirection(forward.end, forward.start),
Rect(Offset(0.0f, 0.0f), Offset(0.5f, 1.0f)),
),
val backward: CurlDirection = CurlDirection(forward.end, forward.start),
) )
@ExperimentalPageCurlApi @ExperimentalPageCurlApi
public data class CurlDirection( public data class DragDirection(
val start: Rect, val start: Rect,
val end: Rect val end: Rect,
) )
@ExperimentalPageCurlApi @ExperimentalPageCurlApi
@@ -48,3 +46,20 @@ public data class ShadowConfig(
val radius: Dp = 15.dp, val radius: Dp = 15.dp,
val offset: DpOffset = DpOffset((-5).dp, 0.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 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.VectorConverter
import androidx.compose.animation.core.calculateTargetValue import androidx.compose.animation.core.calculateTargetValue
import androidx.compose.animation.splineBasedDecay 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.input.pointer.util.VelocityTracker
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import eu.wewox.pagecurl.ExperimentalPageCurlApi import eu.wewox.pagecurl.ExperimentalPageCurlApi
import eu.wewox.pagecurl.config.CurlDirection import eu.wewox.pagecurl.config.DragDirection
import eu.wewox.pagecurl.utils.rotate import eu.wewox.pagecurl.utils.rotate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.PI 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 @ExperimentalPageCurlApi
internal fun Modifier.curlGesture( internal fun Modifier.curlGesture(
key: Any?, key: Any?,
enabled: Boolean, enabled: Boolean,
direction: CurlDirection, direction: DragDirection,
onStart: () -> Unit, onStart: () -> Unit,
onCurl: (Offset, Offset) -> Unit, onCurl: (Offset, Offset) -> Unit,
onEnd: () -> Unit, onEnd: () -> Unit,

View File

@@ -1,27 +1,14 @@
package eu.wewox.pagecurl.page 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.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState 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.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Constraints
import eu.wewox.pagecurl.ExperimentalPageCurlApi import eu.wewox.pagecurl.ExperimentalPageCurlApi
import eu.wewox.pagecurl.config.CurlDirection
import eu.wewox.pagecurl.config.PageCurlConfig import eu.wewox.pagecurl.config.PageCurlConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ExperimentalPageCurlApi @ExperimentalPageCurlApi
@Composable @Composable
@@ -42,23 +29,23 @@ public fun PageCurl(
Box( Box(
Modifier Modifier
.curlGesture( .curlGesture(
key = internalState, state = internalState,
enabled = updatedCurrent < state.max - 1, enabled = updatedCurrent < state.max - 1,
scope = scope, scope = scope,
direction = config.interaction.forward, direction = config.interaction.forward,
start = internalState.rightCurl, start = internalState.rightEdge,
end = internalState.leftCurl, end = internalState.leftEdge,
animatable = internalState.forward, edge = internalState.forward,
onChange = { state.current = updatedCurrent + 1 } onChange = { state.current = updatedCurrent + 1 }
) )
.curlGesture( .curlGesture(
key = internalState, state = internalState,
enabled = updatedCurrent > 0, enabled = updatedCurrent > 0,
scope = scope, scope = scope,
direction = config.interaction.backward, direction = config.interaction.backward,
start = internalState.leftCurl, start = internalState.leftEdge,
end = internalState.rightCurl, end = internalState.rightEdge,
animatable = internalState.backward, edge = internalState.backward,
onChange = { state.current = updatedCurrent - 1 } onChange = { state.current = updatedCurrent - 1 }
) )
) { ) {
@@ -67,141 +54,16 @@ public fun PageCurl(
} }
if (updatedCurrent < state.max) { 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) content(updatedCurrent)
} }
} }
if (updatedCurrent > 0) { 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) 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))