mirror of
https://github.com/SimpleMobileTools/Simple-Draw.git
synced 2025-02-19 21:20:48 +01:00
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
This commit is contained in:
parent
797cfabcb9
commit
2c4dccdda9
@ -63,6 +63,7 @@ class MainActivity : SimpleActivity(), CanvasListener {
|
|||||||
private var lastSavePromptTS = 0L
|
private var lastSavePromptTS = 0L
|
||||||
private var isEraserOn = false
|
private var isEraserOn = false
|
||||||
private var isEyeDropperOn = false
|
private var isEyeDropperOn = false
|
||||||
|
private var isColorFillOn = false
|
||||||
private var isImageCaptureIntent = false
|
private var isImageCaptureIntent = false
|
||||||
private var isEditIntent = false
|
private var isEditIntent = false
|
||||||
private var lastBitmapPath = ""
|
private var lastBitmapPath = ""
|
||||||
@ -105,6 +106,7 @@ class MainActivity : SimpleActivity(), CanvasListener {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
color_fill.setOnClickListener { colorFillClicked() }
|
||||||
checkIntents()
|
checkIntents()
|
||||||
if (!isImageCaptureIntent) {
|
if (!isImageCaptureIntent) {
|
||||||
checkWhatsNewDialog()
|
checkWhatsNewDialog()
|
||||||
@ -393,6 +395,20 @@ class MainActivity : SimpleActivity(), CanvasListener {
|
|||||||
eye_dropper.setImageResource(iconId)
|
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() {
|
private fun confirmImage() {
|
||||||
when {
|
when {
|
||||||
isEditIntent -> {
|
isEditIntent -> {
|
||||||
@ -575,6 +591,7 @@ class MainActivity : SimpleActivity(), CanvasListener {
|
|||||||
eraser.applyColorFilter(contrastColor)
|
eraser.applyColorFilter(contrastColor)
|
||||||
redo.applyColorFilter(contrastColor)
|
redo.applyColorFilter(contrastColor)
|
||||||
eye_dropper.applyColorFilter(contrastColor)
|
eye_dropper.applyColorFilter(contrastColor)
|
||||||
|
color_fill.applyColorFilter(contrastColor)
|
||||||
if (isBlackAndWhiteTheme()) {
|
if (isBlackAndWhiteTheme()) {
|
||||||
stroke_width_bar.setColors(0, contrastColor, 0)
|
stroke_width_bar.setColors(0, contrastColor, 0)
|
||||||
}
|
}
|
||||||
|
@ -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!!
|
||||||
|
}
|
@ -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)
|
@ -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<FloodFillRange>? = 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)
|
||||||
|
}
|
@ -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()
|
||||||
|
}
|
@ -3,30 +3,37 @@ package com.simplemobiletools.draw.pro.models
|
|||||||
import android.os.Parcel
|
import android.os.Parcel
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
internal class MyParcelable : View.BaseSavedState {
|
internal class MyParcelable : View.BaseSavedState {
|
||||||
var paths = LinkedHashMap<MyPath, PaintOptions>()
|
var operations = ArrayList<CanvasOp>()
|
||||||
|
|
||||||
constructor(superState: Parcelable) : super(superState)
|
constructor(superState: Parcelable) : super(superState)
|
||||||
|
|
||||||
constructor(parcel: Parcel) : super(parcel) {
|
constructor(parcel: Parcel) : super(parcel) {
|
||||||
val size = parcel.readInt()
|
val size = parcel.readInt()
|
||||||
for (i in 0 until size) {
|
for (i in 0 until size) {
|
||||||
val key = parcel.readSerializable() as MyPath
|
val serializable = parcel.readSerializable()
|
||||||
val paintOptions = PaintOptions(parcel.readInt(), parcel.readFloat(), parcel.readInt() == 1)
|
if (serializable is MyPath) {
|
||||||
paths[key] = paintOptions
|
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) {
|
override fun writeToParcel(out: Parcel, flags: Int) {
|
||||||
super.writeToParcel(out, flags)
|
super.writeToParcel(out, flags)
|
||||||
out.writeInt(paths.size)
|
out.writeInt(operations.size)
|
||||||
for ((path, paintOptions) in paths) {
|
for (operation in operations) {
|
||||||
out.writeSerializable(path)
|
if (operation is CanvasOp.PathOp) {
|
||||||
out.writeInt(paintOptions.color)
|
val path = operation.path
|
||||||
out.writeFloat(paintOptions.strokeWidth)
|
val paintOptions = operation.paintOptions
|
||||||
out.writeInt(if (paintOptions.isEraser) 1 else 0)
|
out.writeSerializable(path)
|
||||||
|
out.writeInt(paintOptions.color)
|
||||||
|
out.writeFloat(paintOptions.strokeWidth)
|
||||||
|
out.writeInt(if (paintOptions.isEraser) 1 else 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,7 +14,6 @@ import com.simplemobiletools.draw.pro.activities.MainActivity
|
|||||||
import com.simplemobiletools.draw.pro.activities.SimpleActivity
|
import com.simplemobiletools.draw.pro.activities.SimpleActivity
|
||||||
import com.simplemobiletools.draw.pro.views.MyCanvas
|
import com.simplemobiletools.draw.pro.views.MyCanvas
|
||||||
import java.io.*
|
import java.io.*
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
object Svg {
|
object Svg {
|
||||||
fun saveSvg(activity: SimpleActivity, path: String, canvas: MyCanvas) {
|
fun saveSvg(activity: SimpleActivity, path: String, canvas: MyCanvas) {
|
||||||
@ -27,7 +26,7 @@ object Svg {
|
|||||||
if (outputStream != null) {
|
if (outputStream != null) {
|
||||||
val backgroundColor = (canvas.background as ColorDrawable).color
|
val backgroundColor = (canvas.background as ColorDrawable).color
|
||||||
val writer = BufferedWriter(OutputStreamWriter(outputStream))
|
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()
|
writer.close()
|
||||||
activity.toast(R.string.file_saved)
|
activity.toast(R.string.file_saved)
|
||||||
} else {
|
} else {
|
||||||
|
@ -16,7 +16,10 @@ import com.bumptech.glide.request.RequestOptions
|
|||||||
import com.simplemobiletools.commons.extensions.toast
|
import com.simplemobiletools.commons.extensions.toast
|
||||||
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
|
import com.simplemobiletools.commons.helpers.ensureBackgroundThread
|
||||||
import com.simplemobiletools.draw.pro.R
|
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.interfaces.CanvasListener
|
||||||
|
import com.simplemobiletools.draw.pro.models.CanvasOp
|
||||||
import com.simplemobiletools.draw.pro.models.MyParcelable
|
import com.simplemobiletools.draw.pro.models.MyParcelable
|
||||||
import com.simplemobiletools.draw.pro.models.MyPath
|
import com.simplemobiletools.draw.pro.models.MyPath
|
||||||
import com.simplemobiletools.draw.pro.models.PaintOptions
|
import com.simplemobiletools.draw.pro.models.PaintOptions
|
||||||
@ -27,14 +30,16 @@ import kotlin.math.min
|
|||||||
|
|
||||||
class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
||||||
private val MIN_ERASER_WIDTH = 20f
|
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
|
private val mScaledTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
||||||
|
|
||||||
var mPaths = LinkedHashMap<MyPath, PaintOptions>()
|
private var mOperations = ArrayList<CanvasOp>()
|
||||||
var mBackgroundBitmap: Bitmap? = null
|
var mBackgroundBitmap: Bitmap? = null
|
||||||
var mListener: CanvasListener? = null
|
var mListener: CanvasListener? = null
|
||||||
|
|
||||||
private var mLastPaths = LinkedHashMap<MyPath, PaintOptions>()
|
private var mUndoneOperations = ArrayList<CanvasOp>()
|
||||||
private var mUndonePaths = LinkedHashMap<MyPath, PaintOptions>()
|
private var mLastOperations = ArrayList<CanvasOp>()
|
||||||
private var mLastBackgroundBitmap: Bitmap? = null
|
private var mLastBackgroundBitmap: Bitmap? = null
|
||||||
|
|
||||||
private var mPaint = Paint()
|
private var mPaint = Paint()
|
||||||
@ -54,6 +59,7 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
|||||||
private var mCurrBrushSize = 0f
|
private var mCurrBrushSize = 0f
|
||||||
private var mAllowMovingZooming = true
|
private var mAllowMovingZooming = true
|
||||||
private var mIsEraserOn = false
|
private var mIsEraserOn = false
|
||||||
|
private var mIsColorFillOn = false
|
||||||
private var mWasMultitouch = false
|
private var mWasMultitouch = false
|
||||||
private var mIgnoreTouches = false
|
private var mIgnoreTouches = false
|
||||||
private var mWasScalingInGesture = false
|
private var mWasScalingInGesture = false
|
||||||
@ -78,13 +84,13 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mScaleDetector = ScaleGestureDetector(context, ScaleListener())
|
mScaleDetector = ScaleGestureDetector(context, ScaleListener())
|
||||||
pathsUpdated()
|
updateUndoVisibility()
|
||||||
}
|
}
|
||||||
|
|
||||||
public override fun onSaveInstanceState(): Parcelable {
|
public override fun onSaveInstanceState(): Parcelable {
|
||||||
val superState = super.onSaveInstanceState()
|
val superState = super.onSaveInstanceState()
|
||||||
val savedState = MyParcelable(superState!!)
|
val savedState = MyParcelable(superState!!)
|
||||||
savedState.paths = mPaths
|
savedState.operations = mOperations
|
||||||
return savedState
|
return savedState
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,8 +101,8 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
super.onRestoreInstanceState(state.superState)
|
super.onRestoreInstanceState(state.superState)
|
||||||
mPaths = state.paths
|
mOperations = state.operations
|
||||||
pathsUpdated()
|
updateUndoVisibility()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
@ -147,8 +153,8 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
|||||||
mLastTouchX = x
|
mLastTouchX = x
|
||||||
mLastTouchY = y
|
mLastTouchY = y
|
||||||
actionDown(newValueX, newValueY)
|
actionDown(newValueX, newValueY)
|
||||||
mUndonePaths.clear()
|
mUndoneOperations.clear()
|
||||||
mListener?.toggleRedoVisibility(false)
|
updateRedoVisibility(false)
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_MOVE -> {
|
MotionEvent.ACTION_MOVE -> {
|
||||||
if (mTouchSloppedBeforeMultitouch) {
|
if (mTouchSloppedBeforeMultitouch) {
|
||||||
@ -156,7 +162,7 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
|||||||
mTouchSloppedBeforeMultitouch = false
|
mTouchSloppedBeforeMultitouch = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mAllowMovingZooming || (!mScaleDetector!!.isInProgress && event.pointerCount == 1 && !mWasMultitouch)) {
|
if (!mIsColorFillOn && (!mAllowMovingZooming || (!mScaleDetector!!.isInProgress && event.pointerCount == 1 && !mWasMultitouch))) {
|
||||||
actionMove(newValueX, newValueY)
|
actionMove(newValueX, newValueY)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,14 +213,27 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
|||||||
canvas.scale(mScaleFactor, mScaleFactor, mCenter!!.x, mCenter!!.y)
|
canvas.scale(mScaleFactor, mScaleFactor, mCenter!!.x, mCenter!!.y)
|
||||||
|
|
||||||
if (mBackgroundBitmap != null) {
|
if (mBackgroundBitmap != null) {
|
||||||
val left = (width - mBackgroundBitmap!!.width) / 2
|
val bitmap = mBackgroundBitmap!!
|
||||||
val top = (height - mBackgroundBitmap!!.height) / 2
|
val left = (width - bitmap.width) / 2f
|
||||||
canvas.drawBitmap(mBackgroundBitmap!!, left.toFloat(), top.toFloat(), null)
|
val top = (height - bitmap.height) / 2f
|
||||||
|
canvas.drawBitmap(bitmap, left, top, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
for ((key, value) in mPaths) {
|
if (mOperations.isNotEmpty()) {
|
||||||
changePaint(value)
|
val bitmapOps = mOperations.filterIsInstance<CanvasOp.BitmapOp>()
|
||||||
canvas.drawPath(key, mPaint)
|
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<CanvasOp.PathOp>()
|
||||||
|
for (pathOp in pathOps) {
|
||||||
|
changePaint(pathOp.paintOptions)
|
||||||
|
canvas.drawPath(pathOp.path, mPaint)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
changePaint(mPaintOptions)
|
changePaint(mPaintOptions)
|
||||||
@ -223,44 +242,30 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun undo() {
|
fun undo() {
|
||||||
if (mPaths.isEmpty() && mLastPaths.isNotEmpty()) {
|
if (mOperations.isEmpty() && mLastOperations.isNotEmpty()) {
|
||||||
mPaths = mLastPaths.clone() as LinkedHashMap<MyPath, PaintOptions>
|
mOperations = mLastOperations.clone() as ArrayList<CanvasOp>
|
||||||
mBackgroundBitmap = mLastBackgroundBitmap
|
mBackgroundBitmap = mLastBackgroundBitmap
|
||||||
mLastPaths.clear()
|
mLastOperations.clear()
|
||||||
pathsUpdated()
|
updateUndoVisibility()
|
||||||
invalidate()
|
invalidate()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mPaths.isEmpty()) {
|
if (mOperations.isNotEmpty()) {
|
||||||
return
|
val lastOp = mOperations.removeLast()
|
||||||
|
mUndoneOperations.add(lastOp)
|
||||||
|
invalidate()
|
||||||
}
|
}
|
||||||
|
updateUndoRedoVisibility()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun redo() {
|
fun redo() {
|
||||||
if (mUndonePaths.keys.isEmpty()) {
|
if (mUndoneOperations.isNotEmpty()) {
|
||||||
mListener?.toggleRedoVisibility(false)
|
val undoneOperation = mUndoneOperations.removeLast()
|
||||||
return
|
addOperation(undoneOperation)
|
||||||
|
invalidate()
|
||||||
}
|
}
|
||||||
|
updateUndoRedoVisibility()
|
||||||
val lastKey = mUndonePaths.keys.last()
|
|
||||||
addPath(lastKey, mUndonePaths.values.last())
|
|
||||||
mUndonePaths.remove(lastKey)
|
|
||||||
if (mUndonePaths.isEmpty()) {
|
|
||||||
mListener?.toggleRedoVisibility(false)
|
|
||||||
}
|
|
||||||
invalidate()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleEraser(isEraserOn: Boolean) {
|
fun toggleEraser(isEraserOn: Boolean) {
|
||||||
@ -269,6 +274,10 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
|||||||
invalidate()
|
invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun toggleColorFill(isColorFillOn: Boolean) {
|
||||||
|
mIsColorFillOn = isColorFillOn
|
||||||
|
}
|
||||||
|
|
||||||
fun setColor(newColor: Int) {
|
fun setColor(newColor: Int) {
|
||||||
mPaintOptions.color = newColor
|
mPaintOptions.color = newColor
|
||||||
}
|
}
|
||||||
@ -300,17 +309,10 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
|||||||
ensureBackgroundThread {
|
ensureBackgroundThread {
|
||||||
val size = Point()
|
val size = Point()
|
||||||
activity.windowManager.defaultDisplay.getSize(size)
|
activity.windowManager.defaultDisplay.getSize(size)
|
||||||
val options = RequestOptions()
|
val options = RequestOptions().format(DecodeFormat.PREFER_ARGB_8888).disallowHardwareConfig().fitCenter()
|
||||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
|
||||||
.disallowHardwareConfig()
|
|
||||||
.fitCenter()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val builder = Glide.with(context)
|
val builder = Glide.with(context).asBitmap().load(path).apply(options).submit(size.x, size.y)
|
||||||
.asBitmap()
|
|
||||||
.load(path)
|
|
||||||
.apply(options)
|
|
||||||
.submit(size.x, size.y)
|
|
||||||
|
|
||||||
mBackgroundBitmap = builder.get()
|
mBackgroundBitmap = builder.get()
|
||||||
activity.runOnUiThread {
|
activity.runOnUiThread {
|
||||||
@ -324,8 +326,9 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addPath(path: MyPath, options: PaintOptions) {
|
fun addPath(path: MyPath, options: PaintOptions) {
|
||||||
mPaths[path] = options
|
val pathOp = CanvasOp.PathOp(path, options)
|
||||||
pathsUpdated()
|
mOperations.add(pathOp)
|
||||||
|
updateUndoVisibility()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun changePaint(paintOptions: PaintOptions) {
|
private fun changePaint(paintOptions: PaintOptions) {
|
||||||
@ -337,12 +340,12 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clearCanvas() {
|
fun clearCanvas() {
|
||||||
mLastPaths = mPaths.clone() as LinkedHashMap<MyPath, PaintOptions>
|
mLastOperations = mOperations.clone() as ArrayList<CanvasOp>
|
||||||
mLastBackgroundBitmap = mBackgroundBitmap
|
mLastBackgroundBitmap = mBackgroundBitmap
|
||||||
mBackgroundBitmap = null
|
mBackgroundBitmap = null
|
||||||
mPath.reset()
|
mPath.reset()
|
||||||
mPaths.clear()
|
mOperations.clear()
|
||||||
pathsUpdated()
|
updateUndoVisibility()
|
||||||
invalidate()
|
invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -360,28 +363,84 @@ class MyCanvas(context: Context, attrs: AttributeSet) : View(context, attrs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun actionUp(forceLineDraw: Boolean) {
|
private fun actionUp(forceLineDraw: Boolean) {
|
||||||
if (!mWasMultitouch || forceLineDraw) {
|
if (mIsColorFillOn) {
|
||||||
mPath.lineTo(mCurX, mCurY)
|
colorFill()
|
||||||
|
} else if (!mWasMultitouch || forceLineDraw) {
|
||||||
// draw a dot on click
|
drawADot()
|
||||||
if (mStartX == mCurX && mStartY == mCurY) {
|
|
||||||
mPath.lineTo(mCurX, mCurY + 2)
|
|
||||||
mPath.lineTo(mCurX + 1, mCurY + 2)
|
|
||||||
mPath.lineTo(mCurX + 1, mCurY)
|
|
||||||
}
|
|
||||||
mPaths[mPath] = mPaintOptions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pathsUpdated()
|
updateUndoVisibility()
|
||||||
mPath = MyPath()
|
mPath = MyPath()
|
||||||
mPaintOptions = PaintOptions(mPaintOptions.color, mPaintOptions.strokeWidth, mPaintOptions.isEraser)
|
mPaintOptions = PaintOptions(mPaintOptions.color, mPaintOptions.strokeWidth, mPaintOptions.isEraser)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pathsUpdated() {
|
private fun updateUndoRedoVisibility() {
|
||||||
mListener?.toggleUndoVisibility(mPaths.isNotEmpty() || mLastPaths.isNotEmpty())
|
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<CanvasOp.BitmapOp>()
|
||||||
|
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<CanvasOp>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPathsMap(): Map<MyPath, PaintOptions> {
|
||||||
|
val pathOps = mOperations
|
||||||
|
.filterIsInstance<CanvasOp.PathOp>()
|
||||||
|
.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 {
|
private fun MotionEvent?.isTouchSlop(pointerIndex: Int, startX: Float, startY: Float): Boolean {
|
||||||
return if (this == null || actionMasked != MotionEvent.ACTION_MOVE) {
|
return if (this == null || actionMasked != MotionEvent.ACTION_MOVE) {
|
||||||
|
5
app/src/main/res/drawable/ic_color_fill_off_vector.xml
Normal file
5
app/src/main/res/drawable/ic_color_fill_off_vector.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||||
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M18,14c0,-4 -6,-10.8 -6,-10.8s-1.33,1.51 -2.73,3.52l8.59,8.59c0.09,-0.42 0.14,-0.86 0.14,-1.31zM17.12,17.12L12.5,12.5 5.27,5.27 4,6.55l3.32,3.32C6.55,11.32 6,12.79 6,14c0,3.31 2.69,6 6,6 1.52,0 2.9,-0.57 3.96,-1.5l2.63,2.63 1.27,-1.27 -2.74,-2.74z"/>
|
||||||
|
</vector>
|
5
app/src/main/res/drawable/ic_color_fill_on_vector.xml
Normal file
5
app/src/main/res/drawable/ic_color_fill_on_vector.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<vector android:height="24dp" android:viewportHeight="48"
|
||||||
|
android:viewportWidth="48" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M10.561,5.804 L13.074,3.079 33.411,25.136c0.896,0.972 1.344,2.166 1.344,3.581 0,1.416 -0.448,2.609 -1.344,3.581L23.009,43.58c-0.896,0.972 -1.948,1.458 -3.156,1.458 -1.208,0 -2.26,-0.486 -3.156,-1.458L6.295,32.298c-0.896,-0.972 -1.344,-2.166 -1.344,-3.581 0,-1.416 0.448,-2.609 1.344,-3.581L17.341,13.157ZM19.853,15.882 L8.224,28.495L31.483,28.495ZM39.606,46.116c-1.169,0 -2.162,-0.444 -2.98,-1.331 -0.818,-0.887 -1.227,-1.965 -1.227,-3.233 0,-0.718 0.156,-1.5 0.468,-2.345 0.312,-0.845 0.74,-1.711 1.286,-2.599 0.312,-0.549 0.692,-1.141 1.14,-1.775 0.448,-0.634 0.886,-1.225 1.315,-1.775 0.429,0.549 0.867,1.141 1.315,1.775 0.448,0.634 0.828,1.225 1.14,1.775 0.545,0.887 0.974,1.754 1.286,2.599 0.312,0.845 0.468,1.627 0.468,2.345 0,1.268 -0.409,2.345 -1.227,3.233 -0.818,0.887 -1.812,1.331 -2.98,1.331z" android:strokeWidth="1.21722"/>
|
||||||
|
</vector>
|
@ -44,11 +44,19 @@
|
|||||||
android:id="@+id/redo"
|
android:id="@+id/redo"
|
||||||
android:layout_width="@dimen/normal_icon_size"
|
android:layout_width="@dimen/normal_icon_size"
|
||||||
android:layout_height="@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:padding="@dimen/medium_margin"
|
||||||
android:src="@drawable/ic_redo_vector"
|
android:src="@drawable/ic_redo_vector"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/color_fill"
|
||||||
|
android:layout_width="@dimen/normal_icon_size"
|
||||||
|
android:layout_height="@dimen/normal_icon_size"
|
||||||
|
android:layout_toStartOf="@+id/eraser"
|
||||||
|
android:padding="@dimen/medium_margin"
|
||||||
|
android:src="@drawable/ic_color_fill_on_vector" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/eraser"
|
android:id="@+id/eraser"
|
||||||
android:layout_width="@dimen/normal_icon_size"
|
android:layout_width="@dimen/normal_icon_size"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user