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 24db226..17f0046 100644 --- a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/config/CurlConfig.kt +++ b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/config/CurlConfig.kt @@ -10,24 +10,53 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import eu.wewox.pagecurl.ExperimentalPageCurlApi +/** + * The configuration for PageCurl. + * + * @property curl Configures how page curl looks like. + * @property interaction Configures interactions with a page curl. Such as if drag or tap to go on next or previous + * page is allowed, etc. + */ @ExperimentalPageCurlApi public data class PageCurlConfig( val curl: CurlConfig = CurlConfig(), val interaction: InteractionConfig = InteractionConfig(), ) +/** + * Configures how page curl looks like. + * + * @property backPage Configures how back-page looks like (color, content alpha). + * @property shadow Configures how page shadow looks like (color, alpha, radius). + */ @ExperimentalPageCurlApi public data class CurlConfig( val backPage: BackPageConfig = BackPageConfig(), val shadow: ShadowConfig = ShadowConfig(), ) +/** + * Configures how back-page of the page curl looks like. + * + * @property color Color of the back-page. In majority of use-cases it should be set to the content background color. + * @property contentAlpha The alpha which defines how content is "seen through" the back-page. From 0 (nothing is + * visible) to 1 (everything is visible). + */ @ExperimentalPageCurlApi public data class BackPageConfig( val color: Color = Color.White, val contentAlpha: Float = 0.1f, ) +/** + * Configures how page shadow looks like. + * + * @property color The color of the shadow. In majority of use-cases it should be set to the inverted color to the + * content background color. Should be a solid color, see [alpha] to adjust opacity. + * @property alpha The alpha of the [color]. + * @property radius Defines how big the shadow is. + * @property offset Defines how shadow is shifted from the page. A little shift may add more realism. + */ @ExperimentalPageCurlApi public data class ShadowConfig( val color: Color = Color.Black, @@ -36,16 +65,39 @@ public data class ShadowConfig( val offset: DpOffset = DpOffset((-5).dp, 0.dp), ) +/** + * Configures interactions with a page curl. + * + * @property drag Configures drag interactions. + * @property tap Configures tap interactions. + */ @ExperimentalPageCurlApi public data class InteractionConfig( val drag: Drag = Drag(), val tap: Tap = Tap(), ) { + + /** + * Configures drag interactions. + * + * @property forward Configures forward drag interaction. + * @property backward Configures backward drag interaction. + */ @ExperimentalPageCurlApi public data class Drag( val forward: Interaction = Interaction(true, rightHalf(), leftHalf()), val backward: Interaction = Interaction(true, forward.end, forward.start), ) { + + /** + * The drag interaction setting. + * + * @property enabled True if this interaction is enabled or not. + * @property start Defines a rectangle where interaction should start. The rectangle coordinates are relative + * (from 0 to 1) and then scaled to the PageCurl bounds. + * @property end Defines a rectangle where interaction should end. The rectangle coordinates are relative + * (from 0 to 1) and then scaled to the PageCurl bounds. + */ @ExperimentalPageCurlApi public data class Interaction( val enabled: Boolean, @@ -54,18 +106,41 @@ public data class InteractionConfig( ) } + /** + * Configures tap interactions. + * + * @property forward Configures forward tap interaction. + * @property backward Configures backward tap interaction. + * @property custom The custom tap interaction. Could be provided to implement custom taps in the PageCurl, e.g. to + * capture taps in the center, etc. + */ @ExperimentalPageCurlApi public data class Tap( val forward: Interaction = Interaction(true, rightHalf()), val backward: Interaction = Interaction(true, leftHalf()), val custom: CustomInteraction = CustomInteraction(false) ) { + + /** + * The tap interaction setting. + * + * @property enabled True if this interaction is enabled or not. + * @property target Defines a rectangle where interaction captured. The rectangle coordinates are relative + * (from 0 to 1) and then scaled to the PageCurl bounds. + */ @ExperimentalPageCurlApi public data class Interaction( val enabled: Boolean, val target: Rect = Rect.Zero, ) + /** + * The custom tap interaction setting. + * + * @property enabled True if this interaction is enabled or not. + * @property onTap The lambda to invoke to check if tap is handled by custom tap or not. Receives the density + * scope, the PageCurl size and tap position. Returns true if tap is handled and false otherwise. + */ @ExperimentalPageCurlApi public data class CustomInteraction( val enabled: Boolean, @@ -74,6 +149,15 @@ public data class InteractionConfig( } } +/** + * The utility function to create a new copy of the [InteractionConfig] with different enabled states of each + * interactions. + * + * @param dragForwardEnabled True to enable forward drag interaction. + * @param dragBackwardEnabled True to enable backward drag interaction. + * @param tapForwardEnabled True to enable forward tap interaction. + * @param tapBackwardEnabled True to enable backward tap interaction. + */ @ExperimentalPageCurlApi public fun InteractionConfig.copy( dragForwardEnabled: Boolean = drag.forward.enabled, @@ -91,6 +175,12 @@ public fun InteractionConfig.copy( ) ) +/** + * The left half of the PageCurl. + */ private fun leftHalf(): Rect = Rect(0.0f, 0.0f, 0.5f, 1.0f) +/** + * The right half of the PageCurl. + */ private fun rightHalf(): Rect = Rect(0.5f, 0.0f, 1.0f, 1.0f) diff --git a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/CurlDraw.kt b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/CurlDraw.kt index 2ef52c1..bb29b18 100644 --- a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/CurlDraw.kt +++ b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/CurlDraw.kt @@ -5,6 +5,7 @@ import android.graphics.Canvas import android.os.Build import androidx.compose.ui.Modifier import androidx.compose.ui.draw.CacheDrawScope +import androidx.compose.ui.draw.DrawResult import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.toRect @@ -33,19 +34,20 @@ internal fun Modifier.drawCurl( posA: Offset, posB: Offset, ): Modifier = drawWithCache { - fun drawOnlyContent() = - onDrawWithContent { - drawContent() - } - + // Fast-check if curl is in left most position (gesture is fully completed) + // In such case do not bother and draw nothing if (posA == size.toRect().topLeft && posB == size.toRect().bottomLeft) { - return@drawWithCache onDrawWithContent { } + return@drawWithCache drawNothing() } + // Fast-check if curl is in right most position (gesture is not yet started) + // In such case do not bother and draw the full content if (posA == size.toRect().topRight && posB == size.toRect().bottomRight) { return@drawWithCache drawOnlyContent() } + // Find the intersection of the curl line ([posA, posB]) and top and bottom sides, so that we may clip and mirror + // content correctly val topIntersection = lineLineIntersection( Offset(0f, 0f), Offset(size.width, 0f), posA, posB @@ -54,14 +56,20 @@ internal fun Modifier.drawCurl( Offset(0f, size.height), Offset(size.width, size.height), posA, posB ) + + // Should not really happen, but in case there is not intersection (curl line is horizontal), just draw the full + // content instead if (topIntersection == null || bottomIntersection == null) { return@drawWithCache drawOnlyContent() } + // Limit x coordinates of both intersections to be at least 0, so that page do not look like teared from the book val topCurlOffset = Offset(max(0f, topIntersection.x), topIntersection.y) val bottomCurlOffset = Offset(max(0f, bottomIntersection.x), bottomIntersection.y) + // That is the easy part, prepare a lambda to draw the content clipped by the curl line val drawClippedContent = prepareClippedContent(topCurlOffset, bottomCurlOffset) + // That is the tricky part, prepare a lambda to draw the back-page with the shadow val drawCurl = prepareCurl(config, topCurlOffset, bottomCurlOffset) onDrawWithContent { @@ -70,16 +78,34 @@ internal fun Modifier.drawCurl( } } +/** + * The simple method to draw the whole unmodified content. + */ +private fun CacheDrawScope.drawOnlyContent(): DrawResult = + onDrawWithContent { + drawContent() + } + +/** + * The simple method to draw nothing. + */ +private fun CacheDrawScope.drawNothing(): DrawResult = + onDrawWithContent { + /* Empty */ + } + @ExperimentalPageCurlApi private fun CacheDrawScope.prepareClippedContent( topCurlOffset: Offset, bottomCurlOffset: Offset, ): ContentDrawScope.() -> Unit { + // Make a quadrilateral from the left side to the intersection points val path = Path() path.lineTo(topCurlOffset.x, topCurlOffset.y) path.lineTo(bottomCurlOffset.x, bottomCurlOffset.y) path.lineTo(0f, size.height) return result@{ + // Draw a content clipped by the constructed path clipPath(path) { this@result.drawContent() } @@ -92,8 +118,13 @@ private fun CacheDrawScope.prepareCurl( topCurlOffset: Offset, bottomCurlOffset: Offset, ): ContentDrawScope.() -> Unit { + // Build a quadrilateral of the part of the page which should be mirrored as the back-page + // In all cases polygon should have 4 points, even when back-page is only a small "corner" (with 3 points) due to + // the shadow rendering, otherwise it will create a visual artifact when switching between 3 and 4 points polygon val polygon = Polygon( sequence { + // Find the intersection of the curl line and right side + // If intersection is found adds to the polygon points list suspend fun SequenceScope.yieldEndSideInterception() { val offset = lineLineIntersection( topCurlOffset, bottomCurlOffset, @@ -102,12 +133,18 @@ private fun CacheDrawScope.prepareCurl( yield(offset) yield(offset) } + + // In case top intersection lays in the bounds of the page curl, take 2 points from the top side, otherwise + // take the interception with a right side if (topCurlOffset.x < size.width) { yield(topCurlOffset) yield(Offset(size.width, topCurlOffset.y)) } else { yieldEndSideInterception() } + + // In case bottom intersection lays in the bounds of the page curl, take 2 points from the bottom side, + // otherwise take the interception with a right side if (bottomCurlOffset.x < size.width) { yield(Offset(size.width, size.height)) yield(bottomCurlOffset) @@ -117,17 +154,25 @@ private fun CacheDrawScope.prepareCurl( }.toList() ) + // Calculate the angle in radians between X axis and the curl line, this is used to rotate mirrored content to the + // right position of the curled back-page val lineVector = topCurlOffset - bottomCurlOffset - val angle = Math.PI.toFloat() + atan2(-lineVector.y, lineVector.x) * 2 + val angle = Math.PI.toFloat() - atan2(lineVector.y, lineVector.x) * 2 + + // Prepare a lambda to draw the shadow of the back-page val drawShadow = prepareShadow(config, polygon, angle) return result@{ withTransform({ + // Mirror in X axis the drawing as back-page should be mirrored scale(-1f, 1f, pivot = bottomCurlOffset) + // Rotate the drawing according to the curl line rotateRad(angle, pivot = bottomCurlOffset) }) { + // Draw shadow first this@result.drawShadow() + // And finally draw the back-page with an overlay with alpha clipPath(polygon.toPath()) { this@result.drawContent() @@ -146,15 +191,19 @@ private fun CacheDrawScope.prepareShadow( ): ContentDrawScope.() -> Unit { val shadow = config.shadow + // Quick exit if no shadow is requested if (shadow.alpha == 0f || shadow.radius == 0.dp) { return { /* No shadow is requested */ } } + // Prepare shadow parameters val radius = shadow.radius.toPx() val shadowColor = shadow.color.copy(alpha = shadow.alpha).toArgb() val transparent = shadow.color.copy(alpha = 0f).toArgb() val shadowOffset = Offset(-shadow.offset.x.toPx(), shadow.offset.y.toPx()) .rotate(2 * Math.PI.toFloat() - angle) + + // Prepare shadow paint with a shadow layer val paint = Paint().apply { val frameworkPaint = asFrameworkPaint() frameworkPaint.color = transparent @@ -166,6 +215,8 @@ private fun CacheDrawScope.prepareShadow( ) } + // Hardware acceleration supports setShadowLayer() only on API 28 and above, thus to support previous API versions + // draw a shadow to the bitmap instead return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { prepareShadowApi28(radius, paint, polygon) } else { @@ -193,6 +244,7 @@ private fun CacheDrawScope.prepareShadowImage( paint: Paint, polygon: Polygon, ): ContentDrawScope.() -> Unit { + // Increase the size a little bit so that shadow is not clipped val bitmap = Bitmap.createBitmap( (size.width + radius * 4).toInt(), (size.height + radius * 4).toInt(), @@ -201,6 +253,7 @@ private fun CacheDrawScope.prepareShadowImage( Canvas(bitmap).apply { drawPath( polygon + // As bitmap size is increased we should translate the polygon so that shadow remains in center .translate(Offset(2 * radius, 2 * radius)) .offset(radius).toPath() .asAndroidPath(), @@ -210,6 +263,7 @@ private fun CacheDrawScope.prepareShadowImage( return { drawIntoCanvas { + // As bitmap size is increased we should shift the drawing so that shadow remains in center it.nativeCanvas.drawBitmap(bitmap, -2 * radius, -2 * radius, null) } } 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 a6a25d5..0f4520f 100644 --- a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/CurlGesture.kt +++ b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/CurlGesture.kt @@ -77,6 +77,7 @@ internal fun Modifier.curlGesture( return@pointerInput } + // Use velocity tracker to support flings val velocityTracker = VelocityTracker() val startRect by lazy { direction.start.multiply(size) } val endRect by lazy { direction.end.multiply(size) } @@ -87,6 +88,7 @@ internal fun Modifier.curlGesture( return@awaitPointerEventScope } + // Change X position to be always on the right side for more convenient gesture tracking val dragStart = down.position.copy(x = size.width.toFloat()) onStart() 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 d9dee4e..979b33e 100644 --- a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/PageCurl.kt +++ b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/PageCurl.kt @@ -10,6 +10,14 @@ import androidx.compose.ui.Modifier import eu.wewox.pagecurl.ExperimentalPageCurlApi import eu.wewox.pagecurl.config.PageCurlConfig +/** + * Shows the pages which may be rotated by drag or tap gestures. + * + * @param state The state of the PageCurl. Use this to programmatically change the current page or observe changes. + * @param modifier The modifier for this composable. + * @param config The configuration for PageCurl. Configures how page curl looks like and interacts. + * @param content The content lambda to provide the page composable. Receives the page number. + */ @ExperimentalPageCurlApi @Composable public fun PageCurl( diff --git a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/PageCurlState.kt b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/PageCurlState.kt index 5eda580..0498bba 100644 --- a/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/PageCurlState.kt +++ b/pagecurl/src/main/kotlin/eu/wewox/pagecurl/page/PageCurlState.kt @@ -22,6 +22,13 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +/** + * Remembers the [PageCurlState]. + * + * @param max The max number of pages. + * @param initialCurrent The initial current page. + * @return The remembered [PageCurlState]. + */ @ExperimentalPageCurlApi @Composable public fun rememberPageCurlState( @@ -46,14 +53,27 @@ public fun rememberPageCurlState( ) } +/** + * The state of the PageCurl. + * + * @property max The max number of pages. + * @param initialCurrent The initial current page. + */ @ExperimentalPageCurlApi public class PageCurlState( public val max: Int, initialCurrent: Int = 0, ) { + /** + * The observable current page. + */ public var current: Int by mutableStateOf(initialCurrent) internal set + /** + * The observable progress as page is rotated. + * When going forward it changes from 0 to 1, when going backward it is going from 0 to -1. + */ public val progress: Float get() = internalState?.progress ?: 0f internal var internalState: InternalState? by mutableStateOf(null) @@ -75,11 +95,21 @@ public class PageCurlState( internalState = InternalState(constraints, left, right, forward, backward) } + /** + * Instantly snaps the state to the given page. + * + * @param value The page to snap to. + */ public suspend fun snapTo(value: Int) { current = value.coerceIn(0, max - 1) internalState?.reset() } + /** + * Go forward with an animation. + * + * @param block The animation block to animate a change. + */ public suspend fun next(block: suspend Animatable.(Size) -> Unit = DefaultNext) { internalState?.animateTo( target = { current + 1 }, @@ -87,6 +117,11 @@ public class PageCurlState( ) } + /** + * Go backward with an animation. + * + * @param block The animation block to animate a change. + */ public suspend fun prev(block: suspend Animatable.(Size) -> Unit = DefaultPrev) { internalState?.animateTo( target = { current - 1 }, @@ -146,6 +181,9 @@ public class PageCurlState( } } +/** + * The wrapper to represent a line with 2 points: [top] and [bottom]. + */ public data class Edge(val top: Offset, val bottom: Offset) { internal val centerX: Float = (top.x + bottom.x) * 0.5f