原创文章,转载请联系作者
软草平莎过雨新,轻沙走马路无尘。
何时收拾耦耕身?
先上效果图:
image笔刷项目地址在此,大家要是喜欢的话,不妨来点个赞吧
效果解析
本次Demo的实现效果,参考的是windwos
下的画图应用。首先来看下画图板的效果如何:
-
画点
image
从左至右依次是对同一坐标点击2次,点击8次,点击16次的效果展示;
当数量趋向更大时,点的密集程度并没有很明显的偏向,基本可以确定要在圆内均匀分布
-
画线
image
如图为匀速且缓慢滑过时,由点构成线
具体实现
View
此项目的承载View为PenView
,不承担业务逻辑,就是起到一个容器的作用。在PenView
中唯一的作用就是触发invalidate()
方法。
private BasePen mBasePen;
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (w != 0 && h != 0) {
if (mBasePen == null) {
mBasePen = new SprayPen(w, h);
}
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
MotionEvent event1 = MotionEvent.obtain(event);
mBasePen.onTouchEvent(event1);
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
invalidate();
break;
case MotionEvent.ACTION_UP:
break;
}
return true;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mBasePen.onDraw(canvas);
}
具体的业务逻辑,绘制、数据计算、触摸点移动Move等,全都由BasePen
以及它的子类来实现了。
低耦合性,代表着更多的自由度,对现有项目代码(如果应用到项目中)的冲击更小。在性能方面,如果View
满足不了要求,可以用更小的代价将其移植到性能更好的SurfaceView
里去。
业务逻辑
业务方面,BasePen
作为基类,承担了一些基础的数据计算、绘制等功能,而具体的画笔效果则交由子类实现。
先看看BasePen
里做了什么:
- 绘制
private List<Point> mPoints;
public void onDraw(Canvas canvas) {
if (mPoints != null && !mPoints.isEmpty()) {
canvas.drawBitmap(mBitmap, 0, 0, null);
drawDetail(canvas);
}
}
先将笔刷绘制到一张Bitmap
之上,再将这张Bitmap
交给PenView
来绘制出来。Point
是一个只记录了x和y坐标的类。drawDetail(Canvas canvas)
是一个抽象类,由子类实现具体的绘制。
- 滑动轨迹
在BasePen
的onTouchEvent(MotionEvent event1)
方法里。以每次DOWN
事件为开始,记录MOVE
内的所有坐标信息。考虑到喷漆效果基本不用处理笔锋效果,暂不考虑记录UP
信息(后续如果实现其他笔刷效果会优化这里)。
public void onTouchEvent(MotionEvent event1) {
switch (event1.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
clearPoints();
handlePoints(event1);
break;
case MotionEvent.ACTION_MOVE:
handlePoints(event1);
break;
case MotionEvent.ACTION_UP:
break;
}
}
private void handlePoints(MotionEvent event1) {
float x = event1.getX();
float y = event1.getY();
if (x > 0 && y > 0) {
mPoints.add(new Point(x, y));
}
}
private void clearPoints() {
if (mPoints == null) {
return;
}
mPoints.clear();
}
- 喷漆实现
protected void drawDetail(Canvas canvas) {
if (getPoints().isEmpty()) {
return;
}
mTotalNum = 由自定义粒子密度以及画笔宽度计算而来
drawSpray(当前最新坐标点.x, 当前最新坐标点.y, mTotalNum);
}
private void drawSpray(float x, float y, int totalNum) {
for (int i = 0; i < totalNum; i++) {
//算法计算出圆内随机点
float[] randomPoint = getRandomPoint(x, y, mPenW, true);
mCanvas.drawCircle(randomPoint[0], randomPoint[1], mCricleR, mPaint);
}
}
以上是一部分伪代码,
SprayPen
内部定义了一个喷漆粒子密度,会根据画笔的宽度来实时改变粒子数量。每个粒子的半径则由外部依赖的组件提供的width
计算而来。
在drawDetail(...)
方法内,每一次MOVE
和DOWN
事件都会在相应坐标处,绘制一定数目的圆内随机点。
当其串联起来时,就形成了喷漆效果。当然这只是初步完成,还有一些算法需要完善。伪代码表述不全,可参考SprayPen,在代码中有比较完善的注释。
接下来会说一些有关喷漆算法方面的问题。
喷漆算法的几个问题
在实现功能的过程中,有两个问题是值得记录的。
一是圆内均匀随机点的分布问题;二是滑动速度快时,笔画的连接处理问题。
如何均匀的在圆内生成随机点
为了解决这个问题,主要尝试了三种方法:
x在(-R,R)范围内随机取值,由圆解析式 image 求解得y。然后对y在(-y,y)内随机取值,得到的点即为圆内点。同理,也可由y计算出x。
java代码如下:
float x = mRandom.nextInt(r);
float y = (float) Math.sqrt(Math.pow(r, 2) - Math.pow(x, 2));
y = mRandom.nextInt((int) y);
x = 对值随机取正负(x);
y = 对值随机取正负(y);
最终呈现效果如下:
image
当样本数量达到2000时,形状如上所示
可以很明显的看到,在x轴方向,左右两端的密集程度明显高于圆心
随机值在大量数据下会具有规律性,可以理解为当数据很多时,x的取值在(-r,r)大致为均匀分布的,y的取值亦是。当处于左右两端时,y的取值范围变小,视觉效果就显得紧凑了些。
当然如果用概率论数理统计公式来验证会更有说服力,但可惜不会。。。(耸肩)
随机角度,在[0,360)内随机取得角度,然后在[0,r]范围内随机取值,然后使用sin
和cos
来求解x和y。
java代码如下:
float[] ints = new float[2];
int degree = mRandom.nextInt(360);
double curR = mRandom.nextInt(r)+1;
float x = (float) (curR * Math.cos(Math.toRadians(degree)));
float y = (float) (curR * Math.sin(Math.toRadians(degree)));
x = 对值随机取正负(x);
y = 对值随机取正负(y);
最终呈现效果如下:
image
明显看到中心处的密集程度高于边缘地带,事实上当角度固定时,r在[0,R)范围内随机取值。当数量更大时,坐标点是均匀分布的。
当r越小时,所占用的面积越小,就会显得粒子很密集。
随机角度,在[0,360)内随机取得角度,取[0,1]内的随机平方根再和R相乘,然后使用sin
和cos
来求解x和y。
java代码如下:
int degree = mRandom.nextInt(360);
double curR = Math.sqrt(mRandom.nextDouble()) * r;
float x = (float) (curR * Math.cos(Math.toRadians(degree)));
float y = (float) (curR * Math.sin(Math.toRadians(degree)));
x = 对值随机取正负(x);
y = 对值随机取正负(y);
最终呈现效果如下:
image
这次的视觉效果总算是达到了均匀的效果,这个算法是利用了一个根函数的特性,如下图:
image 红色是根函数,蓝色是线性函数。两者相比下来,根函数的取值会更大些,相应的,接近边缘的点就会更多一点,让粒子的分布效果更加均衡。
处理“奋笔疾书”情况
当以比较慢的速度滑动时,笔画尚显流畅无明显断层。当速度过快时,MOVE
留下的点更少,且间距大。会出现画笔断层现象,这时候就需要一些特殊的处理方法。
代码中设定了一个标准值D
,这个值是由所依附的View
的宽和高计算而来,也可以说是Pen
初始化时拿到的w和h。最初也考虑使用画笔的直径计算,但考虑到画笔直径是可以外部动态改变的。标准值最好保持一定的独立性,其所依赖的数据越稳定越好,要不然会影响平衡
。然后当MOVE
时,当前点距离上一个点的相对距离大于这个标准值D
时,就会判定此时处于快移速状态,间距越大移速越快,那么喷漆效果相应地就要减弱【直观而言就是粒子浓度要低】。
快移速状态时,代码会在当前点和上一个点之间,模拟出一些笔迹点。相应地,这些笔迹点的粒子密集度会低一些,其计算函数且是一个反驼峰的变化状态。即连续笔迹点的中间点粒子最稀疏,两边则最密集。
//手速过快时
float stepDis = mPenR * 1.6f;
//笔迹点的数量
int v = (int) (getLastDis() / stepDis);
float gapX = getPoints().get(getPoints().size() - 1).x - getPoints().get(getPoints().size() - 2).x;
float gapY = getPoints().get(getPoints().size() - 1).y - getPoints().get(getPoints().size() - 2).y;
//描绘笔迹点
for (int i = 1; i <= v; i++) {
float x = (float) (getPoints().get(getPoints().size() - 2).x + (gapX * i * stepDis / getLastDis()));
float y = (float) (getPoints().get(getPoints().size() - 2).y + (gapY * i * stepDis / getLastDis()));
drawSpray(x, y, (int) (mTotalNum * calculate(i, 1, v)), mRandom.nextBoolean());
}
/**
* 使用(x-(min+max)/2)^2/(min-(min+max)/2)^2作为粒子密度比函数
*/
private static float calculate(int index, int min, int max) {
float maxProbability = 0.6f;
float minProbability = 0.15f;
if (max - min + 1 <= 4) {
return maxProbability;
}
int mid = (max + min) / 2;
int maxValue = (int) Math.pow(mid - min, 2);
float ratio = (float) (Math.pow(index - mid, 2) / maxValue);
if (ratio >= maxProbability) {
return maxProbability;
} else if (ratio <= minProbability) {
return minProbability;
} else {
return ratio;
}
}
Kotlin
本项目在写的时候,顺便也写了一个Kotlin
版本的。注意,并不是用AS自带的代码转换的。所以Kotlin
版本会有很多不必要的测试体验代码,不要在意这些细节。
Kotlin版本这里这里,喜欢的不妨点个赞吧
总结
以上就是本次Demo
的思路、以及一些算法的解析。数学之美,令人沉醉(数学学渣留下了悔恨的泪水。。。)
数学才是本体啊
笔刷项目地址在此,代码中的注释会更加清晰些,大家要是喜欢的话,不妨来点个赞吧
原创不易,大家走过路过看的开心,可以适当给个一毛两毛聊表心意
[图片上传失败...(image-fb3a00-1527928690868)]
网友评论