From 2c4dccdda9f283f087ec8496fb98f5e6a355dfa7 Mon Sep 17 00:00:00 2001 From: Naveen Date: Mon, 10 Oct 2022 04:51:20 +0530 Subject: [PATCH] Add color filler tool - Simplified the undo/redo code using `Operation` abstraction - Color fill changes is not saved with SVGs. More research/development is needed on this. - Bitmap operations are not saved during `onSaveInstanceState` because of the obvious memory/space issue. Perhaps using `Path` objects in the flood-fill algorithm can remedy this and the above issue --- .../draw/pro/activities/MainActivity.kt | 17 ++ .../draw/pro/extensions/Bitmap.kt | 15 ++ .../draw/pro/extensions/View.kt | 17 ++ .../draw/pro/helpers/FloodFill.kt | 167 ++++++++++++++ .../draw/pro/models/CanvasOp.kt | 16 ++ .../draw/pro/models/MyParcelable.kt | 29 ++- .../simplemobiletools/draw/pro/models/Svg.kt | 3 +- .../draw/pro/views/MyCanvas.kt | 203 +++++++++++------- .../res/drawable/ic_color_fill_off_vector.xml | 5 + .../res/drawable/ic_color_fill_on_vector.xml | 5 + app/src/main/res/layout/activity_main.xml | 10 +- 11 files changed, 401 insertions(+), 86 deletions(-) create mode 100644 app/src/main/kotlin/com/simplemobiletools/draw/pro/extensions/Bitmap.kt create mode 100644 app/src/main/kotlin/com/simplemobiletools/draw/pro/extensions/View.kt create mode 100644 app/src/main/kotlin/com/simplemobiletools/draw/pro/helpers/FloodFill.kt create mode 100644 app/src/main/kotlin/com/simplemobiletools/draw/pro/models/CanvasOp.kt create mode 100644 app/src/main/res/drawable/ic_color_fill_off_vector.xml create mode 100644 app/src/main/res/drawable/ic_color_fill_on_vector.xml diff --git a/app/src/main/kotlin/com/simplemobiletools/draw/pro/activities/MainActivity.kt b/app/src/main/kotlin/com/simplemobiletools/draw/pro/activities/MainActivity.kt index 12db558..cf5a11c 100644 --- a/app/src/main/kotlin/com/simplemobiletools/draw/pro/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/simplemobiletools/draw/pro/activities/MainActivity.kt @@ -63,6 +63,7 @@ class MainActivity : SimpleActivity(), CanvasListener { private var lastSavePromptTS = 0L private var isEraserOn = false private var isEyeDropperOn = false + private var isColorFillOn = false private var isImageCaptureIntent = false private var isEditIntent = false private var lastBitmapPath = "" @@ -105,6 +106,7 @@ class MainActivity : SimpleActivity(), CanvasListener { true } + color_fill.setOnClickListener { colorFillClicked() } checkIntents() if (!isImageCaptureIntent) { checkWhatsNewDialog() @@ -393,6 +395,20 @@ class MainActivity : SimpleActivity(), CanvasListener { eye_dropper.setImageResource(iconId) } + private fun colorFillClicked() { + isColorFillOn = !isColorFillOn + + val iconId = if (isColorFillOn) { + R.drawable.ic_color_fill_off_vector + } else { + R.drawable.ic_color_fill_on_vector + } + + color_fill.setImageResource(iconId) + + my_canvas.toggleColorFill(isColorFillOn) + } + private fun confirmImage() { when { isEditIntent -> { @@ -575,6 +591,7 @@ class MainActivity : SimpleActivity(), CanvasListener { eraser.applyColorFilter(contrastColor) redo.applyColorFilter(contrastColor) eye_dropper.applyColorFilter(contrastColor) + color_fill.applyColorFilter(contrastColor) if (isBlackAndWhiteTheme()) { stroke_width_bar.setColors(0, contrastColor, 0) } diff --git a/app/src/main/kotlin/com/simplemobiletools/draw/pro/extensions/Bitmap.kt b/app/src/main/kotlin/com/simplemobiletools/draw/pro/extensions/Bitmap.kt new file mode 100644 index 0000000..7a2dc1c --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/draw/pro/extensions/Bitmap.kt @@ -0,0 +1,15 @@ +package com.simplemobiletools.draw.pro.extensions + +import android.graphics.Bitmap +import com.simplemobiletools.draw.pro.helpers.QueueLinearFloodFiller + + +fun Bitmap.floodFill(color: Int, x: Int, y: Int, tolerance: Int = 10): Bitmap { + val floodFiller = QueueLinearFloodFiller(this).apply { + fillColor = color + setTolerance(tolerance) + } + + floodFiller.floodFill(x, y) + return floodFiller.image!! +} diff --git a/app/src/main/kotlin/com/simplemobiletools/draw/pro/extensions/View.kt b/app/src/main/kotlin/com/simplemobiletools/draw/pro/extensions/View.kt new file mode 100644 index 0000000..023f28d --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/draw/pro/extensions/View.kt @@ -0,0 +1,17 @@ +package com.simplemobiletools.draw.pro.extensions + +import android.graphics.Rect +import android.view.View + + +val View.boundingBox: Rect + get() { + val rect = Rect() + getDrawingRect(rect) + val location = IntArray(2) + getLocationOnScreen(location) + rect.offset(location[0], location[1]) + return rect + } + +fun View.contains(x: Int, y: Int) = boundingBox.contains(x, y) diff --git a/app/src/main/kotlin/com/simplemobiletools/draw/pro/helpers/FloodFill.kt b/app/src/main/kotlin/com/simplemobiletools/draw/pro/helpers/FloodFill.kt new file mode 100644 index 0000000..07066fa --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/draw/pro/helpers/FloodFill.kt @@ -0,0 +1,167 @@ +package com.simplemobiletools.draw.pro.helpers + +import android.graphics.Bitmap +import android.graphics.Color +import java.util.* + + +// Original algorithm by J. Dunlap http:// www.codeproject.com/KB/GDI-plus/queuelinearflood-fill.aspx +// Java port by Owen Kaluza +// Android port by Darrin Smith (Standard Android) + +class QueueLinearFloodFiller(img: Bitmap) { + + var image: Bitmap? = null + private set + + var tolerance = intArrayOf(0, 0, 0) + private var width = 0 + private var height = 0 + private var pixels: IntArray? = null + var fillColor = 0 + private val startColor = intArrayOf(0, 0, 0) + private lateinit var pixelsChecked: BooleanArray + private var ranges: Queue? = null + + init { + copyImage(img) + } + + fun setTargetColor(targetColor: Int) { + startColor[0] = Color.red(targetColor) + startColor[1] = Color.green(targetColor) + startColor[2] = Color.blue(targetColor) + } + + fun setTolerance(value: Int) { + tolerance = intArrayOf(value, value, value) + } + + private fun copyImage(img: Bitmap) { + // Copy data from provided Image to a BufferedImage to write flood fill to, use getImage to retrieve + // cache data in member variables to decrease overhead of property calls + width = img.width + height = img.height + image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + image = img.copy(img.config, true) + pixels = IntArray(width * height) + image!!.getPixels(pixels, 0, width, 0, 0, width, height) + } + + private fun prepare() { + // Called before starting flood-fill + pixelsChecked = BooleanArray(pixels!!.size) + ranges = LinkedList() + } + + // Fills the specified point on the bitmap with the currently selected fill color. + // int x, int y: The starting coordinates for the fill + fun floodFill(x: Int, y: Int) { + // Setup + prepare() + if (startColor[0] == 0) { + // ***Get starting color. + val startPixel = pixels!![width * y + x] + startColor[0] = startPixel shr 16 and 0xff + startColor[1] = startPixel shr 8 and 0xff + startColor[2] = startPixel and 0xff + } + + // ***Do first call to flood-fill. + linearFill(x, y) + + // ***Call flood-fill routine while flood-fill ranges still exist on the queue + var range: FloodFillRange + while (ranges!!.size > 0) { + // **Get Next Range Off the Queue + range = ranges!!.remove() + + // **Check Above and Below Each Pixel in the flood-fill Range + var downPxIdx = width * (range.Y + 1) + range.startX + var upPxIdx = width * (range.Y - 1) + range.startX + val upY = range.Y - 1 // so we can pass the y coordinate by ref + val downY = range.Y + 1 + for (i in range.startX..range.endX) { + // *Start Fill Upwards + // if we're not above the top of the bitmap and the pixel above this one is within the color tolerance + if (range.Y > 0 && !pixelsChecked[upPxIdx] && checkPixel(upPxIdx)) { + linearFill(i, upY) + } + + // *Start Fill Downwards + // if we're not below the bottom of the bitmap and the pixel below this one is within the color tolerance + if (range.Y < height - 1 && !pixelsChecked[downPxIdx] && checkPixel(downPxIdx)) { + linearFill(i, downY) + } + downPxIdx++ + upPxIdx++ + } + } + image!!.setPixels(pixels, 0, width, 0, 0, width, height) + } + + // Finds the furthermost left and right boundaries of the fill area + // on a given y coordinate, starting from a given x coordinate, filling as it goes. + // Adds the resulting horizontal range to the queue of flood-fill ranges, + // to be processed in the main loop. + // + // int x, int y: The starting coordinates + private fun linearFill(x: Int, y: Int) { + // ***Find Left Edge of Color Area + var lFillLoc = x // the location to check/fill on the left + var pxIdx = width * y + x + while (true) { + // **fill with the color + pixels!![pxIdx] = fillColor + + // **indicate that this pixel has already been checked and filled + pixelsChecked[pxIdx] = true + + // **de-increment + lFillLoc-- // de-increment counter + pxIdx-- // de-increment pixel index + + // **exit loop if we're at edge of bitmap or color area + if (lFillLoc < 0 || pixelsChecked[pxIdx] || !checkPixel(pxIdx)) { + break + } + } + lFillLoc++ + + // ***Find Right Edge of Color Area + var rFillLoc = x // the location to check/fill on the left + pxIdx = width * y + x + while (true) { + // **fill with the color + pixels!![pxIdx] = fillColor + + // **indicate that this pixel has already been checked and filled + pixelsChecked[pxIdx] = true + + // **increment + rFillLoc++ // increment counter + pxIdx++ // increment pixel index + + // **exit loop if we're at edge of bitmap or color area + if (rFillLoc >= width || pixelsChecked[pxIdx] || !checkPixel(pxIdx)) { + break + } + } + rFillLoc-- + + // add range to queue + val r = FloodFillRange(lFillLoc, rFillLoc, y) + ranges!!.offer(r) + } + + // Sees if a pixel is within the color tolerance range. + private fun checkPixel(px: Int): Boolean { + val red = pixels!![px] ushr 16 and 0xff + val green = pixels!![px] ushr 8 and 0xff + val blue = pixels!![px] and 0xff + return red >= startColor[0] - tolerance[0] && red <= startColor[0] + tolerance[0] && green >= startColor[1] - tolerance[1] && green <= startColor[1] + tolerance[1] && blue >= startColor[2] - tolerance[2] && blue <= startColor[2] + tolerance[2] + } + + // Represents a linear range to be filled and branched from. + private inner class FloodFillRange(var startX: Int, var endX: Int, var Y: Int) +} diff --git a/app/src/main/kotlin/com/simplemobiletools/draw/pro/models/CanvasOp.kt b/app/src/main/kotlin/com/simplemobiletools/draw/pro/models/CanvasOp.kt new file mode 100644 index 0000000..0be7a7a --- /dev/null +++ b/app/src/main/kotlin/com/simplemobiletools/draw/pro/models/CanvasOp.kt @@ -0,0 +1,16 @@ +package com.simplemobiletools.draw.pro.models + +import android.graphics.Bitmap +import java.io.Serializable + + +sealed class CanvasOp : Serializable { + class PathOp( + val path: MyPath, + val paintOptions: PaintOptions + ) : CanvasOp() + + class BitmapOp( + val bitmap: Bitmap + ) : CanvasOp() +} diff --git a/app/src/main/kotlin/com/simplemobiletools/draw/pro/models/MyParcelable.kt b/app/src/main/kotlin/com/simplemobiletools/draw/pro/models/MyParcelable.kt index d378e61..e1a2fc5 100644 --- a/app/src/main/kotlin/com/simplemobiletools/draw/pro/models/MyParcelable.kt +++ b/app/src/main/kotlin/com/simplemobiletools/draw/pro/models/MyParcelable.kt @@ -3,30 +3,37 @@ package com.simplemobiletools.draw.pro.models import android.os.Parcel import android.os.Parcelable import android.view.View -import java.util.* + internal class MyParcelable : View.BaseSavedState { - var paths = LinkedHashMap() + var operations = ArrayList() constructor(superState: Parcelable) : super(superState) constructor(parcel: Parcel) : super(parcel) { val size = parcel.readInt() for (i in 0 until size) { - val key = parcel.readSerializable() as MyPath - val paintOptions = PaintOptions(parcel.readInt(), parcel.readFloat(), parcel.readInt() == 1) - paths[key] = paintOptions + val serializable = parcel.readSerializable() + if (serializable is MyPath) { + val paintOptions = PaintOptions(parcel.readInt(), parcel.readFloat(), parcel.readInt() == 1) + val operation = CanvasOp.PathOp(serializable, paintOptions) + operations.add(operation) + } } } override fun writeToParcel(out: Parcel, flags: Int) { super.writeToParcel(out, flags) - out.writeInt(paths.size) - for ((path, paintOptions) in paths) { - out.writeSerializable(path) - out.writeInt(paintOptions.color) - out.writeFloat(paintOptions.strokeWidth) - out.writeInt(if (paintOptions.isEraser) 1 else 0) + out.writeInt(operations.size) + for (operation in operations) { + if (operation is CanvasOp.PathOp) { + val path = operation.path + val paintOptions = operation.paintOptions + out.writeSerializable(path) + out.writeInt(paintOptions.color) + out.writeFloat(paintOptions.strokeWidth) + out.writeInt(if (paintOptions.isEraser) 1 else 0) + } } } diff --git a/app/src/main/kotlin/com/simplemobiletools/draw/pro/models/Svg.kt b/app/src/main/kotlin/com/simplemobiletools/draw/pro/models/Svg.kt index ede5b89..6ce9735 100644 --- a/app/src/main/kotlin/com/simplemobiletools/draw/pro/models/Svg.kt +++ b/app/src/main/kotlin/com/simplemobiletools/draw/pro/models/Svg.kt @@ -14,7 +14,6 @@ import com.simplemobiletools.draw.pro.activities.MainActivity import com.simplemobiletools.draw.pro.activities.SimpleActivity import com.simplemobiletools.draw.pro.views.MyCanvas import java.io.* -import java.util.* object Svg { fun saveSvg(activity: SimpleActivity, path: String, canvas: MyCanvas) { @@ -27,7 +26,7 @@ object Svg { if (outputStream != null) { val backgroundColor = (canvas.background as ColorDrawable).color val writer = BufferedWriter(OutputStreamWriter(outputStream)) - writeSvg(writer, backgroundColor, canvas.mPaths, canvas.width, canvas.height) + writeSvg(writer, backgroundColor, canvas.getPathsMap(), canvas.width, canvas.height) writer.close() activity.toast(R.string.file_saved) } else { diff --git a/app/src/main/kotlin/com/simplemobiletools/draw/pro/views/MyCanvas.kt b/app/src/main/kotlin/com/simplemobiletools/draw/pro/views/MyCanvas.kt index 7fb1298..328e23b 100644 --- a/app/src/main/kotlin/com/simplemobiletools/draw/pro/views/MyCanvas.kt +++ b/app/src/main/kotlin/com/simplemobiletools/draw/pro/views/MyCanvas.kt @@ -16,7 +16,10 @@ import com.bumptech.glide.request.RequestOptions import com.simplemobiletools.commons.extensions.toast import com.simplemobiletools.commons.helpers.ensureBackgroundThread import com.simplemobiletools.draw.pro.R +import com.simplemobiletools.draw.pro.extensions.contains +import com.simplemobiletools.draw.pro.extensions.floodFill import com.simplemobiletools.draw.pro.interfaces.CanvasListener +import com.simplemobiletools.draw.pro.models.CanvasOp import com.simplemobiletools.draw.pro.models.MyParcelable import com.simplemobiletools.draw.pro.models.MyPath import com.simplemobiletools.draw.pro.models.PaintOptions @@ -27,14 +30,16 @@ import kotlin.math.min class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { private val MIN_ERASER_WIDTH = 20f + private val MAX_HISTORY_COUNT = 1000 + private val BITMAP_MAX_HISTORY_COUNT = 60 private val mScaledTouchSlop = ViewConfiguration.get(context).scaledTouchSlop - var mPaths = LinkedHashMap() + private var mOperations = ArrayList() var mBackgroundBitmap: Bitmap? = null var mListener: CanvasListener? = null - private var mLastPaths = LinkedHashMap() - private var mUndonePaths = LinkedHashMap() + private var mUndoneOperations = ArrayList() + private var mLastOperations = ArrayList() private var mLastBackgroundBitmap: Bitmap? = null private var mPaint = Paint() @@ -54,6 +59,7 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { private var mCurrBrushSize = 0f private var mAllowMovingZooming = true private var mIsEraserOn = false + private var mIsColorFillOn = false private var mWasMultitouch = false private var mIgnoreTouches = false private var mWasScalingInGesture = false @@ -78,13 +84,13 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { } mScaleDetector = ScaleGestureDetector(context, ScaleListener()) - pathsUpdated() + updateUndoVisibility() } public override fun onSaveInstanceState(): Parcelable { val superState = super.onSaveInstanceState() val savedState = MyParcelable(superState!!) - savedState.paths = mPaths + savedState.operations = mOperations return savedState } @@ -95,8 +101,8 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { } super.onRestoreInstanceState(state.superState) - mPaths = state.paths - pathsUpdated() + mOperations = state.operations + updateUndoVisibility() } override fun onTouchEvent(event: MotionEvent): Boolean { @@ -147,8 +153,8 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { mLastTouchX = x mLastTouchY = y actionDown(newValueX, newValueY) - mUndonePaths.clear() - mListener?.toggleRedoVisibility(false) + mUndoneOperations.clear() + updateRedoVisibility(false) } MotionEvent.ACTION_MOVE -> { if (mTouchSloppedBeforeMultitouch) { @@ -156,7 +162,7 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { mTouchSloppedBeforeMultitouch = false } - if (!mAllowMovingZooming || (!mScaleDetector!!.isInProgress && event.pointerCount == 1 && !mWasMultitouch)) { + if (!mIsColorFillOn && (!mAllowMovingZooming || (!mScaleDetector!!.isInProgress && event.pointerCount == 1 && !mWasMultitouch))) { actionMove(newValueX, newValueY) } @@ -207,14 +213,27 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { canvas.scale(mScaleFactor, mScaleFactor, mCenter!!.x, mCenter!!.y) if (mBackgroundBitmap != null) { - val left = (width - mBackgroundBitmap!!.width) / 2 - val top = (height - mBackgroundBitmap!!.height) / 2 - canvas.drawBitmap(mBackgroundBitmap!!, left.toFloat(), top.toFloat(), null) + val bitmap = mBackgroundBitmap!! + val left = (width - bitmap.width) / 2f + val top = (height - bitmap.height) / 2f + canvas.drawBitmap(bitmap, left, top, null) } - for ((key, value) in mPaths) { - changePaint(value) - canvas.drawPath(key, mPaint) + if (mOperations.isNotEmpty()) { + val bitmapOps = mOperations.filterIsInstance() + val bitmapOp = bitmapOps.lastOrNull() + if (bitmapOp != null) { + canvas.drawBitmap(bitmapOp.bitmap, 0f, 0f, null) + } + + // only perform path ops after last bitmap op as any previous path operations are already visible due to the bitmap op + val startIndex = if (bitmapOp != null) mOperations.indexOf(bitmapOp) else 0 + val endIndex = mOperations.lastIndex + val pathOps = mOperations.slice(startIndex..endIndex).filterIsInstance() + for (pathOp in pathOps) { + changePaint(pathOp.paintOptions) + canvas.drawPath(pathOp.path, mPaint) + } } changePaint(mPaintOptions) @@ -223,44 +242,30 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { } fun undo() { - if (mPaths.isEmpty() && mLastPaths.isNotEmpty()) { - mPaths = mLastPaths.clone() as LinkedHashMap + if (mOperations.isEmpty() && mLastOperations.isNotEmpty()) { + mOperations = mLastOperations.clone() as ArrayList mBackgroundBitmap = mLastBackgroundBitmap - mLastPaths.clear() - pathsUpdated() + mLastOperations.clear() + updateUndoVisibility() invalidate() return } - if (mPaths.isEmpty()) { - return + if (mOperations.isNotEmpty()) { + val lastOp = mOperations.removeLast() + mUndoneOperations.add(lastOp) + invalidate() } - - val lastPath = mPaths.values.lastOrNull() - val lastKey = mPaths.keys.lastOrNull() - - mPaths.remove(lastKey) - if (lastPath != null && lastKey != null) { - mUndonePaths[lastKey] = lastPath - mListener?.toggleRedoVisibility(true) - } - pathsUpdated() - invalidate() + updateUndoRedoVisibility() } fun redo() { - if (mUndonePaths.keys.isEmpty()) { - mListener?.toggleRedoVisibility(false) - return + if (mUndoneOperations.isNotEmpty()) { + val undoneOperation = mUndoneOperations.removeLast() + addOperation(undoneOperation) + invalidate() } - - val lastKey = mUndonePaths.keys.last() - addPath(lastKey, mUndonePaths.values.last()) - mUndonePaths.remove(lastKey) - if (mUndonePaths.isEmpty()) { - mListener?.toggleRedoVisibility(false) - } - invalidate() + updateUndoRedoVisibility() } fun toggleEraser(isEraserOn: Boolean) { @@ -269,6 +274,10 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { invalidate() } + fun toggleColorFill(isColorFillOn: Boolean) { + mIsColorFillOn = isColorFillOn + } + fun setColor(newColor: Int) { mPaintOptions.color = newColor } @@ -300,17 +309,10 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { ensureBackgroundThread { val size = Point() activity.windowManager.defaultDisplay.getSize(size) - val options = RequestOptions() - .format(DecodeFormat.PREFER_ARGB_8888) - .disallowHardwareConfig() - .fitCenter() + val options = RequestOptions().format(DecodeFormat.PREFER_ARGB_8888).disallowHardwareConfig().fitCenter() try { - val builder = Glide.with(context) - .asBitmap() - .load(path) - .apply(options) - .submit(size.x, size.y) + val builder = Glide.with(context).asBitmap().load(path).apply(options).submit(size.x, size.y) mBackgroundBitmap = builder.get() activity.runOnUiThread { @@ -324,8 +326,9 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { } fun addPath(path: MyPath, options: PaintOptions) { - mPaths[path] = options - pathsUpdated() + val pathOp = CanvasOp.PathOp(path, options) + mOperations.add(pathOp) + updateUndoVisibility() } private fun changePaint(paintOptions: PaintOptions) { @@ -337,12 +340,12 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { } fun clearCanvas() { - mLastPaths = mPaths.clone() as LinkedHashMap + mLastOperations = mOperations.clone() as ArrayList mLastBackgroundBitmap = mBackgroundBitmap mBackgroundBitmap = null mPath.reset() - mPaths.clear() - pathsUpdated() + mOperations.clear() + updateUndoVisibility() invalidate() } @@ -360,28 +363,84 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) { } private fun actionUp(forceLineDraw: Boolean) { - if (!mWasMultitouch || forceLineDraw) { - mPath.lineTo(mCurX, mCurY) - - // draw a dot on click - if (mStartX == mCurX && mStartY == mCurY) { - mPath.lineTo(mCurX, mCurY + 2) - mPath.lineTo(mCurX + 1, mCurY + 2) - mPath.lineTo(mCurX + 1, mCurY) - } - mPaths[mPath] = mPaintOptions + if (mIsColorFillOn) { + colorFill() + } else if (!mWasMultitouch || forceLineDraw) { + drawADot() } - pathsUpdated() + updateUndoVisibility() mPath = MyPath() mPaintOptions = PaintOptions(mPaintOptions.color, mPaintOptions.strokeWidth, mPaintOptions.isEraser) } - private fun pathsUpdated() { - mListener?.toggleUndoVisibility(mPaths.isNotEmpty() || mLastPaths.isNotEmpty()) + private fun updateUndoRedoVisibility() { + updateUndoVisibility() + updateRedoVisibility() } - fun getDrawingHashCode() = mPaths.hashCode().toLong() + (mBackgroundBitmap?.hashCode()?.toLong() ?: 0L) + private fun updateUndoVisibility() { + mListener?.toggleUndoVisibility(mOperations.isNotEmpty() || mLastOperations.isNotEmpty()) + } + + private fun updateRedoVisibility(visible: Boolean = mUndoneOperations.isNotEmpty()) { + mListener?.toggleRedoVisibility(visible) + } + + private fun colorFill() { + val touchedX = mCurX.toInt() + val touchedY = mCurY.toInt() + if (contains(touchedX, touchedY)) { + val bitmap = getBitmap() + val color = mPaintOptions.color + val img = bitmap.floodFill(color = color, x = touchedX, y = touchedY) + addOperation(CanvasOp.BitmapOp(img)) + invalidate() + } + } + + private fun drawADot() { + mPath.lineTo(mCurX, mCurY) + + // draw a dot on click + if (mStartX == mCurX && mStartY == mCurY) { + mPath.lineTo(mCurX, mCurY + 2) + mPath.lineTo(mCurX + 1, mCurY + 2) + mPath.lineTo(mCurX + 1, mCurY) + } + mOperations.add(CanvasOp.PathOp(mPath, mPaintOptions)) + } + + private fun addOperation(operation: CanvasOp) { + mOperations.add(operation) + + // maybe free up some memory + while (mOperations.size > MAX_HISTORY_COUNT) { + val item = mOperations.removeFirst() + if (item is CanvasOp.BitmapOp) { + item.bitmap.recycle() + } + } + + val ops = mOperations.filterIsInstance() + if (ops.size > BITMAP_MAX_HISTORY_COUNT) { + val start = ops.lastIndex - BITMAP_MAX_HISTORY_COUNT + val bitmapOp = ops.slice(start..ops.lastIndex).first() + + val startIndex = mOperations.indexOf(bitmapOp) + mOperations = mOperations.slice(startIndex..mOperations.lastIndex) as ArrayList + } + } + + fun getPathsMap(): Map { + val pathOps = mOperations + .filterIsInstance() + .map { it.path to it.paintOptions } + .toTypedArray() + return mapOf(*pathOps) + } + + fun getDrawingHashCode() = mOperations.hashCode().toLong() + (mBackgroundBitmap?.hashCode()?.toLong() ?: 0L) private fun MotionEvent?.isTouchSlop(pointerIndex: Int, startX: Float, startY: Float): Boolean { return if (this == null || actionMasked != MotionEvent.ACTION_MOVE) { diff --git a/app/src/main/res/drawable/ic_color_fill_off_vector.xml b/app/src/main/res/drawable/ic_color_fill_off_vector.xml new file mode 100644 index 0000000..cef0502 --- /dev/null +++ b/app/src/main/res/drawable/ic_color_fill_off_vector.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_color_fill_on_vector.xml b/app/src/main/res/drawable/ic_color_fill_on_vector.xml new file mode 100644 index 0000000..5c466e4 --- /dev/null +++ b/app/src/main/res/drawable/ic_color_fill_on_vector.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 39b1faa..9856b09 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -44,11 +44,19 @@ android:id="@+id/redo" android:layout_width="@dimen/normal_icon_size" android:layout_height="@dimen/normal_icon_size" - android:layout_toStartOf="@+id/eraser" + android:layout_toStartOf="@+id/color_fill" android:padding="@dimen/medium_margin" android:src="@drawable/ic_redo_vector" android:visibility="gone" /> + +