四、高级 2D 渲染

能够绘制更复杂的图元或使用它们的组合对于使我们的自定义视图的用户体验变得令人敬畏、有用和特别至关重要。到目前为止,我们已经在自定义视图上使用了一些绘图和渲染操作,但是如果我们仔细查看安卓文档,这是安卓向开发人员提供的一组减少了很多的操作。我们已经绘制了一些图元,保存并恢复了我们的canvas状态,并应用了一些裁剪操作,但这只是顶部的薄层。在本章中,我们将再次看到这些操作,但我们也将看到一些新的绘图操作,以及我们如何一起使用所有内容。我们将更详细地介绍以下主题:

  • 绘图操作
  • 掩蔽和剪辑
  • 梯度
  • 把它们放在一起

绘图操作

正如我们刚刚提到的,我们已经看到并使用了一些绘图操作,但那只是里面的内容的信封。我们将看到新的绘图操作以及如何组合它们。

位图

让我们从绘制位图或图像开始。我们将使用图像作为自定义视图的背景,而不是白色背景。使用前面示例中的源代码,我们可以做一些非常简单的修改来绘制图像:

首先,让我们定义一个Bitmap对象,它将保存对图像的引用:

private Bitmap backgroundBitmap; 

首先,让我们用应用上已经有的应用图标初始化它:

public CircularActivityIndicator(Context context, AttributeSet attributeSet) { 
    super(context, attributeSet); 

    backgroundBitmap = BitmapFactory.decodeResource(getResources(),
    R.mipmap.ic_launcher); 

BitmapFactory为我们提供了几种加载和解码图像的方式。

一旦我们加载了图像,我们可以通过调用drawBitmap(Bitmap bitmap, float left, float top, Paint paint)方法在我们的onDraw()方法上绘制它:

@Override 
protected void onDraw(Canvas canvas) { 
    if (backgroundBitmap != null) { 
        canvas.drawBitmap(backgroundBitmap, 0, 0, null); 
    } 

由于我们不需要Paint对象的任何特殊内容,所以我们将其设置为null;我们将在本书后面使用它,但目前,请忽略它。

如果backgroundBitmapnull,则表示无法加载图像;所以,为了安全,我们应该经常检查。这段代码将在我们的自定义视图的左上角绘制图标,尽管我们可以通过设置不同的坐标来改变它的位置-这里我们使用了00-或者像以前一样对我们的canvas应用变换。例如,我们可以根据用户选择的角度旋转图像:

@Override 
protected void onDraw(Canvas canvas) { 
    // apply a rotation of the bitmap based on the selectedAngle 
    if (backgroundBitmap != null) { 
        canvas.save(); 
        canvas.rotate(selectedAngle, backgroundBitmap.getWidth() / 2,
        backgroundBitmap.getHeight() / 2); 
        canvas.drawBitmap(backgroundBitmap, 0, 0, null); 
        canvas.restore(); 
    } 

请注意,我们已经添加了图像的中心作为枢轴点,否则将旋转其左上角。

还有其他方法来绘制图像;安卓还有另一种方法可以将图像从源Rect绘制到目的地RectRect对象允许我们存储四个坐标并将其用作矩形。

方法drawBitmap(Bitmap bitmap, Rect source, Rect dest, Paint paint)对于将图像的一部分绘制成我们想要的任何其他尺寸非常有用。此方法将注意缩放图像的选定部分以填充目标矩形。例如,如果我们想要绘制缩放到整个自定义视图大小的图像的右半部分,我们可以使用下面的代码。

首先我们定义一下背景Bitmap和两个Rect;一个用于保存源维度,另一个用于保存目标维度:

private Bitmap backgroundBitmap; 
private Rect bitmapSource; 
private Rect bitmapDest; 

然后,让我们在类构造函数上实例化它们。在onDraw()方法上这样做并不是一个好的做法,因为我们应该避免将内存分配给在每一帧或每次绘制自定义视图时调用的方法。这样做会触发额外的垃圾收集器循环并影响性能。

public CircularActivityIndicator(Context context, AttributeSet attributeSet) { 
    super(context, attributeSet); 

    backgroundBitmap = BitmapFactory.decodeResource(getResources(),
    R.mipmap.ic_launcher); 
    bitmapSource = new Rect(); 

    bitmapSource.top = 0; 
    bitmapSource.left = 0; 
    if(backgroundBitmap != null) { 
        bitmapSource.left = backgroundBitmap.getWidth() / 2; 
        bitmapSource.right = backgroundBitmap.getWidth(); 
        bitmapSource.botto 
        m = backgroundBitmap.getHeight(); 
    } 
    bitmapDest = new Rect(); 

默认情况下,Rect将四个坐标初始化为 0,但是在这里,为了清楚起见,我们将顶部和左侧的坐标设置为 0。如果图像加载成功,我们将右侧和底部分别设置为图像的宽度和高度。由于我们只想绘制图像的右半部分,因此我们将左边框更新为图像宽度的一半。

onDraw()方法中,我们将目的地Rect的右侧和底部坐标设置为自定义视图的宽度和高度,并绘制图像:

@Override 
protected void onDraw(Canvas canvas) { 
    if (backgroundBitmap != null) { 
        bitmapDest.right = getWidth(); 
        bitmapDest.bottom = getHeight(); 

        canvas.drawBitmap(backgroundBitmap, bitmapSource, bitmapDest,
        null); 
    } 

让我们检查一下结果:

我们可以看到它不遵守图像的长宽比,但是让我们通过计算较小维度的比例来解决它,无论是水平还是垂直,并按照这个比例缩放它。然后,将其应用于另一个维度。计算图像比率后,我们将看到以下代码:

@Override 
protected void onDraw(Canvas canvas) { 
    if (backgroundBitmap != null) { 
        if ((bitmapSource.width() > bitmapSource.height() && getHeight() >
        getWidth()) || 
            (bitmapSource.width() <= bitmapSource.height() && getWidth() >=
            getHeight())) { 

            double ratio = ((double) getHeight()) / ((double)
            bitmapSource.height()); 
            int scaledWidth = (int) (bitmapSource.width() * ratio); 
            bitmapDest.top = 0; 
            bitmapDest.bottom = getHeight(); 
            bitmapDest.left = (getWidth() - scaledWidth) / 2; 
            bitmapDest.right = bitmapDest.left + scaledWidth; 
        } else { 
            double ratio = ((double) getWidth()) / ((double)
            bitmapSource.width()); 
            int scaledHeight = (int) (bitmapSource.height() * ratio); 
            bitmapDest.left = 0; 
            bitmapDest.right = getWidth(); 
            bitmapDest.top = 0; 
            bitmapDest.bottom = scaledHeight; 
        } 

        canvas.drawBitmap(backgroundBitmap, bitmapSource, bitmapDest,
        null); 
    } 

我们也可以使用变换Matrix绘制一个Bitmap。为此,我们可以创建一个新的Matrix实例并应用一个转换:

private Matrix matrix; 

在构造函数上创建一个实例。不要在onDraw()实例上创建实例,因为这会污染内存并触发不必要的垃圾收集,如前所述:

matrix = new Matrix(); 
matrix.postScale(0.2f, 0.2f); 
matrix.postTranslate(0, 200); 

请小心矩阵操作顺序;还有手术后和手术前。有关更多信息,请查看矩阵类文档。

onDraw()方法上,使用drawBitmap (Bitmap bitmap, Matrix matrix, Paint paint)方法并使用我们在类构造器上初始化的matrix绘制Bitmap。在这个例子中,我们还使用了一个null Paint对象来简化,因为我们不需要从这里的Paint对象中得到任何具体的东西。

canvas.drawBitmap(backgroundBitmap, matrix, null); 

虽然这些是将Bitmap绘制到Canvas上最常见的方法,但还有其他一些方法。

此外,检查 GitHub 存储库中的Example12-Drawing文件夹,查看该示例的完整源代码。

使用绘画类

到现在为止,我们一直在绘制一些图元,但是Canvas为我们提供了更多的图元渲染方法。我们将简要介绍其中的一些,但首先,让我们先谈谈Paint类,因为我们还没有正确介绍它。

根据官方定义,Paint类保存了关于如何绘制图元、文本和位图的样式和颜色信息。如果我们检查我们已经构建的例子,我们在我们的类构造器上或者在onCreate方法上创建了一个Paint对象,然后我们用它在我们的onDraw()方法上绘制原语。例如,如果我们将背景绘制实例Style设置为Paint.Style.FILL,它将填充图元,但是如果我们只想绘制轮廓的边界或笔画,我们可以将其更改为Paint.Style.STROKE。我们可以使用Paint.Style.FILL_AND_STROKE来绘制两者。

为了看到Paint.Style.STROKE的动作,我们将在自定义视图中所选的彩色条的顶部绘制一个黑色边框。让我们从定义一个名为indicatorBorderPaint的新Paint对象开始,并在我们的类构造器上初始化它:

indicatorBorderPaint = new Paint(); 
indicatorBorderPaint.setAntiAlias(false); 
indicatorBorderPaint.setColor(BLACK_COLOR); 
indicatorBorderPaint.setStyle(Paint.Style.STROKE); 
indicatorBorderPaint.setStrokeWidth(BORDER_SIZE); 
indicatorBorderPaint.setStrokeCap(Paint.Cap.BUTT); 

我们还用边框线的大小定义了一个常数,并将笔画宽度设置为这个大小。如果我们将宽度设置为0,安卓保证它将使用单个像素来画线。因为我们想画一个黑色的粗边框,所以这不是我们现在的情况。此外,我们将行程Cap设置为Paint.Cap.BUTT,以避免行程溢出其路径。我们还可以使用两个大写字母,Paint.Cap.SQUAREPaint.Cap.ROUND。这最后两个将分别以一个圆、一个圆角或一个正方形结束笔画。

让我们快速看看三个 Caps 的区别,也介绍一下drawLine原语。

首先,我们用所有三个 Caps 创建一个数组,这样我们就可以轻松地在它们之间迭代,并创建一个更紧凑的代码:

private static final Paint.Cap[] caps = new Paint.Cap[] { 
        Paint.Cap.BUTT, 
        Paint.Cap.ROUND, 
        Paint.Cap.SQUARE 
}; 

现在,在我们的onDraw()方法中,让我们使用drawLine(float startX, float startY, float stopX, float stopY, Paint paint)方法使用每个 Caps 绘制一条线:

int xPos = (getWidth() - 100) / 2; 
int yPos = getHeight() / 2 - BORDER_SIZE * CAPS.length / 2; 
for(int i = 0; i < CAPS.length; i++) { 
    indicatorBorderPaint.setStrokeCap(CAPS[i]); 
    canvas.drawLine(xPos, yPos, xPos + 100, yPos,
    indicatorBorderPaint); 
    yPos += BORDER_SIZE * 2; 
} 
indicatorBorderPaint.setStrokeCap(Paint.Cap.BUTT); 

我们将得到类似于下图的结果。我们可以看到,使用Paint.Cap.BUTT笔画Cap时线条略短:

同样,正如我们之前看到的,我们在Paint对象上将AntiAlias标志设置为真。如果启用此标志,所有支持它的操作都将平滑它们所绘制的角。让我们比较启用和禁用此标志的区别:

在左边,我们有三行启用了AntiAlias标志,在右边,我们有同样的三行禁用了AntiAlias标志。我们只能欣赏圆形边缘的差异,但结果更平滑、更好。不是所有的操作和原语都支持它,并且可能会对性能产生影响,所以我们在使用这个标志时需要小心。

我们也可以使用另一种称为drawLine(float[] points, int offset, int count, Paint paint)的方法或其更简单的形式drawLine(float[] points, Paint paint)来绘制多条线。

该方法将为数组中的四个条目的每一个集合绘制一条线;这就像调用drawLine(array[index], array[index + 1], array[index + 2], array[index +3], paint),将索引增加4,并重复这个过程直到数组结束。

在第一种方法中,我们还可以指定要绘制的线条数量,以及从数组内部的哪个偏移量开始。

现在,让我们继续我们的任务,画出边界:

canvas.drawArc( 
       horMargin + BORDER_SIZE / 4, 
       verMargin + BORDER_SIZE / 4, 
       horMargin + circleSize - BORDER_SIZE /2, 
       verMargin + circleSize - BORDER_SIZE /2, 
       0, selectedAngle, true, indicatorBorderPaint); 

它只是画了同样的弧线,但是有了这个新的Paint。一个小细节:随着边框宽度从绘制笔画的位置向中心增长,我们需要将弧线的尺寸缩小BORDER_SIZE /2。让我们看看结果:

我们遗漏了内部边界,但这很正常,因为如果我们记得前几章,那部分在那里是因为我们把它剪了出来,而不是因为drawArc是那样画的。我们可以做一个小把戏来画出这个内部边界。我们将绘制另一个带有裁剪区域大小的弧线,但只是笔画:

canvas.drawArc( 
       clipX - BORDER_SIZE / 4, 
       clipY - BORDER_SIZE / 4, 
       clipX + clipWidth + BORDER_SIZE / 2, 
       clipY + clipWidth + BORDER_SIZE / 2, 
       0, selectedAngle, true, indicatorBorderPaint); 

在这里,我们对边框大小应用了相同的逻辑,但是反过来:我们将弧线画得稍微大一些,而不是小一些。

让我们看看结果:

我们在本书前面已经提到过,但是重要的是不要在onDraw()方法中或者基本上在每次绘制框架时都会调用的任何方法中创建新的Paint对象。我们可能会被诱惑,因为在某些情况下,感觉很方便;但是,避免这种诱惑,在类构造函数中创建对象,或者只是重用这些对象。我们可以更改Paint类实例属性,并重新使用它来绘制不同的颜色或不同的样式。

在 GitHub 存储库的Example13-Paint文件夹中找到这个例子的全部源代码。

我们将更多地使用Paint对象及其属性,但是现在,让我们开始绘制更多的图元。

绘制更多图元

让我们从最简单的绘图操作开始:drawColor(int color)drawARGB(int a, int r, int g, int b)drawRGB(int r, int g, int b),drawPaint(Paint paint)。这些将填充整个canvas,考虑到剪辑区域。

让我们前进到drawRect()drawRoundRect()。这两种方法也很简单,drawRect()画矩形,drawRoundRect()画圆角矩形。

我们可以直接使用这两种方法,指定坐标或者使用Rect。让我们创建一个简单的例子,它将在每次绘制视图或调用onDraw()方法时绘制一个新的随机圆角矩形。

首先,让我们定义两个ArrayLists;一个保存坐标,另一个保存该矩形的颜色信息:

private Paint paint; 
private ArrayList<Float> rects; 
private ArrayList<Integer> colors; 

我们还声明了一个Paint对象,我们将使用它来绘制所有的圆角矩形。现在让我们初始化它们:

public PrimitiveDrawer(Context context, AttributeSet attributeSet) { 
    super(context, attributeSet); 

    rects = new ArrayList<>(); 
    colors = new ArrayList<>(); 

    paint = new Paint(); 
    paint.setStyle(Paint.Style.FILL); 
    paint.setAntiAlias(true); 
} 

我们已经将绘制对象样式设置为Paint.Style.FILL并设置了AntiAlias标志,但尚未设置颜色。我们将在绘制每个矩形之前这样做。

现在让我们实现我们的onDraw()方法。首先,我们将添加四个新的随机坐标。当Math.random()返回一个从01的值时,我们将其乘以当前视图的宽度和高度,得到一个合适的视图坐标。我们还生成了一种新的完全不透明的随机颜色:

@Override 
protected void onDraw(Canvas canvas) { 
    canvas.drawColor(BACKGROUND_COLOR); 

    int width = getWidth(); 
    int height = getHeight(); 

    for (int i = 0; i < 2; i++) { 
        rects.add((float) Math.random() * width); 
        rects.add((float) Math.random() * height); 
    } 
    colors.add(0xff000000 | (int) (0xffffff * Math.random())); 

    for (int i = 0; i < rects.size() / 4; i++) { 
        paint.setColor(colors.get(i)); 
        canvas.drawRoundRect( 
                rects.get(i * 4    ), 
                rects.get(i * 4 + 1), 
                rects.get(i * 4 + 2), 
                rects.get(i * 4 + 3), 
                40, 40, paint); 
    } 

    if (rects.size() < 400) postInvalidateDelayed(20); 
} 

然后,我们将循环我们添加的所有随机点,并取它们当时的4,假设前两个将是矩形的起始 X 和 Y 坐标,后两个将是矩形的结束 X 和 Y 坐标。我们将40硬编码为圆角的角度。我们可以用这个值来改变圆度的大小。

我们引入了颜色的逐位运算。我们知道可以用 32 位整数值存储颜色,通常是 ARGB 格式。每个分量有 8 位。通过按位运算,我们可以轻松操作颜色。关于位运算的更多信息,请参考: 【https://en.wikipedia.org/wiki/Bitwise_operation】

最后,如果我们的数组中少于100个矩形或400个坐标,我们会发布一个延迟20毫秒的Invalidate事件。这只是为了演示,并表明它正在添加和绘制更多的矩形。drawRoundRect()方法可以很容易地通过drawRect()改变,只需移除两个硬编码的40 s 作为圆角的角度。

让我们看看结果:

完整的源代码,请查看 GitHub 存储库中的Example14-Primitives-Rect文件夹。

让我们继续其他原语,例如,drawPointsdrawPoints(float[] points, Paint paint)方法将简单地绘制一个点列表。它将使用paint对象的笔画宽度和笔画Cap。例如,一个快速示例绘制了几条随机线,并在每条线的开头和结尾都绘制了一个点:

@Override 
protected void onDraw(Canvas canvas) { 
    canvas.drawColor(BACKGROUND_COLOR); 

    if (points == null) { 
        points = new float[POINTS * 2]; 
        for(int i = 0; i < POINTS; i++) { 
            points[i * 2    ] = (float) Math.random() * getWidth(); 
            points[i * 2 + 1] = (float) Math.random() * getHeight(); 
        } 
    } 

    paint.setColor(0xffa0a0a0); 
    paint.setStrokeWidth(4.f); 
    paint.setStrokeCap(Paint.Cap.BUTT); 
    canvas.drawLines(points, paint); 

    paint.setColor(0xffffffff); 
    paint.setStrokeWidth(10.f); 
    paint.setStrokeCap(Paint.Cap.ROUND); 
    canvas.drawPoints(points, paint); 
} 

让我们看看结果:

我们在这里用onDraw()方法创建点数组,但是只做了一次。

在 GitHub 存储库中的Example15-Primitives-Points文件夹中检查这个例子的完整源代码。

在前一个例子的基础上,我们可以很容易地引入drawCircle原语。让我们稍微改变一下代码;让我们生成三个随机值,而不是只生成随机值对。前两个是圆的XY坐标,第三个是圆的半径。此外,为了清楚起见,让我们去掉这些行:

@Override 
protected void onDraw(Canvas canvas) { 
    canvas.drawColor(BACKGROUND_COLOR); 

    if (points == null) { 
        points = new float[POINTS * 3]; 
        for(int i = 0; i < POINTS; i++) { 
            points[i * 3    ] = (float) Math.random() * getWidth(); 
            points[i * 3 + 1] = (float) Math.random() * getHeight(); 
            points[i * 3 + 2] = (float) Math.random() * (getWidth()/4); 
        } 
    } 

    for (int i = 0; i < points.length / 3; i++) { 
        canvas.drawCircle( 
                points[i * 3    ], 
                points[i * 3 + 1], 
                points[i * 3 + 2], 
                paint); 
    } 
} 

我们还在类构造器上初始化了我们的paint对象:

paint = new Paint(); 
paint.setStyle(Paint.Style.FILL); 
paint.setAntiAlias(true); 
paint.setColor(0xffffffff); 

让我们看看结果:

在 GitHub 存储库中的Example16-Primitives-Circles文件夹中检查这个例子的完整源代码。

要了解在Canvas上绘制的所有原语、模式和方法,请查看安卓文档。

路径可以被认为是图元、直线、曲线和其他几何形状的容器,正如我们已经看到的,它们可以被用作裁剪区域、绘制或用于在其上绘制文本。

首先,让我们修改前面的例子,将所有的圆转换成一个Path:

@Override 
protected void onDraw(Canvas canvas) { 
    if (path == null) { 
        float[] points = new float[POINTS * 3]; 
        for(int i = 0; i < POINTS; i++) { 
            points[i * 3    ] = (float) Math.random() * getWidth(); 
            points[i * 3 + 1] = (float) Math.random() * getHeight(); 
            points[i * 3 + 2] = (float) Math.random() * (getWidth()/4); 
        } 

        path = new Path(); 

        for (int i = 0; i < points.length / 3; i++) { 
            path.addCircle( 
                    points[i * 3    ], 
                    points[i * 3 + 1], 
                    points[i * 3 + 2], 
                    Path.Direction.CW); 
        } 

        path.close(); 
    } 

我们不需要存储点,所以我们将其声明为局部变量。相反,我们创建了一个Path对象。现在我们有了这个带有所有圆圈的Path,我们可以通过调用drawPath(Path path, Paint paint)方法来绘制它,或者将其用作剪辑蒙版。

我们在项目中添加了一个图像,并将它绘制为背景图像,但是我们将应用由Path定义的剪辑蒙版来使事情变得有趣:

    canvas.save(); 

    if (!touching) canvas.clipPath(path); 
    if(background != null) { 
        backgroundTranformation.reset(); 
        float scale = ((float) getWidth()) / background.getWidth(); 
        backgroundTranformation.postScale(scale, scale); 
        canvas.drawBitmap(background, backgroundTranformation, null); 
    } 
    canvas.restore(); 
} 

让我们看看结果:

要查看该示例的完整源代码,请查看 GitHub 存储库中的Example17-Paths文件夹。

查看安卓关于路径的文档,我们可以看到有很多方法可以给Path添加原语,例如:

  • addCircle()
  • addRect()
  • addRoundRect()
  • addPath()

然而,我们并不局限于这些方法,我们还可以分别使用lineTomoveTo方法在我们的路径将开始下一个元素的地方添加线条或移位。在我们想要使用相对坐标的情况下,Path类为我们提供了方法rLineTorMoveTo,假设给定的坐标相对于Path的最后一点。

有关Path及其方法的更多信息,请查看安卓文档网站。我们可以通过cubicToquadTo的方法做到这一点。贝塞尔曲线由控制平滑曲线形状的控制点组成。让我们通过在用户每次点击屏幕时添加控制点来建立一个快速的例子。

首先,让我们定义两个Paint对象,一个用于贝塞尔曲线,另一个用于绘制参考控制点:

pathPaint = new Paint(); 
pathPaint.setStyle(Paint.Style.STROKE); 
pathPaint.setAntiAlias(true); 
pathPaint.setColor(0xffffffff); 
pathPaint.setStrokeWidth(5.f); 

pointsPaint = new Paint(); 
pointsPaint.setStyle(Paint.Style.STROKE); 
pointsPaint.setAntiAlias(true); 
pointsPaint.setColor(0xffff0000); 
pointsPaint.setStrokeCap(Paint.Cap.ROUND); 
pointsPaint.setStrokeWidth(40.f); 

控制点将被绘制成圆形红点,而贝塞尔曲线将被绘制成更细的白线。当我们初始化我们的对象时,让我们也定义一个空的Path和一个浮点数组来存储点:

points = new ArrayList<>(); 
path = new Path(); 

现在,让我们覆盖onTouchEvent()来添加用户点击屏幕的点,并通过调用 invalidate 方法来触发自定义视图的重绘。

@Override 
public boolean onTouchEvent(MotionEvent event) { 
    if (event.getAction() == MotionEvent.ACTION_DOWN) { 
        points.add(event.getX()); 
        points.add(event.getY()); 

        invalidate(); 
    } 

    return super.onTouchEvent(event); 
} 

在我们的onDraw()方法上,我们先检查一下是否已经有三个点了。如果是这样的话,让我们给我们的Path添加一个三次贝塞尔曲线:

while(points.size() - currentIndex >= 6) { 
    float x1 = points.get(currentIndex); 
    float y1 = points.get(currentIndex + 1); 

    float x2 = points.get(currentIndex + 2); 
    float y2 = points.get(currentIndex + 3); 

    float x3 = points.get(currentIndex + 4); 
    float y3 = points.get(currentIndex + 5); 

    if (currentIndex == 0) path.moveTo(x1, y1); 
    path.cubicTo(x1, y1, x2, y2, x3, y3); 
    currentIndex += 6; 
} 

currentIndex保持插入到Path中的点阵列的最后一个索引。

现在,让我们画出Path和点:

canvas.drawColor(BACKGROUND_COLOR); 
canvas.drawPath(path, pathPaint); 

for (int i = 0; i < points.size() / 2; i++) { 
    float x = points.get(i * 2    ); 
    float y = points.get(i * 2 + 1); 
    canvas.drawPoint(x, y, pointsPaint); 
} 

让我们看看结果:

请在 GitHub 存储库中的Example18-Paths文件夹中查看该示例的完整源代码。

绘图文本

Canvas操作的角度来看,文本可以被认为是一个原语,但是我们把它放在了自己的部分,因为它非常重要。我们不再从最简单的例子开始,因为我们刚刚介绍了路径,我们将继续我们之前的例子,并在Path的顶部绘制文本。为了绘制文本,我们将为贝塞尔曲线重用Paint对象,但是我们将添加一些文本参数:

pathPaint.setTextSize(50.f); 
pathPaint.setTextAlign(Paint.Align.CENTER); 

这将设置文本的大小,并将其与Path的中心对齐,因此每次我们添加新的点时,文本位置都会调整为保持在中心。为了绘制文本,我们简单地称之为drawTextOnPath()方法:

canvas.drawTextOnPath("Building Android UIs with Custom Views", path, 0, 0, pathPaint); 

这是对我们代码的一个非常快速的补充,但是如果我们执行我们的应用,我们可以看到结果,文本在Path行:

考虑到我们画的东西和以前画的一样,但是我们可以自由使用Path作为文本的指南。不需要画它,也不需要画控制点。

在 GitHub 存储库中的Example19-Text folder上查看这个例子的完整源代码。

我们已经开始在路径上绘制文本,因为我们几乎已经构建了示例。但是,有更简单的方法来绘制文本。例如,我们可以通过调用canvas.drawText(String text, float x, float y, Paint paint)canvas.drawText(char[] text, float x, float y, Paint paint)在屏幕的特定位置绘制文本。

这些方法将只是做他们的工作,但他们不会检查文本是否适合可用的空间,他们肯定不会分裂和包装文本。要做到这一点,我们必须自己做。Paint类为我们提供了测量文本和计算文本边界的方法。例如,我们创建了一个小助手方法,返回String的宽度和高度:

private static final float[] getTextSize(String str, Paint paint) { 
    float[] out = new float[2]; 
    Rect boundaries = new Rect(); 
    paint.getTextBounds(str, 0, str.length(), boundaries); 

    out[0] = paint.measureText(str); 
    out[1] = boundaries.height(); 
    return out; 
} 

我们已经使用文本边界来获得文本高度,但是我们已经使用measureText()方法来获得文本宽度。在这两种方法中,大小的计算方式有一些不同。虽然目前在安卓文档网站上没有正确记录,但是在 Stack Overflow: 上有一个关于这个的老讨论。

但是,我们不应该实现自己的文本拆分方法。如果我们想要绘制一个大的文本,并且我们知道它可能需要拆分和包装,我们可以使用StaticLayout类。在这里的例子中,我们将创建一个宽度为视图宽度一半的StaticLayout

我们可以在我们的onLayout()方法上实现它:

@Override 
protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 
    super.onLayout(changed, left, top, right, bottom); 

    // create a layout of half the width of the View 
    if (layout == null) { 
        layout = new StaticLayout( 
                LONG_TEXT, 
                0, 
                LONG_TEXT.length(), 
                paint, 
                (right - left) / 2, 
                Layout.Alignment.ALIGN_NORMAL, 
                1.f, 
                1.f, 
                true); 
    } 
} 

在我们的onDraw()方法中,我们以屏幕为中心绘制。我们知道,布局宽度是视图宽度的一半;我们知道我们必须把它移动四分之一的宽度。

@Override 
protected void onDraw(Canvas canvas) { 
    canvas.drawColor(BACKGROUND_COLOR); 

    canvas.save(); 
    // center the layout on the View 
    canvas.translate(canvas.getWidth()/4, 0); 
    layout.draw(canvas); 
    canvas.restore(); 
} 

结果如下:

在 GitHub 存储库中的Example20-Text文件夹中检查这个例子的完整源代码。

转换和操作

我们之前已经在自定义视图中使用了一些canvas转换,但是让我们重新回顾一下我们可以使用的Canvas操作。首先,让我们看看如何连接这些转换。一旦我们使用了一个转换,我们使用的任何其他转换都将被连接或应用到我们之前的操作之上。为了避免这种行为,我们必须调用我们之前也使用过的save()restore()方法。为了了解转换是如何建立在彼此之上的,让我们创建一个简单的例子。

首先,让我们在构造器上创建一个paint对象:

public PrimitiveDrawer(Context context, AttributeSet attributeSet) { 
    super(context, attributeSet); 

    paint = new Paint(); 
    paint.setStyle(Paint.Style.STROKE); 
    paint.setAntiAlias(true); 
    paint.setColor(0xffffffff); 
} 

现在,让我们根据onLayout()方法上的屏幕大小来计算矩形大小:

@Override 
 protected void onLayout(boolean changed, int left, int top, int right,
 int bottom) { 
     super.onLayout(changed, left, top, right, bottom); 

     int smallerDimension = (right - left); 
     if (bottom - top < smallerDimension) smallerDimension = bottom -
     top; 

     rectSize = smallerDimension / 10; 
     timeStart = System.currentTimeMillis(); 
} 

我们还存储了开始时间,之后我们将使用它进行快速简单的动画制作。现在,我们准备实施onDraw()方法:

@Override 
protected void onDraw(Canvas canvas) { 
    float angle = (System.currentTimeMillis() - timeStart) / 100.f; 

    canvas.drawColor(BACKGROUND_COLOR); 

    canvas.save(); 
    canvas.translate(canvas.getWidth() / 2, canvas.getHeight() / 2); 

    for (int i = 0; i < 15; i++) { 
        canvas.rotate(angle); 
        canvas.drawRect(-rectSize / 2, -rectSize / 2, rectSize / 2,
        rectSize / 2, paint); 
        canvas.scale(1.2f, 1.2f); 
    } 

    canvas.restore(); 
    invalidate(); 
} 

我们首先根据开始以来经过的时间计算angle。动画应该始终基于时间,而不是基于绘制的帧数。

然后,我们绘制背景,通过调用canvas.save()存储canvas状态,并执行到屏幕中心的平移。我们将从中心而不是左上角开始所有的转换和绘图。

在这个例子中,我们将绘制 15 个矩形,其中每个矩形都将逐渐旋转和缩放。由于变换是在彼此之上应用的,这在一个简单的for()循环中非常容易做到。从-rectSize / 2rectSize / 2而不是0rectSize画矩形很重要;否则,它将从一个角度旋转。

把我们画矩形的代码行改成canvas.drawRect(0, 0, rectSize, rectSize, paint)看看会发生什么。

不过,这种方法还有一个替代方法:我们可以在转换中使用枢轴点。rotate()scale()方法都支持两个额外的float参数,即枢轴点坐标。如果我们看一下scale(float sx, float sy, float px, float py)的源代码实现,可以看到它只是应用了一个翻译,调用了简单的 scale 方法,应用了相反的翻译:

public final void scale(float sx, float sy, float px, float py) { 
    translate(px, py); 
    scale(sx, sy);
    translate(-px, -py); 
} 

使用这种方法,我们可以用另一种方式实现onDraw()方法:

@Override 
protected void onDraw(Canvas canvas) { 
    float angle = (System.currentTimeMillis() - timeStart) / 100.f; 

    canvas.drawColor(BACKGROUND_COLOR); 

    canvas.save(); 
    canvas.translate(canvas.getWidth() / 2, 
                     canvas.getHeight() / 2); 

    for (int i = 0; i < 15; i++) { 
        canvas.rotate(angle, rectSize / 2, rectSize / 2); 
        canvas.drawRect(0, 0, rectSize, rectSize, paint); 
        canvas.scale(1.2f, 1.2f, rectSize / 2, rectSize / 2); 
    } 

    canvas.restore(); 
    invalidate(); 
} 

请参见下面的屏幕截图,了解矩形是如何连接的:

此外,这个完整例子的源代码可以在 GitHub 存储库的Example21-Transformations文件夹中找到。

我们已经看到了一些对矩阵的基本操作,例如scale()rotate(),translate(),但是canvas为我们提供了更多的方法:

  • skew:这应用了倾斜变换。
  • setMatrix:这让我们计算一个变换矩阵,并直接将其设置为我们的canvas
  • concat:这个和前面的情况差不多。我们可以将任何矩阵连接到当前矩阵。

把它们放在一起

到目前为止,我们已经看到了许多不同的绘图原语、裁剪操作和矩阵转换,但最有趣的部分是当我们将它们组合在一起时。为了构建出色的定制视图,我们必须使用许多不同类型的操作和转换。

然而,拥有如此多的可用操作是一把双刃剑。当我们将这种复杂性添加到自定义视图中时,我们必须小心,因为我们很容易损害性能。我们应该检查我们是否正在应用,例如,太多或不必要的剪切操作,或者我们是否没有充分优化,或者我们没有最大限度地重用剪切和转换操作。在这种情况下,我们甚至可以使用来自canvas对象的quickReject()方法来快速丢弃将落在剪辑区域之外的区域。

此外,我们需要跟踪我们在 T2 演出的所有save()restore()。执行额外的restore()方法,不仅意味着我们的代码有问题,而且是一个实际的错误。如果我们必须改变到不同的先前保存的状态,我们可以使用restoreToCount()方法以及在我们进行的呼叫中保存状态号码来保存状态。

正如我们之前提到的,并且将在后面的章节中再次提到,避免在onDraw()方法内部分配内存或创建对象的新实例;特别要记住这句话,如果你认为你必须在onDraw()内部创建一个paint对象的新实例。重用paint对象或初始化它们,例如,在类构造函数上。

摘要

在这一章中,我们已经看到了如何绘制更复杂的图元,转换它们,以及在绘制自定义视图时使用裁剪操作。大多数情况下,这些原语本身并没有给我们太多的价值,但是,我们也看到了许多快速的例子,说明如何将它们组合在一起并创建一些有用的东西。我们没有涵盖所有可能的方法、操作或转换,因为这将是大量的信息,不会有用;这看起来像是在读一本语言词典。为了跟上所有可能的方法和绘图原语,请继续查看开发人员的安卓文档,并了解安卓每个新版本的发行说明,以查看有什么新内容。

在下一章中,我们将看到如何使用 OpenGL ES 将 3D 渲染添加到我们的自定义视图中。