Skip to content
Open
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,7 +15,6 @@ This is the first multiplatform drawing library!
- Easy Implementations

**Next releases:**
- Erase tool
- Import/export
- Background content scale

Expand All @@ -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
Expand All @@ -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! \
Expand Down
2 changes: 1 addition & 1 deletion buildSrc/src/main/kotlin/Library.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<List<PathWrapper>> = remember { controller.getPathWrappersForDrawbox(DrawBoxSubscription.DynamicUpdate) }
val openedImage: StateFlow<OpenedImage> = remember { controller.getOpenImageForDrawbox(null) }
Expand All @@ -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(),
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,13 +18,14 @@ import kotlinx.coroutines.flow.*
class DrawController {
private var state: MutableStateFlow<DrawBoxConnectionState> = MutableStateFlow(DrawBoxConnectionState.Disconnected)

/** A stateful list of [Path] that is drawn on the [Canvas]. */
private val drawnPaths: MutableStateFlow<List<PathWrapper>> = MutableStateFlow(emptyList())
/** What tool are we using on the [Canvas] at the minute? */
var canvasTool: MutableStateFlow<CanvasTool> = MutableStateFlow(CanvasTool.BRUSH)

private val activeDrawingPath: MutableStateFlow<List<Offset>?> = MutableStateFlow(null)

/** A stateful list of [Path] that was drawn on the [Canvas] but user retracted his action. */
private val canceledPaths: MutableStateFlow<List<PathWrapper>> = MutableStateFlow(emptyList())
private val actions = MutableStateFlow<List<CanvasAction>>(emptyList())
private val drawnPaths = actions.mapState { getPathsFromActions(it) }
private val undoneActions = MutableStateFlow<List<CanvasAction>>(emptyList())

val openedImage: MutableStateFlow<ImageBitmap?> = MutableStateFlow(null)

Expand All @@ -39,52 +41,76 @@ class DrawController {
/** A [color] of the stroke */
var color: MutableStateFlow<Color> = MutableStateFlow(Color.Red)

/** Whether the controller should register any strokes */
var enabled: MutableStateFlow<Boolean> = MutableStateFlow(true)

/** A [background] of the background of DrawBox */
var background: MutableStateFlow<DrawBoxBackground> = 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) {
reset()
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 {
Expand All @@ -99,31 +125,56 @@ 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!!,
strokeColor = color.value,
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<PathWrapper>()
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
}
}
Expand All @@ -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<PathWrapper>.scale(size: Float): List<PathWrapper> {
Expand All @@ -154,6 +211,19 @@ class DrawController {
}
}

private fun getPathsFromActions(actions: List<CanvasAction>): List<PathWrapper> {
val result = mutableListOf<PathWrapper>()

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<List<PathWrapper>> {
return when (subscription) {
is DrawBoxSubscription.DynamicUpdate -> getDynamicUpdateDrawnPath()
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PathWrapper>) : CanvasAction()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.github.markyav.drawbox.model

enum class CanvasTool {
BRUSH,
ERASER;
}
3 changes: 2 additions & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -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