之前写的一个带笔画记录功能的安卓画板,最近才有时间写个博客好好介绍一下,参考了一些博客,最后使用了 kotlin 实现的,虽然用起来很爽,可是过了一段时间再看自己都有点懵,还好当时留下的注释非常多,有助于理解,下面是 github 源码,欢迎 star 和收藏!
https://github.com/silencefly96/drawdemo
效果图
实现思路
这里是一个带笔画记录功能的画板,我思考了一下大概需要有前进、后退、清除及导出功能,还是先写了一个接口,感觉有助于编写功能:
interface IDrawableView {
fun back()
fun forward()
fun clear()
fun bitmap() : Bitmap
@Throws(IOException::class)
fun output(path: String)
}
其中 bitmap 方法是获得自定义视图的 bitmap,output 会向指定文件名导出 png 图片,都算导出吧。
初始化
private fun init(context: Context) {
mContext = context
//设置抗锯齿
mPaint.isAntiAlias = true
//设置签名笔画样式
mPaint.style = Paint.Style.STROKE
//设置笔画宽度
mPaint.strokeWidth = mPaintWidth
//设置签名颜色
mPaint.color = mPaintColor
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
//创建画板bitmap
mBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
//画板
mCanvas = Canvas(mBitmap)
//背景
mCanvas.drawColor(mBackgroundColor)
}
这里 init 函数在构造函数里设置画笔信息,onSizeChanged 方法里会创建默认颜色的画布。
手指事件
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
mStartX = event.x
mStartY = event.y
//画笔落笔起点
mCurrentPath.moveTo(mStartX, mStartY)
}
MotionEvent.ACTION_MOVE -> {
val previousX = mStartX
val previousY = mStartY
val dx = abs(event.x - previousX)
val dy = abs(event.y - previousY)
// 两点之间的距离大于等于3时,生成贝塞尔绘制曲线
if (dx >= 3 || dy >= 3) {
// 设置贝塞尔曲线的操作点为起点和终点的一半
val cX = (event.x + previousX) / 2
val cY = (event.y + previousY) / 2
// 二阶贝塞尔,实现平滑曲线;previousX, previousY为操作点,cX, cY为终点
mCurrentPath.quadTo(previousX, previousY, cX, cY)
// 第二次执行时,第一次结束调用的坐标值将作为第二次调用的初始坐标值
mStartX = event.x
mStartY = event.y
}
}
MotionEvent.ACTION_UP -> {
//对当前笔画后的路径出栈
var tmp = index + 1
while (tmp < pathList.size) {
pathList.removeAt(tmp)
tmp++
}
//添加到历史笔画
pathList.add(Path(mCurrentPath))
index++
//将路径画到bitmap中,即一次笔画完成才去更新bitmap,而手势轨迹是实时显示在画板上的。
mCanvas.drawPath(mCurrentPath, mPaint)
mCurrentPath.reset()
}
}
// 更新绘制
invalidate()
return true
}
这里有三种事件,按下、移动和松开,按下的时候会记录当前路径的起始点,并将当前路径移到起始位置。
移动的时候大致就是将各个点连起来喽,不过这里判断了下距离再做贝塞尔函数连接,特别注意下这里将上一个点作为控制点,而将本次点与上一点的中点作为终点,这个地方是笔画能够流畅的原因,这样做会使笔画具有一定预测方向的能力。
结束的时候会记录本次的路径并绘制出来,添加到记录的数组里面,这里如果是在按下回退之后的路径,还需要先将后退的路径记录清除掉再添加本次路径,最后别忘了重置 mCurrentPath,重置前需要将本次完整的路径画出。
更新路径
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
//画此次笔画之前的笔画
canvas.drawBitmap(mBitmap, 0f, 0f, mPaint)
//更新move过程中的笔画
mCanvas.drawPath(mCurrentPath, mPaint)
}
更新的时候实际是在上一次的 bitmap 的基础上,绘制本次路径,两者叠加就是全部图形。
前进后退
//路径
private val mCurrentPath: Path = Path()
//历史路径
private val pathList = LinkedList<Path>()
//当前操作位置
private var index = -1
先熟悉下我们前进后退需要用到的几个全局变量,然后先将后退,再说前进。
后退
public override fun back() {
if (index < 0) {
Toast.makeText(mContext, "当前无旧操作可回退!", Toast.LENGTH_SHORT).show()
return
}
//清空画布
mCanvas.drawColor(mBackgroundColor, PorterDuff.Mode.CLEAR)
mCanvas.drawColor(mBackgroundColor)
//逐步添加路径,并绘制
index--
var tmp = 0
while (tmp <= index) {
mCanvas.drawPath(pathList[tmp], mPaint)
tmp++
}
invalidate()
}
这里就是根据当前位置的 index,清空画布后,再重绘到 index 前一个路径记录,同时 index 减一。这里性能可能很差劲,但是能用,如果读者有什么好办法可以在评论中指出!
前进
public override fun forward() {
if (index >= pathList.size - 1) {
Toast.makeText(mContext, "当前无旧操作可前进!", Toast.LENGTH_SHORT).show()
return
}
//只需要画下一笔
mCanvas.drawPath(pathList[++index], mPaint)
invalidate()
}
前进比起后退更简单了,如果有下一笔,画出来就可以了。
清除画布
public override fun clear() {
//更新画板信息
mCanvas.drawColor(mBackgroundColor, PorterDuff.Mode.CLEAR)
mCanvas.drawColor(mBackgroundColor)
mPaint.color = mPaintColor
pathList.clear()
index = -1
invalidate()
}
这里使用了 PorterDuff.Mode.CLEAR 来清除后,还需要使用默认颜色再绘制一遍,很鸡肋,这里还要重置一下各个变量。
导出图片
@Throws(IOException::class)
override fun output(path: String) {
//配置是否去除边缘
val bitmap = when(isClearBlank) {
true -> clearBlank(mBitmap)
false -> mBitmap
}
val bos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, bos)
val buffer: ByteArray = bos.toByteArray()
val file = File(path)
if (file.exists()) {
file.delete()
}
val outputStream: OutputStream = FileOutputStream(file)
outputStream.write(buffer)
outputStream.close()
}
这里就一个导出功能,用到了 bitmap 压缩成 PNG 的方法,不是很难。这里还有一个去除白边的功能,是看得别人的,想想可能用到,还是留了下来,优化了一下,可能不太好理解。
private fun clearBlank(bitmap: Bitmap): Bitmap {
//扫描各边距不等于背景颜色的第一个点
val top = getDifferentFromArray(0, bitmap.width, bitmap,
0 until bitmap.height)
var bottom = getDifferentFromArray(0, bitmap.width, bitmap,
bitmap.height - 1 downTo 0)
val left = getDifferentFromArray(1, bitmap.height, bitmap,
0 until bitmap.width)
var right = getDifferentFromArray(1, bitmap.height, bitmap,
bitmap.width - 1 downTo 0)
//防止创建null的bitmap 引发的崩溃
if (left == 0 && top == 0 && right == 0 && bottom == 0) {
right = 375
bottom = 375
}
return Bitmap.createBitmap(bitmap, left, top, right - left, bottom - top)
}
主要就是获得四个方向第一次有数据的点的位置,在创建 bitmap,这样出来的图像就等于完美压缩了一般。下面这个方法是对 bitmap 的处理,这里为了能够把四个地方共用,传了一个 array 参数描述处理的方向:
private fun getDifferentFromArray(type: Int, length: Int, bitmap: Bitmap, array: IntProgression): Int {
val pixels = IntArray(length)
for (i in array) {
when(type) {
//https://blog.csdn.net/tanmx219/article/details/81328315
0 -> bitmap.getPixels(pixels, 0, length, 0, i, length, 1) //获得一行
1 -> bitmap.getPixels(pixels, 0, 1, i, 0, 1, length) //获得一列
else -> {}
}
for (j in pixels) {
if (j != mBackgroundColor) {
return i
}
}
}
return 0
}
关于 bitmap 处理的一些知识可以看这篇博客,很有帮助
https://blog.csdn.net/tanmx219/article/details/81328315
完整代码
虽然给出了 GitHub 链接还是贴一下完整代码吧,毕竟 GitHub 也就拿这个用了一下。
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.widget.Toast
import java.io.*
import java.util.*
import kotlin.math.abs
interface IDrawableView {
fun back()
fun forward()
fun clear()
fun bitmap() : Bitmap
@Throws(IOException::class)
fun output(path: String)
}
@Suppress("RedundantVisibilityModifier")
class DrawableView : View, IDrawableView {
private lateinit var mContext: Context
//画笔宽度 px;
public var mPaintWidth = 10f
set(value) {
field = value
mPaint.strokeWidth = value
}
//画笔颜色
public var mPaintColor: Int = Color.BLACK
set(value) {
field = value
mPaint.color = value
}
//背景色
public var mBackgroundColor: Int = Color.TRANSPARENT
set(value) {
field = value
mCanvas.drawColor(value, PorterDuff.Mode.CLEAR)
mCanvas.drawColor(value)
var tmp = 0
while (tmp <= index) {
mCanvas.drawPath(pathList[tmp], mPaint)
tmp++
}
invalidate()
}
//是否清除边缘空白
public var isClearBlank: Boolean = false
//手写画笔
private val mPaint: Paint = Paint()
//起点X
private var mStartX = 0f
//起点Y
private var mStartY = 0f
//路径
private val mCurrentPath: Path = Path()
//历史路径
private val pathList = LinkedList<Path>()
//当前操作位置
private var index = -1
//画布
private lateinit var mCanvas: Canvas
//生成的图片
private lateinit var mBitmap: Bitmap
constructor(context: Context) : super(context) {
init(context)
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
init(context)
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
init(context)
}
private fun init(context: Context) {
mContext = context
//设置抗锯齿
mPaint.isAntiAlias = true
//设置签名笔画样式
mPaint.style = Paint.Style.STROKE
//设置笔画宽度
mPaint.strokeWidth = mPaintWidth
//设置签名颜色
mPaint.color = mPaintColor
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
//创建画板bitmap
mBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
//画板
mCanvas = Canvas(mBitmap)
//背景
mCanvas.drawColor(mBackgroundColor)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
//画此次笔画之前的笔画
canvas.drawBitmap(mBitmap, 0f, 0f, mPaint)
//更新move过程中的笔画
mCanvas.drawPath(mCurrentPath, mPaint)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
mStartX = event.x
mStartY = event.y
//画笔落笔起点
mCurrentPath.moveTo(mStartX, mStartY)
}
MotionEvent.ACTION_MOVE -> {
val previousX = mStartX
val previousY = mStartY
val dx = abs(event.x - previousX)
val dy = abs(event.y - previousY)
// 两点之间的距离大于等于3时,生成贝塞尔绘制曲线
if (dx >= 3 || dy >= 3) {
// 设置贝塞尔曲线的操作点为起点和终点的一半
val cX = (event.x + previousX) / 2
val cY = (event.y + previousY) / 2
// 二阶贝塞尔,实现平滑曲线;previousX, previousY为操作点,cX, cY为终点
mCurrentPath.quadTo(previousX, previousY, cX, cY)
// 第二次执行时,第一次结束调用的坐标值将作为第二次调用的初始坐标值
mStartX = event.x
mStartY = event.y
}
}
MotionEvent.ACTION_UP -> {
//对当前笔画后的路径出栈
var tmp = index + 1
while (tmp < pathList.size) {
pathList.removeAt(tmp)
tmp++
}
//添加到历史笔画
pathList.add(Path(mCurrentPath))
index++
//将路径画到bitmap中,即一次笔画完成才去更新bitmap,而手势轨迹是实时显示在画板上的。
mCanvas.drawPath(mCurrentPath, mPaint)
mCurrentPath.reset()
}
}
// 更新绘制
invalidate()
return true
}
public override fun back() {
if (index < 0) {
Toast.makeText(mContext, "当前无旧操作可回退!", Toast.LENGTH_SHORT).show()
return
}
//清空画布
mCanvas.drawColor(mBackgroundColor, PorterDuff.Mode.CLEAR)
mCanvas.drawColor(mBackgroundColor)
//逐步添加路径,并绘制
index--
var tmp = 0
while (tmp <= index) {
mCanvas.drawPath(pathList[tmp], mPaint)
tmp++
}
invalidate()
}
public override fun forward() {
if (index >= pathList.size - 1) {
Toast.makeText(mContext, "当前无旧操作可回退!", Toast.LENGTH_SHORT).show()
return
}
//只需要画下一笔
mCanvas.drawPath(pathList[++index], mPaint)
invalidate()
}
/**
* 清除画板
*/
public override fun clear() {
//更新画板信息
mCanvas.drawColor(mBackgroundColor, PorterDuff.Mode.CLEAR)
mCanvas.drawColor(mBackgroundColor)
mPaint.color = mPaintColor
pathList.clear()
index = -1
invalidate()
}
/**
* 保存画板
*
*/
public override fun bitmap(): Bitmap {
return mBitmap
}
/**
* 保存画板
* @param path 保存到路径
*
*/
@Throws(IOException::class)
override fun output(path: String) {
//配置是否去除边缘
val bitmap = when(isClearBlank) {
true -> clearBlank(mBitmap)
false -> mBitmap
}
val bos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, bos)
val buffer: ByteArray = bos.toByteArray()
val file = File(path)
if (file.exists()) {
file.delete()
}
val outputStream: OutputStream = FileOutputStream(file)
outputStream.write(buffer)
outputStream.close()
}
/**
* 逐行扫描 清楚边界空白。
*
* @param bitmap
* @return
*/
private fun clearBlank(bitmap: Bitmap): Bitmap {
//扫描各边距不等于背景颜色的第一个点
val top = getDifferentFromArray(0, bitmap.width, bitmap,
0 until bitmap.height)
var bottom = getDifferentFromArray(0, bitmap.width, bitmap,
bitmap.height - 1 downTo 0)
val left = getDifferentFromArray(1, bitmap.height, bitmap,
0 until bitmap.width)
var right = getDifferentFromArray(1, bitmap.height, bitmap,
bitmap.width - 1 downTo 0)
//防止创建null的bitmap 引发的崩溃
if (left == 0 && top == 0 && right == 0 && bottom == 0) {
right = 375
bottom = 375
}
return Bitmap.createBitmap(bitmap, left, top, right - left, bottom - top)
}
private fun getDifferentFromArray(type: Int, length: Int, bitmap: Bitmap, array: IntProgression): Int {
val pixels = IntArray(length)
for (i in array) {
when(type) {
//https://blog.csdn.net/tanmx219/article/details/81328315
0 -> bitmap.getPixels(pixels, 0, length, 0, i, length, 1) //获得一行
1 -> bitmap.getPixels(pixels, 0, 1, i, 0, 1, length) //获得一列
else -> {}
}
for (j in pixels) {
if (j != mBackgroundColor) {
return i
}
}
}
return 0
}
}
结语
其实还有设置笔画粗细、颜色之类的没说,具体看源码里面的使用,好了,性能虽然不怎么样,可是这带记录笔画功能的安卓画板可是重来没在各个博客上看到过哦!!!
end