Android如何绘制一个跟随手指移动的平滑曲线

72

http://marakana.com/tutorials/android/2d-graphics-example.html

我正在使用下面的示例,但当我在屏幕上快速移动手指时,线条会变成单个点。

我不确定是否可以加速绘制。或者我应该用一条直线连接最后两个点。这两种解决方案中的第二个似乎是一个不错的选择,除非您在移动手指时非常快,则会出现长时间的直线段和锐角曲线。

如果有其他解决方案,很高兴听到他们的意见。

谢谢提前帮助。


如果直线对您的目的来说“不够好”,您可以研究曲线拟合:https://dev59.com/tnNA5IYBdhLWcg3wpvpg - HostileFork says dont trust SE
谢谢,听起来很有用。我之前没有考虑过使用样条曲线,主要是因为我认为它需要更多的资源。另外,这个在 Android 上可用吗? - Somk
2
你有Path.quadToPath.cubicTo... https://dev59.com/IVDTa4cB1Zd3GeqPL8aU - HostileFork says dont trust SE
http://stackoverflow.com/questions/11328848/drawing-a-circle-where-the-user-has-touched-on-canvas/28263820#28263820 - Yogendra
11个回答

116

如你所提及的,一种简单的解决方案是用一条直线连接这些点。以下是实现代码:

public void onDraw(Canvas canvas) {
    Path path = new Path();
    boolean first = true;
    for(Point point : points){
        if(first){
            first = false;
            path.moveTo(point.x, point.y);
        }
        else{
            path.lineTo(point.x, point.y);
        }
    }
    canvas.drawPath(path, paint);
}

确保将画笔从填充(fill)更改为描边(stroke):

paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(2);
paint.setColor(Color.WHITE);

另一种选项是使用quadTo方法通过插值来连接点:

public void onDraw(Canvas canvas) {
    Path path = new Path();
    boolean first = true;
    for(int i = 0; i < points.size(); i += 2){
        Point point = points.get(i);
        if(first){
            first = false;
            path.moveTo(point.x, point.y);
        }

        else if(i < points.size() - 1){
            Point next = points.get(i + 1);
            path.quadTo(point.x, point.y, next.x, next.y);
        }
        else{
            path.lineTo(point.x, point.y);
        }
    }

    canvas.drawPath(path, paint);
}

这仍然会导致一些锐利的边缘。

如果你非常有雄心壮志,可以按照以下方式开始计算立方样条:

public void onDraw(Canvas canvas) {
    Path path = new Path();

    if(points.size() > 1){
        for(int i = points.size() - 2; i < points.size(); i++){
            if(i >= 0){
                Point point = points.get(i);

                if(i == 0){
                    Point next = points.get(i + 1);
                    point.dx = ((next.x - point.x) / 3);
                    point.dy = ((next.y - point.y) / 3);
                }
                else if(i == points.size() - 1){
                    Point prev = points.get(i - 1);
                    point.dx = ((point.x - prev.x) / 3);
                    point.dy = ((point.y - prev.y) / 3);
                }
                else{
                    Point next = points.get(i + 1);
                    Point prev = points.get(i - 1);
                    point.dx = ((next.x - prev.x) / 3);
                    point.dy = ((next.y - prev.y) / 3);
                }
            }
        }
    }

    boolean first = true;
    for(int i = 0; i < points.size(); i++){
        Point point = points.get(i);
        if(first){
            first = false;
            path.moveTo(point.x, point.y);
        }
        else{
            Point prev = points.get(i - 1);
            path.cubicTo(prev.x + prev.dx, prev.y + prev.dy, point.x - point.dx, point.y - point.dy, point.x, point.y);
        }
    }
    canvas.drawPath(path, paint);
}

此外,我发现你需要更改以下内容以避免重复的运动事件:

public boolean onTouch(View view, MotionEvent event) {
    if(event.getAction() != MotionEvent.ACTION_UP){
        Point point = new Point();
        point.x = event.getX();
        point.y = event.getY();
        points.add(point);
        invalidate();
        Log.d(TAG, "point: " + point);
        return true;
    }
    return super.onTouchEvent(event);
}

将dx和dy的值添加到Point类中:

class Point {
    float x, y;
    float dx, dy;

    @Override
    public String toString() {
        return x + ", " + y;
    }
}
这会产生平滑的线条,但有时必须使用循环连接点。此外,在长时间绘图会话中,计算变得计算密集。
编辑
我制作了一个快速项目,演示了这些不同的技术,包括Square建议的签名实现。享受:https://github.com/johncarl81/androiddraw

11
这是一篇有关此主题的有趣文章。似乎安卓会批处理触摸事件:http://corner.squareup.com/2010/07/smooth-signatures.html - John Ericksen
4
第一个loop中的cubicTo()示例似乎是错误的。它应该循环遍历所有点,而不仅仅是最后一个点。 - Eric Obermühlner
4
@johncarl我同意Eric的观点。我认为曲线代码有误 - 第一个循环仅平滑了最后几个点。我建议第一个循环从0开始,而不是从“points.size() - 2”开始。(这是基于我将此代码复制到我的图形应用程序中并进行测试的事实) - Richard Le Mesurier
这是一个很好的答案,但在计算三次样条时应该使用for(int i = 0 ; i < points.size(); i++){ - QuinnBaetz
7
Android已经内置了与此类似但功能更强大的功能,您可以在这里查看[w/c](http://android-coding.blogspot.com/2014/05/composepatheffect-example.html)。只需使用johncarl的第一个示例即可实现平滑边缘线。 - mr5
显示剩余6条评论

35
这可能对您来说已经不重要了,但我为解决它而苦苦挣扎,并希望分享一下,对其他人可能有用。
@johncarl提供的解决方案教程对于绘图非常好,但对我的目的却有限制。如果您将手指从屏幕上拿开并重新放回,则该解决方案将在最后一次单击和新单击之间绘制一条线,使整个图形始终连接。因此,我一直在尝试找到一个解决方案,最终我得到了它!(如果听起来很明显,对不起,我是一个图形初学者)
public class MainActivity extends Activity {
    DrawView drawView;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // Set full screen view
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
                     WindowManager.LayoutParams.FLAG_FULLSCREEN);
        requestWindowFeature(Window.FEATURE_NO_TITLE);

        drawView = new DrawView(this);
        setContentView(drawView);
        drawView.requestFocus();
    }
}


public class DrawingPanel extends View implements OnTouchListener {
    private static final String TAG = "DrawView";

    private static final float MINP = 0.25f;
    private static final float MAXP = 0.75f;

    private Canvas  mCanvas;
    private Path    mPath;
    private Paint       mPaint;   
    private LinkedList<Path> paths = new LinkedList<Path>();

    public DrawingPanel(Context context) {
        super(context);
        setFocusable(true);
        setFocusableInTouchMode(true);

        this.setOnTouchListener(this);

        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setDither(true);
        mPaint.setColor(Color.BLACK);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeJoin(Paint.Join.ROUND);
        mPaint.setStrokeCap(Paint.Cap.ROUND);
        mPaint.setStrokeWidth(6);
        mCanvas = new Canvas();
        mPath = new Path();
        paths.add(mPath);
    }               

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
    }

    @Override
    protected void onDraw(Canvas canvas) {            
        for (Path p : paths){
            canvas.drawPath(p, mPaint);
        }
    }

    private float mX, mY;
    private static final float TOUCH_TOLERANCE = 4;

    private void touch_start(float x, float y) {
        mPath.reset();
        mPath.moveTo(x, y);
        mX = x;
        mY = y;
    }

    private void touch_move(float x, float y) {
        float dx = Math.abs(x - mX);
        float dy = Math.abs(y - mY);
        if (dx >= TOUCH_TOLERANCE || dy >= TOUCH_TOLERANCE) {
            mPath.quadTo(mX, mY, (x + mX)/2, (y + mY)/2);
            mX = x;
            mY = y;
        }
    }

    private void touch_up() {
        mPath.lineTo(mX, mY);
        // commit the path to our offscreen
        mCanvas.drawPath(mPath, mPaint);
        // kill this so we don't double draw            
        mPath = new Path();
        paths.add(mPath);
    }

    @Override
    public boolean onTouch(View arg0, MotionEvent event) {
        float x = event.getX();
        float y = event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                touch_start(x, y);
                invalidate();
                break;
            case MotionEvent.ACTION_MOVE:
                touch_move(x, y);
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                touch_up();
                invalidate();
                break;
        }
        return true;
    } 
}  

我使用了android手指绘图的样例,并进行了一些修改,使其能够存储每一个路径,而不仅仅是最后一个!希望能对某些人有所帮助!

祝好。


1
嘿...请尝试在某个地方清除列表,否则随着一次绘制更多内容,它会逐渐变慢。 - Arun Abraham
1
嗨。通过存储绘图路径,实现“撤销”或“恢复”功能很容易。但是如果我们没有将绘图存储到位图中,如何实现擦除功能呢? - Yeung
只是好奇,mCanvas 有什么用?你在 touch_up() 中提到了“将路径提交到我们的离屏”,但似乎 mCanvas 在其他任何地方都没有被使用过。 - Jake Stoeffler
你能帮我解决这个问题吗? - Leonardo Sapuy
非常好,但你能告诉我如何删除并清除所有画布吗?我的意思是,无论我绘制了什么,我怎样才能将它们全部清除? - Coas Mckey
显示剩余3条评论

23

我尝试了几种方法来呈现运动事件的积累点。最终,我通过计算两个点之间的中点,并将列表中的点视为二次贝塞尔曲线的锚点(除了第一个和最后一个点,它们通过简单的线连接到下一个中点),得到了最佳结果。

这样可以得到平滑的曲线而没有任何拐角。绘制的路径将不会碰到列表中实际的点,但会经过每一个中点。

Path path = new Path();
if (points.size() > 1) {
    Point prevPoint = null;
    for (int i = 0; i < points.size(); i++) {
        Point point = points.get(i);

        if (i == 0) {
            path.moveTo(point.x, point.y);
        } else {
            float midX = (prevPoint.x + point.x) / 2;
            float midY = (prevPoint.y + point.y) / 2;

            if (i == 1) {
                path.lineTo(midX, midY);
            } else {
                path.quadTo(prevPoint.x, prevPoint.y, midX, midY);
            }
        }
        prevPoint = point;
    }
    path.lineTo(prevPoint.x, prevPoint.y);
}

谢谢。它完全做到了我所期望的。 - Mario Lenci
谢谢你,这正是我所寻找的! - Aurel
1
我不得不将 path.lineTo(prevPoint.x, prevPoint.y) 语句从 for 循环中提取出来,以获得一条干净的平滑线。您能解释一下它为什么要在那里吗? - wheels53
path.lineTo(prevPoint.x, prevPoint.y) 的作用是将最后一个中间点连接到终点。它已经在循环外部,不应该影响线条的平滑度。 - Eric Obermühlner

10
如果您想简单:
public class DrawByFingerCanvas extends View {

    private Paint brush = new Paint(Paint.ANTI_ALIAS_FLAG);
    private Path path = new Path();

    public DrawByFingerCanvas(Context context) {
        super(context);
        brush.setStyle(Paint.Style.STROKE);
        brush.setStrokeWidth(5);
    }

    @Override
    protected void onDraw(Canvas c) {
        c.drawPath(path, brush);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        float x = event.getX();
        float y = event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                path.moveTo(x,y);
                break;
            case MotionEvent.ACTION_MOVE:
                path.lineTo(x, y);
                break;
            default:
                return false;
        }
        invalidate();
        return true;
    }
}

在这个活动中:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(new DrawByFingerCanvas(this));
}

结果:

输入图像描述

要清除所有绘画,请旋转屏幕。


1
这应该是被接受的答案——用最简单的代码实现了 OP 所要求的功能。 - Jacob Kaddoura
谢谢。请告诉我如何添加一个大加号符号,以显示光标所在的位置? - Faizan Haidar Khan

6

我遇到了非常类似的问题。当你调用onTouch方法时,你也应该使用onTouch(MotionEvent event)方法(在onTouch方法内部)。

event.getHistorySize();

以及类似的内容。

int histPointsAmount = event.getHistorySize(); 
for(int i = 0; i < histPointsAmount; i++){
    // get points from event.getHistoricalX(i);
    // event.getHistoricalY(i); and use them for your purpouse
}

不知道这个...很好奇事件中有多少历史数据。 - Yevgeny Simkin

4

ACTION_MOVE 的运动事件可能会在单个对象中批量包含多个移动样本。最新的指针坐标可使用 getX(int) 和 getY(int) 获取。批处理中较早的坐标可使用 getHistoricalX(int, int)getHistoricalY(int, int) 访问。在构建路径时使用它们使得路径更加平滑:

    int historySize = event.getHistorySize();
    for (int i = 0; i < historySize; i++) {
      float historicalX = event.getHistoricalX(i);
      float historicalY = event.getHistoricalY(i);
      path.lineTo(historicalX, historicalY);
    }

    // After replaying history, connect the line to the touch point.
    path.lineTo(eventX, eventY);

这里有一篇来自Square的好教程:http://corner.squareup.com/2010/07/smooth-signatures.html


我遇到了曲线无法正确绘制的问题。这个解决方案解决了我的问题。谢谢! - channae

3
我最近对这个做了一些修改,现在我开发了我认为是最好的解决方案,因为它能够做到以下三点:
  1. 允许你绘制不同的线条
  2. 适用于更大的画笔粗细,而无需使用复杂的三次样条插值
  3. 比这里的许多解决方案更快,因为canvas.drawPath()方法在for循环外部调用,所以不会被多次调用。

public class DrawView extends View implements OnTouchListener {
private static final String TAG = "DrawView";

List<Point> points = new ArrayList<Point>();
Paint paint = new Paint();
List<Integer> newLine = new ArrayList<Integer>();

public DrawView(Context context, AttributeSet attrs){
        super(context, attrs);
        setFocusable(true);
        setFocusableInTouchMode(true);
        setClickable(true);

        this.setOnTouchListener(this);

        paint.setColor(Color.WHITE);
        paint.setAntiAlias(true);
        paint.setStyle(Paint.Style.STROKE);
        paint.setStrokeWidth(20);

    }

    public void setColor(int color){
        paint.setColor(color);
    }
    public void setBrushSize(int size){
        paint.setStrokeWidth((float)size);
    }
    public DrawView(Context context) {
        super(context);
        setFocusable(true);
        setFocusableInTouchMode(true);

        this.setOnTouchListener(this);


        paint.setColor(Color.BLUE);
        paint.setAntiAlias(true);
        paint.setStyle(Paint.Style.STROKE);
        paint.setStrokeWidth(20);
    }

    @Override
    public void onDraw(Canvas canvas) {
        Path path = new Path();
        path.setFillType(Path.FillType.EVEN_ODD);
        for (int i = 0; i<points.size(); i++) {
            Point newPoint = new Point();
            if (newLine.contains(i)||i==0){
                newPoint = points.get(i)
                path.moveTo(newPoint.x, newPoint.y);
            } else {
                newPoint = points.get(i);

                path.lineTo(newPoint.x, newPoint.y);
            }

        }
        canvas.drawPath(path, paint);
    }

    public boolean onTouch(View view, MotionEvent event) {
        Point point = new Point();
        point.x = event.getX();
        point.y = event.getY();
        points.add(point);
        invalidate();
        Log.d(TAG, "point: " + point);
        if(event.getAction() == MotionEvent.ACTION_UP){
            // return super.onTouchEvent(event);
            newLine.add(points.size());
        }
        return true;
    }
    }

    class Point {
        float x, y;

    @Override
    public String toString() {
        return x + ", " + y;
    }
    }

这也可以运行,但效果不如此前的那个好。
  import java.util.ArrayList;
import java.util.List;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnTouchListener;
import android.util.*;

public class DrawView extends View implements OnTouchListener {
    private static final String TAG = "DrawView";
List<Point> points = new ArrayList<Point>();
Paint paint = new Paint();
List<Integer> newLine = new ArrayList<Integer>();

public DrawView(Context context, AttributeSet attrs){
    super(context, attrs);
    setFocusable(true);
    setFocusableInTouchMode(true);

    this.setOnTouchListener(this);

    paint.setColor(Color.WHITE);
    paint.setAntiAlias(true);
}
public DrawView(Context context) {
    super(context);
    setFocusable(true);
    setFocusableInTouchMode(true);

    this.setOnTouchListener(this);

    paint.setColor(Color.WHITE);
    paint.setAntiAlias(true);
    }

@Override
public void onDraw(Canvas canvas) {
    for (int i = 0; i<points.size(); i++) {
        Point newPoint = new Point();
        Point oldPoint = new Point();
        if (newLine.contains(i)||i==0){
            newPoint = points.get(i);
            oldPoint = newPoint;
        } else {
            newPoint = points.get(i);
            oldPoint = points.get(i-1);
        }
            canvas.drawLine(oldPoint.x, oldPoint.y, newPoint.x, newPoint.y, paint);
    }
}

public boolean onTouch(View view, MotionEvent event) {
    Point point = new Point();
    point.x = event.getX();
    point.y = event.getY();
    points.add(point);
    invalidate();
    Log.d(TAG, "point: " + point);
    if(event.getAction() == MotionEvent.ACTION_UP){
        // return super.onTouchEvent(event);
        newLine.add(points.size());
    }
    return true;
    }
}

class Point {
    float x, y;

    @Override
    public String toString() {
        return x + ", " + y;
    }
}

它可以让你比较好地画线,唯一的问题是如果你把线条变粗,那么画出来的线条会看起来有点奇怪,所以我仍然建议使用第一个。


1

请为提问者发布最小化的示例。 - Daniel.Wang

1

你可能在MotionEvent中有更多的信息可用,而你并没有意识到这些信息可以提供一些数据。

你链接中的示例忽略了事件中包含的历史触摸点。请参阅MotionEvent文档顶部的“批处理”部分:http://developer.android.com/reference/android/view/MotionEvent.html 此外,使用线连接这些点可能也不是一个坏主意。


0

我曾经遇到过这个问题,我画了一个点而不是一条线。你应该先创建一个路径来保存你的线条。只在第一次触摸事件上调用path.moveto。然后在你的画布上绘制路径,最后在完成后重置或倒回路径(path.reset)...


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接