mirror of
https://github.com/fankes/pagecurl-multiplatform.git
synced 2025-09-06 02:35:25 +08:00
Add comments
This commit is contained in:
@@ -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)
|
||||
|
@@ -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<Offset>.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)
|
||||
}
|
||||
}
|
||||
|
@@ -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()
|
||||
|
@@ -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(
|
||||
|
@@ -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<Edge, AnimationVector4D>.(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<Edge, AnimationVector4D>.(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
|
||||
|
Reference in New Issue
Block a user