diff --git a/README.md b/README.md index 0506d79..f1bac21 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ This is the first multiplatform drawing library! - Cross-platform! - Customisable stoke size, color and opacity +- Erase tool - Inbuilt Undo and Redo options - Reset option - Background with color/image @@ -14,7 +15,6 @@ This is the first multiplatform drawing library! - Easy Implementations **Next releases:** -- Erase tool - Import/export - Background content scale @@ -40,7 +40,7 @@ DrawBox(drawController = controller, modifier = Modifier.fillMaxSize()) Using Gradle Kotlin DSL: ```kotlin -implementation("io.github.markyav.drawbox:drawbox:1.3.1") +implementation("io.github.markyav.drawbox:drawbox:1.4.0") ``` ## Examples @@ -57,7 +57,7 @@ This project was created by [Mark Yavorskyi](https://www.linkedin.com/in/mark-ya ## History I love my work. The idea of creating this open-source project appeared because I needed a multiplatform (Android + desktop) library for drawing. -I fround several popular libs for Android but there was **ZERO** for using in KMM/KMP. +I found several popular libs for Android but there was **ZERO** for using in KMM/KMP. I still have some aspects to improve and I will be happy if you share your feedback or propose an idea! Hope you enjoy it! \ diff --git a/buildSrc/src/main/kotlin/Library.kt b/buildSrc/src/main/kotlin/Library.kt index c378c9e..af68715 100644 --- a/buildSrc/src/main/kotlin/Library.kt +++ b/buildSrc/src/main/kotlin/Library.kt @@ -5,7 +5,7 @@ object Library { val group = "io.github.markyav.drawbox" val artifact = "drawbox" - val version = "1.3.1" + val version = "1.4.0" object License { val name = "Apache-2.0" diff --git a/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/box/DrawBox.kt b/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/box/DrawBox.kt index 6e28e92..8e77f96 100644 --- a/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/box/DrawBox.kt +++ b/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/box/DrawBox.kt @@ -1,6 +1,7 @@ package io.github.markyav.drawbox.box import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -13,7 +14,8 @@ import kotlinx.coroutines.flow.StateFlow @Composable fun DrawBox( controller: DrawController, - modifier: Modifier = Modifier.fillMaxSize(), + modifier: Modifier = Modifier, + backgroundContent: (@Composable BoxScope.() -> Unit)? = null ) { val path: StateFlow> = remember { controller.getPathWrappersForDrawbox(DrawBoxSubscription.DynamicUpdate) } val openedImage: StateFlow = remember { controller.getOpenImageForDrawbox(null) } @@ -25,15 +27,16 @@ fun DrawBox( background = background, modifier = Modifier.fillMaxSize(), ) + backgroundContent?.invoke(this@Box) DrawBoxCanvas( pathListWrapper = path, openedImage = openedImage, alpha = canvasOpacity, onSizeChanged = controller::connectToDrawBox, onTap = controller::onTap, - onDragStart = controller::insertNewPath, - onDrag = controller::updateLatestPath, - onDragEnd = controller::finalizePath, + onDragStart = controller::onDragStart, + onDrag = controller::onDrag, + onDragEnd = controller::onDragEnd, modifier = Modifier.fillMaxSize(), ) } diff --git a/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/controller/DrawController.kt b/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/controller/DrawController.kt index d48bdf2..85924a5 100644 --- a/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/controller/DrawController.kt +++ b/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/controller/DrawController.kt @@ -1,10 +1,11 @@ package io.github.markyav.drawbox.controller -import androidx.compose.runtime.* import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.* import androidx.compose.ui.unit.IntSize +import io.github.markyav.drawbox.model.CanvasAction import io.github.markyav.drawbox.model.PathWrapper +import io.github.markyav.drawbox.model.CanvasTool import io.github.markyav.drawbox.util.addNotNull import io.github.markyav.drawbox.util.combineStates import io.github.markyav.drawbox.util.createPath @@ -17,13 +18,14 @@ import kotlinx.coroutines.flow.* class DrawController { private var state: MutableStateFlow = MutableStateFlow(DrawBoxConnectionState.Disconnected) - /** A stateful list of [Path] that is drawn on the [Canvas]. */ - private val drawnPaths: MutableStateFlow> = MutableStateFlow(emptyList()) + /** What tool are we using on the [Canvas] at the minute? */ + var canvasTool: MutableStateFlow = MutableStateFlow(CanvasTool.BRUSH) private val activeDrawingPath: MutableStateFlow?> = MutableStateFlow(null) - /** A stateful list of [Path] that was drawn on the [Canvas] but user retracted his action. */ - private val canceledPaths: MutableStateFlow> = MutableStateFlow(emptyList()) + private val actions = MutableStateFlow>(emptyList()) + private val drawnPaths = actions.mapState { getPathsFromActions(it) } + private val undoneActions = MutableStateFlow>(emptyList()) val openedImage: MutableStateFlow = MutableStateFlow(null) @@ -39,45 +41,48 @@ class DrawController { /** A [color] of the stroke */ var color: MutableStateFlow = MutableStateFlow(Color.Red) + /** Whether the controller should register any strokes */ + var enabled: MutableStateFlow = MutableStateFlow(true) + /** A [background] of the background of DrawBox */ var background: MutableStateFlow = MutableStateFlow(DrawBoxBackground.NoBackground) /** Indicate how many redos it is possible to do. */ - val undoCount = drawnPaths.mapState { it.size } + val undoCount = actions.mapState { it.size } /** Indicate how many undos it is possible to do. */ - val redoCount = canceledPaths.mapState { it.size } + val redoCount = undoneActions.mapState { it.size } /** Executes undo the drawn path if possible. */ fun undo() { - if (drawnPaths.value.isNotEmpty()) { - val _drawnPaths = drawnPaths.value.toMutableList() - val _canceledPaths = canceledPaths.value.toMutableList() + if (actions.value.isNotEmpty()) { + val _actions = actions.value.toMutableList() + val _undoneActions = undoneActions.value.toMutableList() - _canceledPaths.add(_drawnPaths.removeLast()) + _undoneActions.add(_actions.removeLast()) - drawnPaths.value = _drawnPaths - canceledPaths.value = _canceledPaths + actions.value = _actions + undoneActions.value = _undoneActions } } /** Executes redo the drawn path if possible. */ fun redo() { - if (canceledPaths.value.isNotEmpty()) { - val _drawnPaths = drawnPaths.value.toMutableList() - val _canceledPaths = canceledPaths.value.toMutableList() + if (undoneActions.value.isNotEmpty()) { + val _actions = actions.value.toMutableList() + val _undoneActions = undoneActions.value.toMutableList() - _drawnPaths.add(_canceledPaths.removeLast()) + _actions.add(_undoneActions.removeLast()) - drawnPaths.value = _drawnPaths - canceledPaths.value = _canceledPaths + actions.value = _actions + undoneActions.value = _undoneActions } } /** Clear drawn paths and the bitmap image. */ fun reset() { - drawnPaths.value = emptyList() - canceledPaths.value = emptyList() + actions.value = emptyList() + undoneActions.value = emptyList() } fun open(image: ImageBitmap) { @@ -85,6 +90,27 @@ class DrawController { openedImage.value = image } + internal fun onDragStart(newPoint: Offset) { + if (!enabled.value) return + + insertNewPath(newPoint) + } + + internal fun onDrag(newPoint: Offset){ + if (!enabled.value) return + + updateLatestPath(newPoint) + } + + internal fun onDragEnd(){ + if (!enabled.value) return + + when (canvasTool.value){ + CanvasTool.BRUSH -> finalizePath() + CanvasTool.ERASER -> finalizeEraserPath() + } + } + /** Call this function when user starts drawing a path. */ internal fun updateLatestPath(newPoint: Offset) { (state.value as? DrawBoxConnectionState.Connected)?.let { @@ -99,21 +125,20 @@ class DrawController { internal fun insertNewPath(newPoint: Offset) { (state.value as? DrawBoxConnectionState.Connected)?.let { require(activeDrawingPath.value == null) - /*val pathWrapper = PathWrapper( - points = mutableStateListOf(newPoint.div(it.size.toFloat())), - strokeColor = color.value, - alpha = opacity.value, - strokeWidth = strokeWidth.value.div(it.size.toFloat()), - )*/ activeDrawingPath.value = listOf(newPoint.div(it.size.toFloat())) - canceledPaths.value = emptyList() + undoneActions.value = emptyList() } } internal fun finalizePath() { (state.value as? DrawBoxConnectionState.Connected)?.let { require(activeDrawingPath.value != null) - val _drawnPaths = drawnPaths.value.toMutableList() + val _actions = actions.value.toMutableList() + + // We need more than one point to draw a proper path, but we can point to the same place twice + if (activeDrawingPath.value!!.size == 1){ + updateLatestPath(activeDrawingPath.value!![0].times(it.size.toFloat())) + } val pathWrapper = PathWrapper( points = activeDrawingPath.value!!, @@ -121,9 +146,35 @@ class DrawController { alpha = opacity.value, strokeWidth = strokeWidth.value.div(it.size.toFloat()), ) - _drawnPaths.add(pathWrapper) + _actions.add(CanvasAction.Draw(pathWrapper)) + + actions.value = _actions + activeDrawingPath.value = null + } + } + + internal fun finalizeEraserPath(){ + // TODO is sometimes unreliable, I think it's looking for each point, but it should check to see if our [p1] - [p2] intersects their [p1] - [p2] + + (state.value as? DrawBoxConnectionState.Connected)?.let { + require(activeDrawingPath.value != null) + + val toRemove = mutableListOf() + val _erasedPath = activeDrawingPath.value!! + + for (pw in actions.value) { + if (pw !is CanvasAction.Draw) continue + + if (pw.path.points.any { p -> _erasedPath.any { e -> e.minus(p).getDistance() < strokeWidth.value.div(it.size.toFloat()) } }) { + toRemove.add(pw.path) + } + } + + if (toRemove.isNotEmpty()) { + actions.value += CanvasAction.Erase(toRemove) + undoneActions.value = emptyList() + } - drawnPaths.value = _drawnPaths activeDrawingPath.value = null } } @@ -140,8 +191,14 @@ class DrawController { } internal fun onTap(newPoint: Offset) { + if (!enabled.value) return + insertNewPath(newPoint) - finalizePath() + + when (canvasTool.value){ + CanvasTool.BRUSH -> finalizePath() + CanvasTool.ERASER -> finalizeEraserPath() + } } private fun List.scale(size: Float): List { @@ -154,6 +211,19 @@ class DrawController { } } + private fun getPathsFromActions(actions: List): List { + val result = mutableListOf() + + actions.forEach { action -> + when(action) { + is CanvasAction.Draw -> result.add(action.path) + is CanvasAction.Erase -> result.removeAll(action.erased.toSet()) + } + } + + return result + } + fun getDrawPath(subscription: DrawBoxSubscription): StateFlow> { return when (subscription) { is DrawBoxSubscription.DynamicUpdate -> getDynamicUpdateDrawnPath() @@ -167,8 +237,8 @@ class DrawController { (state.value as? DrawBoxConnectionState.Connected)?.let { val pathWrapper = PathWrapper( points = activeDrawingPath.value ?: emptyList(), - strokeColor = color.value, - alpha = opacity.value, + strokeColor = if (canvasTool.value == CanvasTool.BRUSH) color.value else Color.Red, + alpha = if (canvasTool.value == CanvasTool.BRUSH) opacity.value else 0.8f, strokeWidth = strokeWidth.value.div(it.size.toFloat()), ) _a.addNotNull(pathWrapper) diff --git a/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/model/CanvasAction.kt b/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/model/CanvasAction.kt new file mode 100644 index 0000000..957f1c6 --- /dev/null +++ b/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/model/CanvasAction.kt @@ -0,0 +1,6 @@ +package io.github.markyav.drawbox.model + +sealed class CanvasAction { + data class Draw(val path: PathWrapper) : CanvasAction() + data class Erase(val erased: List) : CanvasAction() +} \ No newline at end of file diff --git a/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/model/CanvasTool.kt b/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/model/CanvasTool.kt new file mode 100644 index 0000000..561c573 --- /dev/null +++ b/drawbox/src/commonMain/kotlin/io/github/markyav/drawbox/model/CanvasTool.kt @@ -0,0 +1,6 @@ +package io.github.markyav.drawbox.model + +enum class CanvasTool { + BRUSH, + ERASER; +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e411586..6c8855a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Thu Jan 08 17:26:51 GMT 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists