Extract to library

This commit is contained in:
Oleksandr Balan
2022-05-14 22:08:29 +02:00
parent d62d35c898
commit 81d8898d24
48 changed files with 393 additions and 90 deletions

1
pagecurl/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

43
pagecurl/build.gradle Normal file
View File

@@ -0,0 +1,43 @@
plugins {
id "com.android.library"
id "org.jetbrains.kotlin.android"
}
android {
compileSdk 32
defaultConfig {
minSdk 24
targetSdk 32
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
}
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = freeCompilerArgs +
"-Xexplicit-api=strict" +
"-Xopt-in=kotlin.RequiresOptIn"
}
}
dependencies {
implementation("androidx.compose.ui:ui:$compose_version")
implementation("androidx.compose.foundation:foundation:$compose_version")
}

View File

21
pagecurl/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.wewox.pagecurl" />

View File

@@ -0,0 +1,9 @@
package eu.wewox.pagecurl
/**
* Used for annotating experimental page curl API that is likely to change or be removed in the future.
*/
@RequiresOptIn(
"This API is experimental and is likely to change or to be removed in the future."
)
public annotation class ExperimentalPageCurlApi

View File

@@ -0,0 +1,53 @@
package eu.wewox.pagecurl.config
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import eu.wewox.pagecurl.ExperimentalPageCurlApi
@ExperimentalPageCurlApi
public data class PageCurlConfig(
val curl: CurlConfig = CurlConfig(),
val interaction: InteractionConfig = InteractionConfig()
)
@ExperimentalPageCurlApi
public data class InteractionConfig(
val forward: CurlDirection.Forward = CurlDirection.Forward(
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.Backward = CurlDirection.Backward(forward.end, forward.start),
)
@ExperimentalPageCurlApi
public sealed interface CurlDirection {
public val start: Rect
public val end: Rect
public data class Forward(override val start: Rect, override val end: Rect) : CurlDirection
public data class Backward(override val start: Rect, override val end: Rect) : CurlDirection
}
@ExperimentalPageCurlApi
public data class CurlConfig(
val backPage: BackPageConfig = BackPageConfig(),
val shadow: ShadowConfig = ShadowConfig(),
)
@ExperimentalPageCurlApi
public data class BackPageConfig(
val color: Color = Color.White,
val contentAlpha: Float = 0.1f,
)
@ExperimentalPageCurlApi
public data class ShadowConfig(
val color: Color = Color.Black,
val alpha: Float = 0.2f,
val radius: Dp = 15.dp,
val offset: DpOffset = DpOffset((-5).dp, 0.dp),
)

View File

@@ -0,0 +1,216 @@
package eu.wewox.pagecurl.page
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.CacheDrawScope
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.rotateRad
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import eu.wewox.pagecurl.ExperimentalPageCurlApi
import eu.wewox.pagecurl.config.CurlConfig
import eu.wewox.pagecurl.utils.Polygon
import eu.wewox.pagecurl.utils.lineLineIntersection
import eu.wewox.pagecurl.utils.rotate
import java.lang.Float.max
import kotlin.math.atan2
@ExperimentalPageCurlApi
public fun Modifier.drawCurl(
config: CurlConfig = CurlConfig(),
posA: Offset,
posB: Offset,
): Modifier = drawWithCache {
fun drawOnlyContent() =
onDrawWithContent {
drawContent()
}
if (posA == size.toRect().topLeft && posB == size.toRect().bottomLeft) {
return@drawWithCache onDrawWithContent { }
}
if (posA == size.toRect().topRight && posB == size.toRect().bottomRight) {
return@drawWithCache drawOnlyContent()
}
val topIntersection = lineLineIntersection(
Offset(0f, 0f), Offset(size.width, 0f),
posA, posB
)
val bottomIntersection = lineLineIntersection(
Offset(0f, size.height), Offset(size.width, size.height),
posA, posB
)
if (topIntersection == null || bottomIntersection == null) {
return@drawWithCache drawOnlyContent()
}
val topCurlOffset = Offset(max(0f, topIntersection.x), topIntersection.y)
val bottomCurlOffset = Offset(max(0f, bottomIntersection.x), bottomIntersection.y)
val drawClippedContent = prepareClippedContent(topCurlOffset, bottomCurlOffset)
val drawCurl = prepareCurl(config, topCurlOffset, bottomCurlOffset)
onDrawWithContent {
drawClippedContent()
drawCurl()
}
}
@ExperimentalPageCurlApi
private fun CacheDrawScope.prepareClippedContent(
topCurlOffset: Offset,
bottomCurlOffset: Offset,
): ContentDrawScope.() -> Unit {
val path = Path()
path.lineTo(topCurlOffset.x, topCurlOffset.y)
path.lineTo(bottomCurlOffset.x, bottomCurlOffset.y)
path.lineTo(0f, size.height)
return result@{
clipPath(path) {
this@result.drawContent()
}
}
}
@ExperimentalPageCurlApi
private fun CacheDrawScope.prepareCurl(
config: CurlConfig,
topCurlOffset: Offset,
bottomCurlOffset: Offset,
): ContentDrawScope.() -> Unit {
val polygon = Polygon(
sequence {
suspend fun SequenceScope<Offset>.yieldEndSideInterception() {
val offset = lineLineIntersection(
topCurlOffset, bottomCurlOffset,
Offset(size.width, 0f), Offset(size.width, size.height)
) ?: return
yield(offset)
yield(offset)
}
if (topCurlOffset.x < size.width) {
yield(topCurlOffset)
yield(Offset(size.width, topCurlOffset.y))
} else {
yieldEndSideInterception()
}
if (bottomCurlOffset.x < size.width) {
yield(Offset(size.width, size.height))
yield(bottomCurlOffset)
} else {
yieldEndSideInterception()
}
}.toList()
)
val lineVector = topCurlOffset - bottomCurlOffset
val angle = Math.PI.toFloat() + atan2(-lineVector.y, lineVector.x) * 2
val drawShadow = prepareShadow(config, polygon, angle)
return result@{
withTransform({
scale(-1f, 1f, pivot = bottomCurlOffset)
rotateRad(angle, pivot = bottomCurlOffset)
}) {
this@result.drawShadow()
clipPath(polygon.toPath()) {
this@result.drawContent()
val overlayAlpha = 1f - config.backPage.contentAlpha
drawRect(config.backPage.color.copy(alpha = overlayAlpha))
}
}
}
}
@ExperimentalPageCurlApi
private fun CacheDrawScope.prepareShadow(
config: CurlConfig,
polygon: Polygon,
angle: Float
): ContentDrawScope.() -> Unit {
val shadow = config.shadow
if (shadow.alpha == 0f || shadow.radius == 0.dp) {
return { /* No shadow is requested */ }
}
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)
val paint = Paint().apply {
val frameworkPaint = asFrameworkPaint()
frameworkPaint.color = transparent
frameworkPaint.setShadowLayer(
shadow.radius.toPx(),
shadowOffset.x,
shadowOffset.y,
shadowColor
)
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
prepareShadowApi28(radius, paint, polygon)
} else {
prepareShadowImage(radius, paint, polygon)
}
}
private fun prepareShadowApi28(
radius: Float,
paint: Paint,
polygon: Polygon,
): ContentDrawScope.() -> Unit = {
drawIntoCanvas {
it.nativeCanvas.drawPath(
polygon
.offset(radius).toPath()
.asAndroidPath(),
paint.asFrameworkPaint()
)
}
}
private fun CacheDrawScope.prepareShadowImage(
radius: Float,
paint: Paint,
polygon: Polygon,
): ContentDrawScope.() -> Unit {
val bitmap = Bitmap.createBitmap(
(size.width + radius * 4).toInt(),
(size.height + radius * 4).toInt(),
Bitmap.Config.ARGB_8888
)
Canvas(bitmap).apply {
drawPath(
polygon
.translate(Offset(2 * radius, 2 * radius))
.offset(radius).toPath()
.asAndroidPath(),
paint.asFrameworkPaint()
)
}
return {
drawIntoCanvas {
it.nativeCanvas.drawBitmap(bitmap, -2 * radius, -2 * radius, null)
}
}
}

View File

@@ -0,0 +1,82 @@
package eu.wewox.pagecurl.page
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.calculateTargetValue
import androidx.compose.animation.splineBasedDecay
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
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.utils.rotate
import kotlin.math.PI
@ExperimentalPageCurlApi
public fun Modifier.curlGesture(
enabled: Boolean,
direction: CurlDirection,
onStart: () -> Unit,
onCurl: (Offset, Offset) -> Unit,
onEnd: () -> Unit,
onCancel: () -> Unit,
): Modifier = pointerInput(enabled) {
if (!enabled) {
return@pointerInput
}
val velocityTracker = VelocityTracker()
val startRect by lazy { direction.start.multiply(size) }
val endRect by lazy { direction.end.multiply(size) }
forEachGesture {
awaitPointerEventScope {
val down = awaitFirstDown(requireUnconsumed = false)
if (!startRect.contains(down.position)) {
return@awaitPointerEventScope
}
val dragStart = down.position.copy(x = size.width.toFloat())
onStart()
var dragCurrent = dragStart
drag(down.id) { change ->
dragCurrent = change.position
velocityTracker.addPosition(System.currentTimeMillis(), dragCurrent)
change.consume()
val vector = (dragStart - dragCurrent).rotate(PI.toFloat() / 2)
onCurl(dragCurrent - vector, dragCurrent + vector)
}
val velocity = velocityTracker.calculateVelocity()
val decay = splineBasedDecay<Offset>(this)
val target = decay.calculateTargetValue(
Offset.VectorConverter,
dragCurrent,
Offset(velocity.x, velocity.y)
).let {
Offset(
it.x.coerceIn(0f, size.width.toFloat() - 1),
it.y.coerceIn(0f, size.height.toFloat() - 1)
)
}
if (endRect.contains(target)) {
onEnd()
} else {
onCancel()
}
}
}
}
private fun Rect.multiply(size: IntSize): Rect =
Rect(
topLeft = Offset(size.width * left, size.height * top),
bottomRight = Offset(size.width * right, size.height * bottom),
)

View File

@@ -0,0 +1,136 @@
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.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
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
public fun PageCurl(
current: Int,
count: Int,
modifier: Modifier = Modifier,
onCurrentChange: (Int) -> Unit,
config: PageCurlConfig = PageCurlConfig(),
content: @Composable (Int) -> Unit
) {
val scope = rememberCoroutineScope()
val updatedCurrent by rememberUpdatedState(current)
BoxWithConstraints(modifier) {
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 = remember { Animatable(right, Curl.VectorConverter, Curl.VisibilityThreshold) }
val backward = remember { Animatable(left, Curl.VectorConverter, Curl.VisibilityThreshold) }
Box(
Modifier
.curlGesture(
enabled = updatedCurrent < count - 1,
scope = scope,
direction = config.interaction.forward,
start = right,
end = left,
animatable = forward,
onChange = { onCurrentChange(updatedCurrent + 1) }
)
.curlGesture(
enabled = updatedCurrent > 0,
scope = scope,
direction = config.interaction.backward,
start = left,
end = right,
animatable = backward,
onChange = { onCurrentChange(updatedCurrent - 1) }
)
) {
if (updatedCurrent + 1 < count) {
content(updatedCurrent + 1)
}
if (updatedCurrent < count) {
Box(Modifier.drawCurl(config.curl, forward.value.a, forward.value.b)) {
content(updatedCurrent)
}
}
if (updatedCurrent > 0) {
Box(Modifier.drawCurl(config.curl, backward.value.a, backward.value.b)) {
content(updatedCurrent - 1)
}
}
}
}
}
@ExperimentalPageCurlApi
private fun Modifier.curlGesture(
enabled: Boolean,
scope: CoroutineScope,
direction: CurlDirection,
start: Curl,
end: Curl,
animatable: Animatable<Curl, AnimationVector4D>,
onChange: () -> Unit,
): Modifier =
curlGesture(
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)
}
},
)
private 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,71 @@
package eu.wewox.pagecurl.utils
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import kotlin.math.cos
import kotlin.math.sin
internal data class Polygon(val vertices: List<Offset>) {
private val size: Int = vertices.size
fun translate(offset: Offset): Polygon =
Polygon(vertices.map { it + offset })
fun offset(value: Float): Polygon {
val edgeNormals = List(size) {
val edge = vertices[index(it + 1)] - vertices[index(it)]
Offset(edge.y, -edge.x).normalized()
}
val vertexNormals = List(size) {
(edgeNormals[index(it - 1)] + edgeNormals[index(it)]).normalized()
}
return Polygon(
vertices.mapIndexed { index, vertex ->
vertex + vertexNormals[index] * value
}
)
}
fun toPath(): Path =
Path().apply {
vertices.forEachIndexed { index, vertex ->
if (index == 0) {
moveTo(vertex.x, vertex.y)
} else {
lineTo(vertex.x, vertex.y)
}
}
}
private fun index(i: Int) = ((i % size) + size) % size
}
private fun Offset.normalized(): Offset {
val distance = getDistance()
return if (distance != 0f) this / distance else this
}
internal fun Offset.rotate(angle: Float): Offset {
val sin = sin(angle)
val cos = cos(angle)
return Offset(x * cos - y * sin, x * sin + y * cos)
}
internal fun lineLineIntersection(
line1a: Offset,
line1b: Offset,
line2a: Offset,
line2b: Offset,
): Offset? {
val denominator = (line1a.x - line1b.x) * (line2a.y - line2b.y) - (line1a.y - line1b.y) * (line2a.x - line2b.x)
if (denominator == 0f) return null
val x = ((line1a.x * line1b.y - line1a.y * line1b.x) * (line2a.x - line2b.x) -
(line1a.x - line1b.x) * (line2a.x * line2b.y - line2a.y * line2b.x)) / denominator
val y = ((line1a.x * line1b.y - line1a.y * line1b.x) * (line2a.y - line2b.y) -
(line1a.y - line1b.y) * (line2a.x * line2b.y - line2a.y * line2b.x)) / denominator
return Offset(x, y)
}