diff --git a/demo/src/main/kotlin/eu/wewox/pagecurl/MainActivity.kt b/demo/src/main/kotlin/eu/wewox/pagecurl/MainActivity.kt index 7195178..1fd3197 100644 --- a/demo/src/main/kotlin/eu/wewox/pagecurl/MainActivity.kt +++ b/demo/src/main/kotlin/eu/wewox/pagecurl/MainActivity.kt @@ -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 = { diff --git a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/config/CurlConfig.kt b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/config/CurlConfig.kt index c7fe1c6..901cde8 100644 --- a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/config/CurlConfig.kt +++ b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/config/CurlConfig.kt @@ -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()) + } diff --git a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/CurlGesture.kt b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/CurlGesture.kt index 1d8696c..86fe3a3 100644 --- a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/CurlGesture.kt +++ b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/CurlGesture.kt @@ -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, + 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, diff --git a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/PageCurl.kt b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/PageCurl.kt index a7aa84b..851b76f 100644 --- a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/PageCurl.kt +++ b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/PageCurl.kt @@ -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, - val backward: Animatable, - ) { - 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, - 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 = - 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) - } -} diff --git a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/PageCurlState.kt b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/PageCurlState.kt new file mode 100644 index 0000000..364dd56 --- /dev/null +++ b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/PageCurlState.kt @@ -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.(Size) -> Unit = DefaultNext) { + internalState?.animateTo( + target = { current + 1 }, + animate = { forward.block(it) } + ) + } + + public suspend fun prev(block: suspend Animatable.(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, + val backward: Animatable, + ) { + + 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 = + 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.(Size) -> Unit = { size -> + animateTo( + targetValue = size.start, + animationSpec = keyframes { + durationMillis = DefaultAnimDuration + size.end at 0 + size.middle at DefaultMidPointDuration + } + ) +} + +private val DefaultPrev: suspend Animatable.(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))