mirror of
https://github.com/fankes/pagecurl-multiplatform.git
synced 2025-09-07 19:14:04 +08:00
feat: support compose multiplatform
This commit is contained in:
@@ -1,32 +1,65 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin)
|
||||
alias(libs.plugins.mavenpublish)
|
||||
id("convention.jvm.toolchain")
|
||||
autowire(libs.plugins.kotlin.multiplatform)
|
||||
autowire(libs.plugins.android.library)
|
||||
autowire(libs.plugins.jetbrains.compose)
|
||||
}
|
||||
|
||||
group = property.project.groupName
|
||||
|
||||
kotlin {
|
||||
androidTarget()
|
||||
jvm("desktop")
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
jvmToolchain(17)
|
||||
sourceSets {
|
||||
all {
|
||||
languageSettings {
|
||||
optIn("kotlinx.cinterop.ExperimentalForeignApi")
|
||||
}
|
||||
}
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
}
|
||||
}
|
||||
val androidMain by getting
|
||||
val desktopMain by getting {
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
}
|
||||
}
|
||||
val iosX64Main by getting
|
||||
val iosArm64Main by getting
|
||||
val iosSimulatorArm64Main by getting
|
||||
val iosMain by creating {
|
||||
dependsOn(commonMain)
|
||||
iosX64Main.dependsOn(this)
|
||||
iosArm64Main.dependsOn(this)
|
||||
iosSimulatorArm64Main.dependsOn(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "eu.wewox.pagecurl"
|
||||
namespace = property.project.groupName
|
||||
compileSdk = property.project.android.compileSdk
|
||||
|
||||
compileSdk = libs.versions.sdk.compile.get().toInt()
|
||||
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.sdk.min.get().toInt()
|
||||
minSdk = property.project.android.minSdk
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
|
||||
}
|
||||
kotlinOptions {
|
||||
freeCompilerArgs = freeCompilerArgs +
|
||||
"-Xexplicit-api=strict"
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.ui)
|
||||
}
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
2
pagecurl/src/androidMain/AndroidManifest.xml
Normal file
2
pagecurl/src/androidMain/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
@@ -0,0 +1,9 @@
|
||||
package eu.wewox.pagecurl.page
|
||||
|
||||
import android.graphics.BlurMaskFilter
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
|
||||
actual fun Paint.setBlurred(value: Float) {
|
||||
if (value == 0f) return
|
||||
asFrameworkPaint().maskFilter = BlurMaskFilter(value, BlurMaskFilter.Blur.NORMAL)
|
||||
}
|
@@ -0,0 +1,3 @@
|
||||
package eu.wewox.pagecurl.page
|
||||
|
||||
internal actual fun systemCurrentTimeMillis() = System.currentTimeMillis()
|
@@ -48,7 +48,7 @@ public fun rememberPageCurlConfig(
|
||||
backPageColor: Color = Color.White,
|
||||
backPageContentAlpha: Float = 0.1f,
|
||||
shadowColor: Color = Color.Black,
|
||||
shadowAlpha: Float = 0.2f,
|
||||
shadowAlpha: Float = 0.5f,
|
||||
shadowRadius: Dp = 15.dp,
|
||||
shadowOffset: DpOffset = DpOffset((-5).dp, 0.dp),
|
||||
dragForwardEnabled: Boolean = true,
|
@@ -1,23 +1,22 @@
|
||||
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.DrawResult
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.toRect
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.ImageBitmapConfig
|
||||
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
|
||||
@@ -25,8 +24,9 @@ import eu.wewox.pagecurl.config.PageCurlConfig
|
||||
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.PI
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.max
|
||||
|
||||
@ExperimentalPageCurlApi
|
||||
internal fun Modifier.drawCurl(
|
||||
@@ -157,7 +157,7 @@ private fun CacheDrawScope.prepareCurl(
|
||||
// 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 = 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)
|
||||
@@ -197,29 +197,27 @@ private fun CacheDrawScope.prepareShadow(
|
||||
// Prepare shadow parameters
|
||||
val radius = config.shadowRadius.toPx()
|
||||
val shadowColor = config.shadowColor.copy(alpha = config.shadowAlpha).toArgb()
|
||||
val transparent = config.shadowColor.copy(alpha = 0f).toArgb()
|
||||
// TODO shadowOffset to be set here
|
||||
val shadowOffset = Offset(-config.shadowOffset.x.toPx(), config.shadowOffset.y.toPx())
|
||||
.rotate(2 * Math.PI.toFloat() - angle)
|
||||
.rotate(2 * PI.toFloat() - angle)
|
||||
|
||||
// Prepare shadow paint with a shadow layer
|
||||
val paint = Paint().apply {
|
||||
val frameworkPaint = asFrameworkPaint()
|
||||
frameworkPaint.color = transparent
|
||||
frameworkPaint.setShadowLayer(
|
||||
config.shadowRadius.toPx(),
|
||||
shadowOffset.x,
|
||||
shadowOffset.y,
|
||||
shadowColor
|
||||
)
|
||||
color = Color(shadowColor)
|
||||
setBlurred(radius)
|
||||
|
||||
// val frameworkPaint = asFrameworkPaint()
|
||||
// frameworkPaint.color = transparent
|
||||
// frameworkPaint.setShadowLayer(
|
||||
// config.shadowRadius.toPx(),
|
||||
// shadowOffset.x,
|
||||
// shadowOffset.y,
|
||||
// shadowColor
|
||||
// )
|
||||
}
|
||||
|
||||
// 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 {
|
||||
prepareShadowImage(radius, paint, polygon)
|
||||
}
|
||||
return prepareShadowImage(radius, paint, polygon)
|
||||
}
|
||||
|
||||
private fun prepareShadowApi28(
|
||||
@@ -228,12 +226,7 @@ private fun prepareShadowApi28(
|
||||
polygon: Polygon,
|
||||
): ContentDrawScope.() -> Unit = {
|
||||
drawIntoCanvas {
|
||||
it.nativeCanvas.drawPath(
|
||||
polygon
|
||||
.offset(radius).toPath()
|
||||
.asAndroidPath(),
|
||||
paint.asFrameworkPaint()
|
||||
)
|
||||
it.drawPath(polygon.offset(radius).toPath(), paint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,26 +236,26 @@ private fun CacheDrawScope.prepareShadowImage(
|
||||
polygon: Polygon,
|
||||
): ContentDrawScope.() -> Unit {
|
||||
// Increase the size a little bit so that shadow is not clipped
|
||||
val bitmap = Bitmap.createBitmap(
|
||||
val bitmap = ImageBitmap(
|
||||
(size.width + radius * 4).toInt(),
|
||||
(size.height + radius * 4).toInt(),
|
||||
Bitmap.Config.ARGB_8888
|
||||
ImageBitmapConfig.Argb8888
|
||||
)
|
||||
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(),
|
||||
paint.asFrameworkPaint()
|
||||
.offset(radius).toPath(), paint
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
it.drawImage(bitmap, Offset(-2 * radius, -2 * radius), paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal expect fun Paint.setBlurred(value: Float)
|
@@ -5,9 +5,9 @@ import androidx.compose.animation.core.AnimationVector4D
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.calculateTargetValue
|
||||
import androidx.compose.animation.splineBasedDecay
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
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
|
||||
@@ -84,50 +84,50 @@ internal fun Modifier.curlGesture(
|
||||
val velocityTracker = VelocityTracker()
|
||||
val startRect by lazy { targetStart.multiply(size) }
|
||||
val endRect by lazy { targetEnd.multiply(size) }
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
if (!startRect.contains(down.position)) {
|
||||
return@awaitPointerEventScope
|
||||
}
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
if (!startRect.contains(down.position)) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
// Change X position to be always on the right side for more convenient gesture tracking
|
||||
val dragStart = down.position.copy(x = size.width.toFloat())
|
||||
// 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()
|
||||
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)
|
||||
}
|
||||
var dragCurrent = dragStart
|
||||
drag(down.id) { change ->
|
||||
dragCurrent = change.position
|
||||
velocityTracker.addPosition(systemCurrentTimeMillis(), dragCurrent)
|
||||
change.consume()
|
||||
val vector = (dragStart - dragCurrent).rotate(PI.toFloat() / 2)
|
||||
onCurl(dragCurrent - vector, dragCurrent + vector)
|
||||
}
|
||||
|
||||
if (dragCurrent == dragStart) {
|
||||
onCancel()
|
||||
return@awaitPointerEventScope
|
||||
}
|
||||
if (dragCurrent == dragStart) {
|
||||
onCancel()
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
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()
|
||||
}
|
||||
if (endRect.contains(target)) {
|
||||
onEnd()
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal expect fun systemCurrentTimeMillis(): Long
|
@@ -0,0 +1,51 @@
|
||||
package eu.wewox.pagecurl.page
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import eu.wewox.pagecurl.ExperimentalPageCurlApi
|
||||
import eu.wewox.pagecurl.config.PageCurlConfig
|
||||
import eu.wewox.pagecurl.utils.multiply
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ExperimentalPageCurlApi
|
||||
internal fun Modifier.tapGesture(
|
||||
config: PageCurlConfig,
|
||||
scope: CoroutineScope,
|
||||
onTapForward: suspend () -> Unit,
|
||||
onTapBackward: suspend () -> Unit,
|
||||
): Modifier = pointerInput(config) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown().also { it.consume() }
|
||||
val up = waitForUpOrCancellation() ?: return@awaitEachGesture
|
||||
|
||||
if ((down.position - up.position).getDistance() > viewConfiguration.touchSlop) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
if (config.tapCustomEnabled && config.onCustomTap(this, size, up.position)) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
if (config.tapForwardEnabled && config.tapForwardInteraction.target.multiply(size).contains(up.position)) {
|
||||
scope.launch {
|
||||
onTapForward()
|
||||
}
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
if (config.tapBackwardEnabled &&
|
||||
config.tapBackwardInteraction.target
|
||||
.multiply(size)
|
||||
.contains(up.position)
|
||||
) {
|
||||
scope.launch {
|
||||
onTapBackward()
|
||||
}
|
||||
return@awaitEachGesture
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,10 @@
|
||||
package eu.wewox.pagecurl.page
|
||||
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import org.jetbrains.skia.FilterBlurMode
|
||||
import org.jetbrains.skia.MaskFilter
|
||||
|
||||
actual fun Paint.setBlurred(value: Float) {
|
||||
if (value == 0f) return
|
||||
asFrameworkPaint().maskFilter = MaskFilter.makeBlur(FilterBlurMode.NORMAL, value / 2f)
|
||||
}
|
@@ -0,0 +1,3 @@
|
||||
package eu.wewox.pagecurl.page
|
||||
|
||||
internal actual fun systemCurrentTimeMillis() = System.currentTimeMillis()
|
@@ -0,0 +1,10 @@
|
||||
package eu.wewox.pagecurl.page
|
||||
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import org.jetbrains.skia.FilterBlurMode
|
||||
import org.jetbrains.skia.MaskFilter
|
||||
|
||||
actual fun Paint.setBlurred(value: Float) {
|
||||
if (value == 0f) return
|
||||
asFrameworkPaint().maskFilter = MaskFilter.makeBlur(FilterBlurMode.NORMAL, value / 2f)
|
||||
}
|
@@ -0,0 +1,13 @@
|
||||
package eu.wewox.pagecurl.page
|
||||
|
||||
import kotlinx.cinterop.alloc
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.ptr
|
||||
import platform.posix.gettimeofday
|
||||
import platform.posix.timeval
|
||||
|
||||
internal actual fun systemCurrentTimeMillis() = memScoped {
|
||||
val timeVal = alloc<timeval>()
|
||||
gettimeofday(timeVal.ptr, null)
|
||||
(timeVal.tv_sec * 1000) + (timeVal.tv_usec / 1000)
|
||||
}
|
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="eu.wewox.pagecurl" />
|
@@ -1,53 +0,0 @@
|
||||
package eu.wewox.pagecurl.page
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.forEachGesture
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import eu.wewox.pagecurl.ExperimentalPageCurlApi
|
||||
import eu.wewox.pagecurl.config.PageCurlConfig
|
||||
import eu.wewox.pagecurl.utils.multiply
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ExperimentalPageCurlApi
|
||||
internal fun Modifier.tapGesture(
|
||||
config: PageCurlConfig,
|
||||
scope: CoroutineScope,
|
||||
onTapForward: suspend () -> Unit,
|
||||
onTapBackward: suspend () -> Unit,
|
||||
): Modifier = pointerInput(config) {
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
val down = awaitFirstDown().also { it.consume() }
|
||||
val up = waitForUpOrCancellation() ?: return@awaitPointerEventScope
|
||||
|
||||
if ((down.position - up.position).getDistance() > viewConfiguration.touchSlop) {
|
||||
return@awaitPointerEventScope
|
||||
}
|
||||
|
||||
if (config.tapCustomEnabled && config.onCustomTap(this, size, up.position)) {
|
||||
return@awaitPointerEventScope
|
||||
}
|
||||
|
||||
if (config.tapForwardEnabled && config.tapForwardInteraction.target.multiply(size).contains(up.position)) {
|
||||
scope.launch {
|
||||
onTapForward()
|
||||
}
|
||||
return@awaitPointerEventScope
|
||||
}
|
||||
|
||||
if (config.tapBackwardEnabled &&
|
||||
config.tapBackwardInteraction.target
|
||||
.multiply(size)
|
||||
.contains(up.position)
|
||||
) {
|
||||
scope.launch {
|
||||
onTapBackward()
|
||||
}
|
||||
return@awaitPointerEventScope
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user