魔棒是画板工具一个重要的功能,非常实用,只要轻轻一点,就能把触摸到的颜色区域选中,做复制、剪切、擦除等工作。
那怎么实现呢?
先来看看效果:
要实现这个效果,需要对安卓canvas和paint理解比较深才行。
原理:
1、获取画板上用户触摸点的颜色, bitmap.getPixel;
2、根据目标色对画布进行检索,符合容差范围内的像素纳入到选区内。上下左右4个方向检索,检索到连续的Point汇集成Rect,把Rect合并成Region;
3、对Region取boundaryPath,获取到选区是个Path对象
4、对Path对象描述的范围做虚线框选中显示,同时得到Rect作为选中的位置锚定。
5、把Path跟画布结合生成出剪切、复制的图像进行后续操作。
关键实现:
整个实现都在一个单独的View中操作,即在原来的画布View上添加一层半透明View。即CutView。代码太长,这里给出关键代码:
private fun startDashAnimate() {
dashAnimate.setIntValues(dashMin, dashMax)
dashAnimate.duration = 4000
dashAnimate.addUpdateListener {
val dash = it.animatedValue as Int
dashPaint.pathEffect = DashPathEffect(floatArrayOf(20f, 20f), dash.toFloat())
invalidate()
}
dashAnimate.repeatCount = ValueAnimator.INFINITE
dashAnimate.start()
}
private fun pauseAnim() {
dashAnimate.pause()
}
private fun resumeAnim() {
dashAnimate.resume()
}
private fun findRegionPath(event: MotionEvent) {
actionShowLoading?.invoke()
GlobalScope.launch(Dispatchers.IO) {
pvsEditView?.let {
it.saveToPhoto(true)?.let {bitmap ->
filterRegionUtils.findColorRegion(event.x.toInt(), event.y.toInt(), bitmap) {path, r ->
addPath(path, r)
GlobalScope.launch(Dispatchers.Main) {
invalidate()
actionHideLoading?.invoke()
}
}
}
}
}
}
这里其他的都是选区动画与绘制。主要看魔棒的入口方法:findRegionPath
findRegionPath由于耗时较长,使用了协程进行计算。
把真正的findColorRegion查找色块放到了工具类filterRegionUtils
这是核心,它返回找到的Path和Rect
整个色块查找类:
class FilterRegionUtils {
data class Point(val x: Int, val y: Int)
data class Segment(val point: Point, val rect: Rect)
private val segmentStack = Stack<Segment>()
private val tolerance = 70
private var rectF = RectF()
private val markedPointMap = HashMap<Int, Boolean>()
private val visitedSeedMap = HashMap<Int, Boolean>()
private var width: Int = 0
private var height: Int = 0
private var pointColor: Int = 0
private lateinit var pixels: IntArray
private val segmentList = arrayListOf<Segment>()
fun findColorRegion(x: Int, y: Int, bitmap: Bitmap, action: ((Path, RectF) -> Unit)) {
markedPointMap.clear()
segmentStack.clear()
visitedSeedMap.clear()
width = bitmap.width
height = bitmap.height
if (x < 0 || x >= width || y < 0 || y >= height) {
return
}
val region = Region()
val path = Path()
path.moveTo(x.toFloat(), y.toFloat())
rectF = RectF(x.toFloat(), y.toFloat(), x.toFloat(), y.toFloat())
// 拿到该bitmap的颜色数组
pixels = IntArray(width * height)
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
pointColor = bitmap.getPixel(x, y)
val point = Point(x, y)
searchLineAtPoint(point)
var index = 1
while (segmentStack.isNotEmpty()) {
val segment = segmentStack.pop()
processSegment(segment)
region.union(segment.rect)
rectF.left = min(rectF.left, segment.rect.left.toFloat())
rectF.top = min(rectF.top, segment.point.y.toFloat())
rectF.right = max(rectF.right, segment.rect.right.toFloat())
rectF.bottom = max(rectF.bottom, segment.point.y.toFloat())
index++
}
val tempPath = region.boundaryPath
path.addPath(tempPath)
action.invoke(path, rectF)
}
private fun processSegment(segment: Segment) {
val left = segment.rect.left
val right = segment.rect.right
val y = segment.point.y
for (x in left .. right) {
val top = y-1
searchLineAtPoint(Point(x, top))
val bottom = y+1
searchLineAtPoint(Point(x, bottom))
}
}
private fun searchLineAtPoint(point: Point) {
if (point.x < 0 || point.x >= width || point.y < 0 || point.y >= height) return
if (visitedSeedMap[point.y * width + point.x] != null) {
return
}
if (!markPointIfMatches(point)) return
// search left
var left = point.x;
var x = point.x - 1;
while (x >= 0) {
val lPoint = Point(x, point.y)
if (markPointIfMatches(lPoint)) {
left = x
} else {
break
}
x--
}
// search right
var right = point.x
x = point.x + 1
while (x < width) {
val rPoint = Point(x, point.y)
if (markPointIfMatches(rPoint)) {
right = x
} else {
break
}
x++
}
val segment = Segment(point, Rect(left, point.y-1, right, point.y+1))
segmentList.add(segment)
segmentStack.push(segment)
}
private fun markPointIfMatches(point: Point): Boolean {
val offset = point.y*width + point.x
val visited = visitedSeedMap[offset]
if (visited != null) return false
var matches = false
if (matchPoint(point)) {
matches = true
markedPointMap[offset] = true
}
visitedSeedMap[offset] = true
return matches
}
private fun matchPoint(point: Point): Boolean {
val index = point.y*width + point.x
val c1 = pixels[index]
val t = max(max(abs(Color.red(c1)-Color.red(pointColor)), abs(Color.green(c1)-Color.green(pointColor))),
abs(Color.blue(c1)-Color.blue(pointColor)))
val alpha = abs(Color.alpha(c1)-Color.alpha((pointColor)))
// 容差值范围内的都视作同一颜色
return t < tolerance && alpha < tolerance
}
}
整个算法流程还是比较简洁高效的。
再看后面,拿到了选区的Path和Rect后,怎么跟画布结合实现复制或剪切。
/**
* 剪切选区
*/
fun cutPath(path: Path, isNormal: Boolean) {
bitmap?.let {
bitmap = Bitmap.createBitmap(it.width, it.height, Bitmap.Config.ARGB_8888)
canvas = Canvas(bitmap!!)
val paint = Paint()
paint.style = Paint.Style.FILL
canvas.drawPath(path, paint)
paint.xfermode = if (isNormal) {
// 取原bitmap的非交集部分
PorterDuffXfermode(PorterDuff.Mode.SRC_OUT)
} else {
// 取原bitmap的交集部分
PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
}
canvas.drawBitmap(it, 0f, 0f, paint)
}
}
这是剪切的方法,很简单,就是利用Paint的xfermode,用isNormal控制是正选还是反选,即取交集还是非交集。
复制选区方法也类似:
fun genAreaBitmap(src: Bitmap, action: ((Bitmap, RectF) -> Unit)){
if (!canOperate()) {
return
}
// 根据裁剪区域生成bitmap
val srcCopy = Bitmap.createBitmap(src.width, src.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(srcCopy)
val rectF = region.bounds
// 避免溢出
rectF.right = min(src.width, rectF.right)
rectF.bottom = min(src.height, rectF.bottom)
val paint = Paint()
var r = rectF
paint.style = Paint.Style.FILL
val op = if (isNormal) {
Region.Op.INTERSECT
} else {
r = Rect(0, 0, width, height)
Region.Op.DIFFERENCE
}
canvas.clipPath(targetPath, op)
canvas.drawBitmap(src, 0f, 0f, paint)
val fBitmap = Bitmap.createBitmap(srcCopy, r.left, r.top,
r.width(), r.height())
action.invoke(fBitmap, RectF(r))
finish()
}
利用Cavnas的clipPath接口,在画布上裁剪出指定区域。