Add comments

This commit is contained in:
Oleksandr Balan
2022-08-09 18:34:08 +02:00
parent 43e5c076b9
commit 44a323b254
5 changed files with 199 additions and 7 deletions

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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(

View File

@@ -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