自定义组件开发五 阴影、 渐变和位图运算

时间:2023-02-10 13:04:30

介绍阴影、渐变和位图运算等技术
阴影只是一个狭义的说法,实际上也包括发光等效果;Android 也提供了强大的渐变功能,渐变能为物体带来更真实的质感,比如可以用渐变绘制一颗五子棋或一根金属圆棒;位图运算就更有趣了,Android 为 Bitmap 的运算提供了多达16 种运算方法,获得的结果也不尽相同。
绘图技术是自定义组件和游戏开发的基础

阴影

可以为文字和图形指定阴影(Shader)。在绘图中,有一个叫 layer(层)的概念,默认情况下,我们的文字和图形绘制在主层(main layer)上,其实也可以将内容绘制在新建的 layer 上。实际上阴影就是在 main layer 的下面添加了一个阴影层(shader layer),可以为阴影指定模糊度、偏移量和阴影颜色。

Paint 类定义了一个名为 setShadowLayer 的方法:
public void setShadowLayer(float radius, float dx, float dy, int shadowColor),参数意义如下:
radius:阴影半径
dx:x 方向阴影的偏移量
dy:y 方向阴影的偏移量
shadowColor:阴影的颜色

阴影 layer 显示阴影时 , shader layer 有 两 种 类 型 : View.LAYER_TYPE_SOFTWARE 和 View.LAYER_TYPE_HARDWARE,laye的默认类型为 LAYER_TYPE_HARDWARE,但阴影只能在View.LAYER_TYPE_SOFTWARE 环境下工作 , 所以 , 我们需要调用 View 类 的 public voidsetLayerType(int layerType, Paint paint) 方法为 Paint 对象指定层的类型 :setLayerType(View.LAYER_TYPE_SOFTWARE, paint)。

public class ShaderView extends View {
    public ShaderView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setTextSize(100);
        this.setLayerType(View.LAYER_TYPE_SOFTWARE, paint);
        paint.setShadowLayer(10, 1, 1, Color.RED);

                canvas.drawText("Android 开发", 100, 100, paint);
        paint.setShadowLayer(10, 5, 5, Color.BLUE);
        canvas.drawText("Android 绘图技术", 100, 220, paint);
    }
}

自定义组件开发五 阴影、 渐变和位图运算

渐变

渐变(Gradient)是绘图过程中颜色或位图以特定规律进行变化,能增强物体的质感和审美情趣。生活中的渐变非常多,例如公路两边的电线杆、树木、建筑物的阳台、铁轨的枕木延伸到远方等等,很多的自然理象都充满了渐变的形式特点。Android 同样对渐变进行了完善支持,通过渐变,可以绘制出更加逼真的效果。
Graphics2D 渐变种类有:
线性渐变:LinearGradient
径向渐变:RadialGradient
扫描渐变:SweepGradient
位图渐变:BitmapShader
混合渐变:ComposeShader
其中,线性渐变、径向渐变和扫描渐变属于颜色渐变,指定 2 种或 2 种以上的颜色,根据颜色过渡算法自动计算出中间的过渡颜色,从而形成渐变效果,对于开发人员来说,无需关注中间的渐变颜色。位图渐变则不再是简单的颜色渐变,而是以图片做为贴片有规律的变化,类似于壁纸平铺。混合渐变则能将多种渐变进行组合,实现更加复杂的渐变效果。
如果 A、B 分别代表 2 种不同的颜色,我们将渐变分为三种:
Ø ABAB 型:A、B 两种颜色重复变化,通过 TileMode 类的 REPEAT 常量来表示;
Ø ABBA 型:A、B 两种颜色镜像变化,通过 TileMode 类的 MIRROR 常量来表示;
Ø AABB 型:A、B 两种颜色只出现一次,通过 TileMode 类的 CLAMP 常量来表示。
自定义组件开发五 阴影、 渐变和位图运算
定义渐变时,必须指定一个渐变区域,根据定义的渐变内容和渐变模式填满该区域。每一种渐变都被定义成了一个类,他们都继承自同一个父类——Shader。绘图时,调用 Paint 类的setShader(Shader shader)方法指定一种渐变类型,绘制出来的绘图填充区域都将使用指定的渐变颜色或位图进行填充。本质上来说,前面谈到的填充(Fill)和渐变(Gradient)都大同小异。我们需要重点掌握每个渐变类的构造方法的参数以及意义。
讨论渐变时虽然更多的是指填充区域的渐变,但绘图样式为 STOKE 时,线条同样可以应用渐变效果。

线性渐变 ( LinearGradient )

线性渐变(LinearGradient)根据指定的角度、颜色和模式使用渐变颜色填充绘图区域。我们必须定义两个点(x0,y0)和(x1,y1),渐变的方向与这两个点的连线垂直
自定义组件开发五 阴影、 渐变和位图运算
LinearGradient 的构造方法如下;
public LinearGradient(float x0, float y0, float x1, float y1, int color0, int color1, TileMode tile):本方法用于两种颜色的渐变,各参数意义如下:
x0、y0:用于决定线性方向的第一个点的坐标(x0,y0);
x1、y1:用于决定线性方向的第二个点的坐标(x1,y1);
color0:第一种颜色;
color1:第二种颜色;
tile:渐变模式

假设我们绘制了三个矩形,第一个矩形的渐变区域与矩形恰好一致,第二个矩形的渐变区域大于矩形区域,第三个矩形的渐变区域小于矩形区域,均采用 TileMode 的 CLAMP 模式,代码如下:

public class GradientView extends View {
    private static final int OFFSET = 100;
    private Paint paint;
    public GradientView(Context context, AttributeSet attrs) {
        super(context, attrs);

                paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setStyle(Paint.Style.FILL);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Rect rect1 = new Rect(100, 100, 500, 400);
        LinearGradient lg = new LinearGradient(
                rect1.left, rect1.top, rect1.right, rect1.bottom,
                Color.RED, Color.BLUE, Shader.TileMode.CLAMP);
        paint.setShader(lg);
        canvas.drawRect(rect1, paint);
        //坐标往下移动
        canvas.translate(0, rect1.height() + OFFSET);
        //放大渐变矩形
        Rect rect2 = new Rect(rect1);
        rect2.inset(-100, -100);
        lg = new LinearGradient(
                rect2.left, rect2.top, rect2.right, rect2.bottom,
                Color.RED, Color.BLUE, Shader.TileMode.CLAMP);
        paint.setShader(lg);
        canvas.drawRect(rect1, paint);
        //坐标往下移动
        canvas.translate(0, rect1.height() + OFFSET);
        //缩小渐变矩形
        Rect rect3 = new Rect(rect1);
        rect3.inset(100, 100);
        lg = new LinearGradient(
                rect3.left, rect3.top, rect3.right, rect3.bottom,
                Color.RED, Color.BLUE, Shader.TileMode.CLAMP);
        paint.setShader(lg);
        canvas.drawRect(rect1, paint);
    }
}

自定义组件开发五 阴影、 渐变和位图运算

我们看到,当渐变区域和矩形区域大小不同时,表现出来的效果也有不同。请读者仔细体会。另外,您也可以修改渐变模式,看看其他两种渐变有什么特点。
如果两种颜色无法满足绘图需求,LinearGradient 支持三种或者三种以上颜色的渐变,对应的构造方法如下:
public LinearGradient(float x0, float y0, float x1, float y1, int colors[], float positions[], TileModetile),这是一个功能更加强大的构造方法,我们来看看该构造方法参数的作用:
x0、y0:起始点的坐标
x1、y1:终止点的坐标
colors:多种颜色
positions:颜色的位置(比例)
TileMode:渐变模式
参数 colors 和 positions 都是数组,前者用于指定多种颜色,后者用于指定每种颜色的起始
比例位置。positions 数组中的元素个数与 colors 要相同,且是 0 至 1 的数值,[0,1]是临界区,如果小于 0 则当 0 处理,如果大于 1 则当 1 处理。假如在绘图区域和渐变区域大小相同的情况下,
colors 包含了三种颜色:red、yellow、green,在渐变区域中这三种颜色的起始比例位置为 0、0.3、1,则颜色渐变如图 5-6 所示,比例位置为 0.2、0.5、0.8,则颜色渐变如图所示
自定义组件开发五 阴影、 渐变和位图运算

从图可以看出,0.2 比例位置处设置为 red,0~0.2 之间的颜色也是 red,0.8 比例位置
处为 green,0.8~1 之间的颜色也是 green。当然,这里呈现出来的效果是在绘图区域和渐变区域相同的前提条件下,修改渐变区域的大小或者 TileMode 渐变模式,结果必定又不相同。

径向渐变( RadialGradient )

径向渐变是以指定的点为中心,向四周以渐变颜色进行圆周扩散,和线性渐变一样,支持两种或多种颜色。
自定义组件开发五 阴影、 渐变和位图运算

径向渐变的主要构造方法如下:
public RadialGradient(float x, float y, float radius, int color0, int color1, TileMode tile),该构造方法支持两种颜色,下面是参数的作用:
x、y:中心点坐标
radius:渐变半径
color0:起始颜色
color1:结束颜色
TileMode:渐变模式
public RadialGradient(float x, float y, float radius, int colors[], float positions[], TileModetile),该构造方法支持 3 种或 3 种以上颜色的渐变,各参数的作用如下:x、y:中心点坐标
radius:渐变半径
colors:多种颜色
positions:颜色的位置(比例)
TileMode:渐变模式
接下来我们在 View 上绘制相同大小的正方形和圆,使用一致的径向渐变,模式为TileMode.MIRROR,在大部分时候,镜像模式的渐变效果看起来会更舒服更讨人喜欢。

public class RadialGradientView extends View {

    public RadialGradientView(Context context, AttributeSet attrs) {
    super(context, attrs);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Rect rect = new Rect(100, 100, 500, 500);
        RadialGradient rg = new RadialGradient(
                300, 300, 200, Color.RED, Color.GREEN, Shader.TileMode.MIRROR);
        Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
        p.setShader(rg);
        canvas.drawRect(rect, p);
        canvas.translate(510, 0);
        canvas.drawOval(new RectF(rect), p);
    }
}

上面代码中,我们定义了一个 RadialGradient 对象,中心点坐标为(300, 300),正好是正方形和圆的中心,半径为 200,意味着渐变区域与正方形和圆的大小相同。
自定义组件开发五 阴影、 渐变和位图运算
利用径向渐变,我们可以画出五子棋的棋子,五子棋分为黑色和白色两种不同的棋子,为了画出更逼真的效果,需要考虑棋子的反光效果,光点不能是正中心,而应该向右下角偏移;同时,为棋子加上阴影,棋子似乎跃然纸上。黑色棋子使用黑白两色绘制,白色棋子则使用灰白两色绘制

自定义组件开发五 阴影、 渐变和位图运算

有了棋子,就应该有棋盘,棋盘是一个 m*n 的矩阵,按照一定的规律画好水平线和垂直线
就可以了。

public class FiveChessView extends View {
    private static final int SIZE = 120;//棋子的尺寸
    private static final int OFFSET = 10; //发光点的偏移大小
    private Paint paint;
    public FiveChessView(Context context, AttributeSet attrs) {
        super(context, attrs);
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int width = this.getMeasuredWidth();
        int height = this.getMeasuredHeight();
        int rows = height / SIZE;
        int cols = width / SIZE;
        //画棋盘
        drawChessBoard(canvas, rows, cols);
        //画棋子
        drawChess(canvas, 4, 4, ChessType.BLACK);
        drawChess(canvas, 4, 5, ChessType.BLACK);
        drawChess(canvas, 5, 4, ChessType.WHITE);
        drawChess(canvas, 3, 5, ChessType.WHITE);
    }
    /** * 画棋盘 */
    private void drawChessBoard(Canvas canvas, int rows, int cols) {
        paint.setColor(Color.GRAY);
        paint.setShadowLayer(0, 0, 0, Color.GRAY);//取消阴影
        for(int i = 0; i < rows + 1; i ++){
            canvas.drawLine(0, i * SIZE, cols * SIZE, i * SIZE, paint);
        }
        for(int i = 0; i < cols + 1; i ++){

                    canvas.drawLine(i * SIZE, 0, i * SIZE, rows * SIZE, paint);
        }
    }
    /** * 画棋子 * @param x 行 * @param y 列 * @param chessType 棋子类型 */
    private void drawChess(Canvas canvas, int x, int y, ChessType chessType){
        //定义棋子颜色
        int colorOuter = chessType == ChessType.BLACK ? Color.BLACK: Color.GRAY;
        int colorInner = Color.WHITE;
        //定义渐变,发光点向右下角偏移 OFFSET
        RadialGradient rg = new RadialGradient(x * SIZE + OFFSET, y * SIZE + OFFSET, SIZE / 1.5f,
                colorInner, colorOuter, Shader.TileMode.CLAMP);
        paint.setShader(rg);
        //画棋子
        this.setLayerType(View.LAYER_TYPE_SOFTWARE, paint);
        paint.setShadowLayer(6, 4, 4, Color.parseColor("#AACCCCCC"));//给棋子加阴影
        canvas.drawCircle(x * SIZE, y * SIZE, SIZE / 2, paint);
    }
    enum ChessType{
        BLACK, WHITE
    }
}

扫描渐变(SweepGradient )

Sweep,这是什么单词?英文字典里翻译为“清扫、扫除”,不过,我觉得叫扫描渐变也挺好,不管了,就这么叫吧。
SweepGradient 类似于军事题材电影中的雷达扫描效果,固定圆心,将半径假想为有形并旋转一周而绘制的渐变颜色。SweepGradient 定义了两个主要的构造方法:
public SweepGradient(float cx, float cy, int color0, int color1)
支持两种颜色的扫描渐变,参数的作用如下:
cx、cy:圆点坐标;
color0:起始颜色;
color1:结束颜色。
public SweepGradient(float cx, float cy, int colors[], float positions[])
支持多种颜色的扫描渐变,参数的作用如下:
cx、cy:圆点坐标;
colors:多种颜色;
positions:颜色的位置(比例)。
参数的作用和使用在前面的内容中都有说明,不再赘述,我们通过一个简单的案例说明SweepGradient 的使用方法。

public class SweepGradientView extends View {
public SweepGradientView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
SweepGradient sg =
new SweepGradient(300, 300, Color.GREEN, Color.YELLOW);
paint.setShader(sg);
canvas.drawRect(new Rect(0, 0, 600, 600), paint);
}
}

自定义组件开发五 阴影、 渐变和位图运算
如图所示,渐变的起始颜色为绿色,终止颜色为黄色,和RadialGradient 不同,RadialGradient的渐变是以圆点为中心向四围扩散(想像为涟漪),扩散的距离由参数radius决定,一旦超过该距离,将根据 TileMode 渐变模式重复绘制渐变颜色;SweepGradient 是从 0 度方向开始,以指定点为中心,保存中心不动,将半径旋转一周,不需要指定半径和渐变模式,因为颜色的渐变指向无穷远处,而且只旋转 360 度。

上图所示的效果在绿色和黄色之间没有过渡色,显得特别突兀,可以使用第二个构造方法,定义三种或三种以上的颜色,第一种颜色和最后一种颜色相同就即可。下面的代码演示了这种用法,定义 SweepGradient 对象时,参数 positions 为 null 表示各颜色所占比例平均分配

public class SweepGradient2View extends View {
public SweepGradient2View(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
SweepGradient sg = new SweepGradient(300, 300,
new int[]{Color.GREEN, Color.YELLOW, Color.RED, Color.GREEN}, null);
paint.setShader(sg);
canvas.drawOval(new RectF(0, 0, 600, 600), paint);
}
}

自定义组件开发五 阴影、 渐变和位图运算

位图渐变(BitmapShader)

位图渐变其实就是在绘制的图形中将指定的位图作为背景,如果图形比位图小,则通过渐变模式进行平铺,TileMode.CLAMP 模式不平铺,TileMode.REPEAT 模式表示平铺,TileMode.MIRROR模式也表示平铺,但是交错的位图是彼此的镜像,方向相反。可以同时指定水平和垂直两个方向的渐变模式。

BitmapShader 只有一个构造方法:
public BitmapShader(Bitmap bitmap, TileMode tileX, TileMode tileY),参数如下:
bitmap:位图;
tileX:x 方向的重复模式;
tileY:y 方向的重复模式。
使用 Android Studio 开发 Android 应用程序时,在 res/mipmap 目录下有一张名为 ic_launcher的默认图片,我们编写一个案例用该图片填充到绘制的图形中。

public class BitmapShaderView extends View {

    public BitmapShaderView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Bitmap bmp = BitmapFactory.decodeResource(
                getResources(), R.mipmap.ic_launcher);
        BitmapShader bs = new BitmapShader(bmp,
                Shader.TileMode.REPEAT, Shader.TileMode.MIRROR);
        Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setShader(bs);
        canvas.drawRect(new Rect(0, 0,
                getMeasuredWidth(), getMeasuredHeight()), paint);
    }
}

自定义组件开发五 阴影、 渐变和位图运算
从图中看出,水平方向的渐变模式为 TileMode.REPEAT,所以小机器人重复出现了,而垂直方向的渐变模式为 TileMode.MIRROR,偶数行的小机器人与奇数行是垂直翻转的。

混合渐变( ComposeShader )

混合渐变ComposeShader是将两种不同的渐变通过位图运算后得到的一种更加复杂的渐变。位图运算有 16 种之多,在下一个小节中将介绍,本节我们无法向您详细解释,我们暂且先掌握ComposeShader 的基本使用方法。
ComposeShader 有两个构造方法:
public ComposeShader(Shader shaderA, Shader shaderB, Xfermode mode)
public ComposeShader(Shader shaderA, Shader shaderB, Mode mode)
shaderA 和 shaderB 是两个渐变对象,mode 为位图运算类型,16 种运算模式如图
所示,其实从命名就能大概知道每种位图运算的含义,大家不妨好好研究一下,理解
了自然就记住了。
自定义组件开发五 阴影、 渐变和位图运算

下面的案例是将两种渐变(线性渐变和位图渐变)进行 Mode.SRC_ATOP 运算得到的新的混合渐变,如下图所示,Mode.SRC_ATOP 运算是指显示第一个位图的全部,而第二个位图只显示二者的交集部分并显示在上面。

public class ComposeShaderView extends View{
public ComposeShaderView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//位图渐变
Bitmap bmp = BitmapFactory.decodeResource(
getResources(), R.mipmap.ic_launcher);
BitmapShader bs = new BitmapShader(bmp,
Shader.TileMode.REPEAT, Shader.TileMode.MIRROR);
//线性渐变
LinearGradient lg = new LinearGradient(0, 0, getMeasuredWidth(), 0,
Color.RED, Color.BLUE, Shader.TileMode.CLAMP);
//混合渐变
ComposeShader cs =
new ComposeShader(bs, lg, PorterDuff.Mode.SRC_ATOP);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setShader(cs);
canvas.drawRect(new Rect(0, 0, getMeasuredWidth(), 600), paint);
}
}

代码中,shaderA 为位图渐变,shaderB 为线性渐变,根据 Mode.SRC_ATOP 的运算规则,shaderA 会全部显示(此处为小机器人),shaderB 只显示二者相交部分并位于最上面(TOP),所以,得到的运算效果如图所示。大家可以试试其他的位图运算模式,从结果中找出运算规律。
自定义组件开发五 阴影、 渐变和位图运算

渐变 与 Matrix

渐变类都继承自同一个父类——Shader,该类并不复杂,不过定义了一个非常有用的方法:public void setLocalMatrix(Matrix localM),该方法能和渐变结合,在填充渐变颜色的时候实现移位、旋转、缩放和拉斜的效果。
下面的案例中,我们做了一个旋转的圆,圆内使用 SweepGradient 渐变填充,看起来像一张光盘。首先,我们创建了一个 Matrix 对象 mMatrix,mMatrix 定义了以圆点为中心渐变的旋转效果,注意不是旋转 Canvas 而是旋转 SweepGradient。onDraw()方法中不断调用 invalidate()重绘自己,每重绘一次就旋转 3 度,于是就形成了一个旋转的动画。

public class Sweep extends View {
    private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private float mRotate;
    private Matrix mMatrix = new Matrix();
    private Shader mShader;
    public Sweep(Context context, AttributeSet attrs) {
        super(context, attrs);

                setFocusable(true);
        setFocusableInTouchMode(true);
        float x = 160;
        float y = 100;
        mShader = new SweepGradient(x, y, new int[] { Color.GREEN,
                Color.RED,
                Color.BLUE,
                Color.GREEN }, null);
        mPaint.setShader(mShader);
    }
    @Override protected void onDraw(Canvas canvas) {
        Paint paint = mPaint;
        float x = 160;
        float y = 100;
        canvas.translate(300, 300);
        canvas.drawColor(Color.WHITE);
        mMatrix.setRotate(mRotate, x, y);
        mShader.setLocalMatrix(mMatrix);
        mRotate += 3;
        if (mRotate >= 360) {
            mRotate = 0;
        }
        invalidate();
        canvas.drawCircle(x, y, 380, paint);
    }
}

位图运算

PorterDuffXfermode

位图运算为位图的功能繁衍提供了强大的技术基础,大大增强了位图的可塑性和延伸性,使很多看起来非常复杂的效果和功能都能轻易实现,比如圆形头像、不规则图片、橡皮擦、稀奇古怪的自定义进度条等等。但因为运算模式多,加上大部分人对图形学缺少了解和研究,往往会造成心理上难以逾越的门槛。所以,对本节的案例要多理解、多对比、多分析。

在 Graphics2D 中,类 PorterDuffXfermode 提供对位图运算模式的定义与支持, “ProterDuff”是两个人名组合:Tomas Proter 和 Tom Duff,他们是最早在 SIGGRAPH 上提出图形混合概念的大神级人物。创建 PorterDuffXfermode 对象时,可以提供多达 16 种运算模式,运行官方提供的 APIDemos App,找到 Graphics/Xfermodes,能看到如图 5-17 所示的运行结果。
自定义组件开发五 阴影、 渐变和位图运算

位图运算模式定义在 PorterDuff 类的内部枚举类型 Mode 中,对应了 16 个不同的枚举值:
public static enum Mode {
ADD,
CLEAR,
DARKEN,
DST,
DST_ATOP,
DST_IN,
DST_OUT,
DST_OVER,
LIGHTEN,
MULTIPLY,
OVERLAY,
SCREEN,
SRC,
SRC_ATOP,
SRC_IN,
SRC_OUT,
SRC_OVER,
XOR
}
我们通过下面的表格来理解各运算模式的作用。
自定义组件开发五 阴影、 渐变和位图运算
自定义组件开发五 阴影、 渐变和位图运算
为了实现位图运算,创建 PorterDuffXfermode 对象后,调用 Paint 类的 public Xfermode
setXfermode(Xfermode xfermode) 方 法 , PorterDuffXfermode 是 Xfermode 的 子 类 , 将PorterDuffXfermode 对象作为实际参数传入即可,形如:
paint.setXfermode(new PorterDuffXfermode(Mode.CLEAR));

图层( Layer )

Canvas 在一般的情况下可以看作是一张画布,所有的绘图操作如位图、圆、直线等都在这
张画布上绘制,Canvas 同时还定义了相关属性如 Matrix、颜色等等。但是,倘若需要实现一些相对复杂的绘图操作,比如多层动画、地图(地图可以有多个地图层叠加而成,比如:政区层、道路层、兴趣点层)等,需要 Canvas 提供的图层(layer)支持,缺省情况下可以看作只有一个图层 layer。如果需要按层次来绘图, Canvas 需要创建一些中间层。layer 按照“栈结构”来管理,如图 所示。
自定义组件开发五 阴影、 渐变和位图运算
既然是栈结构,自然存在入栈和出栈两种行为。layer 入栈时,后续的绘图操作都发生在这
个 layer 上,而 layer 出栈时,将把本图层绘制的图像“绘制”到上层或是 Canvas 上,复制 layer到 Canvas 上时,还可以指定 layer 的透明度。

其实在《5.2 阴影》这一小节中,我们向大家介绍了 layer 的概念,我们可以将它翻译成“图
层”,Canvas 默认的图层称之为“主图层(main layer)”,阴影显示在“阴影图层(shader layer)”中,实际上,我们还能自己创建新的图层并入栈,创建图层通过 saveLayer()方法,该方法有下面几个重载的版本:
public int saveLayer(float left, float top, float right, float bottom, Paint paint, int saveFlags)
public int saveLayer(RectF bounds, Paint paint, int saveFlags)
public int saveLayer(RectF bounds, Paint paint)
public int saveLayer(float left, float top, float right, float bottom, Paint paint)
还能通过 saveLayerAlpha()方法为图层(layer)指定透明度:
public int saveLayerAlpha(RectF bounds, int alpha, int saveFlags)
public int saveLayerAlpha(RectF bounds, int alpha)
public int saveLayerAlpha(float left, float top, float right, float bottom, int alpha, int saveFlags)
public int saveLayerAlpha(float left, float top, float right, float bottom, int alpha)
saveLayer()方法中,left、top、right 和 bottom 用于确定图层的位置和大小;参数 paint 官方文档对作用的描述是“This is copied, and is applied to the offscreen when restore() is called.”,可以指定为 null;saveFlags 用于指定保存标识位,虽然也有好几个值可供选择,但官方推荐使用Canvas.ALL_SAVE_FLAG。在 saveLayerAlpha()方法中,参数 alpha 自然就是指定图层的透明度(0~255)了。这两个方法的返回值为一个 int 值,代表当前入栈的 layer 的 id,通过该 id 能明确是哪一个layer 出栈。layer 从栈中弹出(出栈),需要调用 public void restoreToCount(int saveCount)方法,该方法的参数就是 saveLayer()或 saveLayerAlpha()的返回值。

位图运算技巧

要实现位图的混合运算,一方面需要通过 PorterDuffXfermode 指定运算的模式,另一方面还需要借助 layer 进行“离屏缓存”,达到类似 Photoshop 中“遮罩层”的效果。归纳起来,大概有下面几个参考步骤(其实可以有更加简化的步骤):
1) 准备好分别代表 DST 和 SRC 的位图,同时准备第三个位图,该位图用于绘制 DST 和
SRC 运算后的结果;
2) 创建大小合适的图层(layer)并入栈;
3) 先将 DST 位图绘制在第三个位图上;
4) 调用 Paint 的 setXfermode()方法定义位图运算模式;
5) 再将 SRC 位图绘制在第三个位图上;
6) 清除位图运算模式;
7) 图层(layer)出栈
8) 将第三个位图绘制在 View 的 Canvas 上以便显示。
为了让大家能理解 layer 在位图运算中的作用,我们层层递进由浅入深地进行学习。首先,
在不使用 layer 的情况下,按照上面的思路绘制一个圆和一个正方形,圆作为 DST,正方形作为SRC,并执行 Mode.SRC 的运算模式。

public class PorterDuffXferView extends View {
    public PorterDuffXferView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Bitmap dst = Bitmap.createBitmap(300, 300, Bitmap.Config.ARGB_8888);
        Bitmap src = dst.copy(Bitmap.Config.ARGB_8888, true);
        Bitmap b3 = Bitmap.createBitmap(450, 450, Bitmap.Config.ARGB_8888);
        Canvas c1 = new Canvas(dst);
        Canvas c2 = new Canvas(src);
        Canvas c3 = new Canvas(b3);
        Paint p1 = new Paint();
        p1.setColor(Color.GRAY);
        c1.drawCircle(150, 150, 150, p1);
        Paint p2 = new Paint();
        p2.setColor(Color.GREEN);
        c2.drawRect(0, 0, 300, 300, p2);
//定义画笔
        Paint paint = new Paint();
//画圆
        c3.drawBitmap(dst, 0, 0, null);
//定义位图的运算模式
        paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC));
//画正方形
        c3.drawBitmap(src, 150, 150, paint);
//清除运算效果
        paint.setXfermode(null);
//绘制到 Canvas 上
        canvas.drawBitmap(b3, 0, 0, null);
    }
}

运行这段代码,结果却大失所望,从图 中看出,与我们前面介绍的相差甚远,根本不
是我们想要的结果,正确的效果应该如图所示。
自定义组件开发五 阴影、 渐变和位图运算
该问题需要用到图层(layer)来解决。事实上,在 Mode.SRC 运算模式中,如果不愿意看到DST(圆)的非交集部分(左上角的灰色部分),不使用 layer 是解决不了问题的。我们必须在正方形区域定义一个图层,绘图后,图层区域内的部分将会显示,而图层区域外的部分即会消失,示意图可以帮助大家理解前面这段话。
自定义组件开发五 阴影、 渐变和位图运算
虚线部分表示图层(layer),绘图时,先创建图层并入栈,该图层的 left 和top 应该与圆的圆点坐标相同,right 和 bottom 则应该与正方形的 right 和 bottom 相同,接下来依次绘制圆形和正方形,所有绘制都作用在前面创建的图层上,通过 restoreToCount()方法将图层出栈后,显示出来的其实是图层之内的部分,图层之外的部分不会显示了。我们重构一下上面的代码。

public class PorterDuffXferView extends View {
    public PorterDuffXferView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Bitmap dst = Bitmap.createBitmap(300, 300, Bitmap.Config.ARGB_8888);
        Bitmap src = dst.copy(Bitmap.Config.ARGB_8888, true);
        Bitmap b3 = Bitmap.createBitmap(450, 450, Bitmap.Config.ARGB_8888);
        Canvas c1 = new Canvas(dst);
        Canvas c2 = new Canvas(src);
        Canvas c3 = new Canvas(b3);
        Paint p1 = new Paint();
        p1.setColor(Color.GRAY);
        c1.drawCircle(150, 150, 150, p1);
        Paint p2 = new Paint();
        p2.setColor(Color.GREEN);
        c2.drawRect(0, 0, 300, 300, p2);
//定义画笔
        Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
//创建图层
        int layer = c3.saveLayer(150, 150, 450, 450, null, Canvas.ALL_SAVE_FLAG);
//画圆
        c3.drawBitmap(dst, 0, 0, null);
//定义位图的运算模式
        paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC));
//画正方形
        c3.drawBitmap(src, 150, 150, paint);
//清除运算效果
        paint.setXfermode(null);
//恢复
                c3.restoreToCount(layer);
//绘制到 Canvas 上
        canvas.drawBitmap(b3, 0, 0, null);
    }
}

不出所料,效果出来了。该示例只是演示了 Mode.SRC 的用法,另外的 15 种大家最好都能画出来,确保运行结果与图所示一模一样,千万不要想当然的以为代码差不多(这样的想法让您在学习过程中忽略了很多细节,不可取)。再次强调说明一下——图层 layer 的位置和大小要根据实际情况进行设置。
本节我们为大家提供了一个位图运算的基本思路,需要定义 3 个 Bitmap 对象,这是为了方便您理解,但这不是必要条件,具体要创建多少 Bitmap 对象取决于开发人员自己,在下面的案例中,可能会有些微的变化

案例 1 : 圆形 头像

现在很多 App 或网页在显示头像时(如微博),不再使用千遍一律的方形,而是使用更加活泼的圆形。手机或相机拍出来的照片都是矩形,如果要显示为圆形,必须采用技术手段来解决。我们先来分析一下基本的解决思路。将照片作为DST,SRC则是新创建的画了实心圆的位图,其实也是遮罩层。如果既要显示出 SRC 的形状,又要显示出 DST 的内容,则必须使用 DST_IN 运算模式(DST 表示显示 DST 内容,IN 表示只显示相交部分)。我们首先来看一下初级代码实现。

public class CirclePhotoView extends View {
    private Bitmap bmpCat;

    private Bitmap bmpCircleMask;
    private Canvas cvsCircle;
    private Paint paint;
    public CirclePhotoView(Context context, AttributeSet attrs) {
        super(context, attrs);
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        bmpCat = BitmapFactory.decodeResource(getResources(), R.mipmap.cat);
        int minWidth = Math.min(bmpCat.getWidth(), bmpCat.getHeight());
        bmpCircleMask = Bitmap.createBitmap(minWidth, minWidth,
                Bitmap.Config.ARGB_8888);
        cvsCircle = new Canvas(bmpCircleMask);
        int r = minWidth / 2;
        cvsCircle.drawCircle(r, r, r, paint);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawBitmap(bmpCat, 0, 0, null);
        paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
        canvas.drawBitmap(bmpCircleMask, 0, 0, paint);
    }
}

R.mipmap.cat 是一张可爱的小猫图片(其实我更喜欢小狗),加载到 bmpCat 对象中,另外,创建了一个名为 bmpCircleMask 的 Bitmap 对象,并为该对象创建了一个关联的 Canvas,在bmpCircleMask 上画了一个实心圆,实心圆的直径为图片的短的边长。bmpCircleMask 同时也是遮罩层。运行上面的代码,效果如图所示。
自定义组件开发五 阴影、 渐变和位图运算

边!大黑边!!!!遮罩层圆形位图的 4 个角变成了黑色,我们要的应该是透明,要解决这个问题,必须使用图层(layer)。创建一个图层,大小和 bmpCircleMask 一样,将 DST(小猫)和SRC(实心圆)都绘制在该图层上,奇迹立刻出现了。

public class CirclePhotoView extends View {
//省略部分代码
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int w = bmpCircleMask.getWidth();
int layer = canvas.saveLayer(0, 0, w, w, null, Canvas.ALL_SAVE_FLAG);
canvas.drawBitmap(bmpCat, 0, 0, null);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
canvas.drawBitmap(bmpCircleMask, 0, 0, paint);
canvas.restoreToCount(layer);
}
}

自定义组件开发五 阴影、 渐变和位图运算
利用这个方法,我们其实可以实现任意形状的图片,本质上,遮罩层是什么形状,图片就会显示什么形状,如果读者熟悉 PhotoShop 中的蒙版,其实 layer 和蒙版概念基本相同。如图 所示就是将小猫和 Android 机器人通过 DST_IN 位图运算后得到的效果。需要注意的是,遮罩层最好使用 png 图片,这种格式的图片才支持透明像素,才能有真正的不规则照片。
自定义组件开发五 阴影、 渐变和位图运算


public class AnomalousPhotoView extends View {
    private Bitmap bmpCat;
    private Bitmap bmpMask;
    private Paint paint;

    private static final int OFFSET = 100;
    public AnomalousPhotoView(Context context, AttributeSet attrs) {
        super(context, attrs);
        bmpCat = BitmapFactory.decodeResource(getResources(), R.mipmap.cat);
        bmpMask = BitmapFactory.decodeResource(
                getResources(), R.mipmap.ic_launcher);
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int id = canvas.saveLayer(OFFSET, OFFSET, bmpMask.getWidth() + OFFSET,
                bmpMask.getHeight() + OFFSET,
                null, Canvas.ALL_SAVE_FLAG);
        canvas.drawBitmap(bmpCat, 0, 0, null);
        paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
        canvas.drawBitmap(bmpMask, OFFSET, OFFSET, paint);
        canvas.restoreToCount(id);
    }
}

案例 2 : 刮刮乐

刮刮乐是个很好玩的小应用,娱乐性很强,靠的是运气。在图片上随机产生一个中奖信息,蒙上一层颜色,用户使用手指在屏幕上涂刮,颜色即被擦除,最后看到中奖信息。
自定义组件开发五 阴影、 渐变和位图运算
从技术实现上来说,我们发现刮刮乐有两个图层,一个是不会变化的中奖信息图层,一个是蒙上了一层灰色的图层。当用户手指在屏幕上涂抹时,我们需要将灰色抹掉。中奖信息其实并不需要改变,换句话说,涂抹时,无需重绘中奖信息。
所以,对于中奖信息位图来说,应该采用更简单的实现,可以在图片上写上中奖信息后作为View 的背景(Background),当手指在屏幕上涂抹时就不需要考虑他的重绘问题了。我们再创建一个 Bitmap 对象,初始蒙上一层灰色,手指在屏幕上移动时同时绘制线条,将线条与灰色做Mode.CLEAR 运算,相交的部分即被清除,变成了透明效果,于是我们就能看到背景了。
实现刮刮乐需要经历下面两个步骤:
1) 绘制背景
背景需要一张图片,资源中的图片不能编辑,所以必须调用 Bitmap 的 copy()方法复制
一张同样的图片并设置可编辑标识,画上随机生成的中奖信息,调用 View 类的 public
void setBackground(Drawable background)方法设置为背景(该方法有兼容性问题)。
2) 在屏幕上绘制线条
定义一个 Bitmap 对象,初始画上一层灰色,当手指在屏幕上移动时,不断绘制曲线,
曲线和灰色做 Mode.CLEAR 运算,实现清除的效果。
下面一起来看源码,源码比较长,相关的技术点在前面的章节内容中都已涉及,在此不再赘述。运行效果如图所示。

public class GuaGuaLeView extends View {
    private Random rnd;
    private Paint paint;
    private Paint clearPaint;
    private static final String[] PRIZE = {
            "恭喜,您中了一等奖,奖金 1 亿元",
            "恭喜,您中了二等奖,奖金 5000 万元",
            "恭喜,您中了三等奖,奖金 100 元",
            "很遗憾,您没有中奖,继续加油哦"
    };
    /**涂抹的粗细*/
    private static final int FINGER = 50;
    /**缓冲区*/
    private Bitmap bmpBuffer;
    /**缓冲区画布*/
    private Canvas cvsBuffer;
    private int curX, curY;
    public GuaGuaLeView(Context context, AttributeSet attrs) {
        super(context, attrs);
        rnd = new Random();
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setTextSize(100);
        paint.setColor(Color.WHITE);
        clearPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));

                clearPaint.setStrokeJoin(Paint.Join.ROUND);
        clearPaint.setStrokeCap(Paint.Cap.ROUND);
        clearPaint.setStrokeWidth(FINGER);
//画背景
        drawBackground();
    }
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
//初始化缓冲区
        bmpBuffer = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
        cvsBuffer = new Canvas(bmpBuffer);
//为缓冲区蒙上一灰色
        cvsBuffer.drawColor(Color.parseColor("#FF808080"));
    }
    /** * 随机生成中奖信息 * @return 数组 PRIZE 的索引 */
    private int getPrizeIndex(){
        return rnd.nextInt(PRIZE.length);
    }
    /** * 绘制背景,背景包括背景图片和中奖信息 */
    private void drawBackground(){
        Bitmap bmpBackground = BitmapFactory.decodeResource(
                getResources(), R.mipmap.konglong);
//从资源中读取的 bmpBackground 不可以修改,复制出一张可以修改的图片
        Bitmap bmpBackgroundMutable =
                bmpBackground.copy(Bitmap.Config.ARGB_8888, true);
//在图片上画上中奖信息
        Canvas cvsBackground = new Canvas(bmpBackgroundMutable);

//计算出文字所占的区域,将文字放在正中间
                String text = PRIZE[getPrizeIndex()];
        Rect rect = new Rect();
        paint.getTextBounds(text, 0, text.length(), rect);
        int x = (bmpBackgroundMutable.getWidth() - rect.width()) / 2;
        int y = (bmpBackgroundMutable.getHeight() - rect.height()) / 2;
        this.setLayerType(View.LAYER_TYPE_SOFTWARE, paint);
        paint.setShadowLayer(10, 0, 0, Color.GREEN);
        cvsBackground.drawText(text, x, y, paint);
        paint.setShadowLayer(0, 0, 0, Color.YELLOW);
//画背景
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
            this.setBackground(new BitmapDrawable(
                    getResources(), bmpBackgroundMutable));
        }else{
            this.setBackgroundDrawable(
                    new BitmapDrawable(bmpBackgroundMutable));
        }
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawBitmap(bmpBuffer, 0, 0, paint);
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                curX = x;
                curY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                    cvsBuffer.drawLine(curX, curY, x, y, clearPaint);
                invalidate();
                curX = x;
                curY = y;
                break;
            case MotionEvent.ACTION_UP:
                invalidate();
                break;
            default:
                break;
        }
        return true;
    }
}

练习作业

1、请编写一个双人对战的五子棋游戏,要求:
A. 黑白双方对战;
B. 可以选择哪一方先下;
C. 用户可以定制棋子大小和棋盘大小;
D. 用虚线框标明最后下的棋子
E. 判断胜负。
2、请将图片显示成如图 5-25 所示的形状。
提示:您可以到 http://iconfont.cn/repositories/10 随便下载一张图片
自定义组件开发五 阴影、 渐变和位图运算
3、请绘制一张光盘,如图 所示,并让该光盘不断旋转。
提示:可以参考 Android API Demos 中的 Graphics/Sweep 示例。
自定义组件开发五 阴影、 渐变和位图运算

谢谢认真观读本文的每一位小伙伴,衷心欢迎小伙伴给我指出文中的错误,也欢迎小伙伴与我交流学习。
欢迎爱学习的小伙伴加群一起进步:230274309