Android绘图(四)阴影、渐变和位图运算处理

本章将向您介绍阴影、渐变和位图运算等技术。阴影只是一个狭义的说法,实际上也包括发光等效果;Android 也供了强大的渐变功能,渐变能为物体带来更真实的质感,比如可以用渐变绘制一颗五子棋或一根金属圆棒;位图运算就更有趣了,Android 为 Bitmap 的运算供了多达16 种运算方法,获得的结果也不尽相同。不过,主要还是在于灵活应用。

一、阴影

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

/**
 * 设置阴影
 * @param radius 阴影半径
 * @param dx  x方向阴影的偏移量
 * @param dy  y方向阴影的偏移量
 * @param shadowColor 阴影的颜色
 */
public void setShadowLayer(float radius, float dx, float dy, int shadowColor);

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

public void setLayerType(int layerType, Paint paint)

1.1 案例-为文字添加阴影和发光效果

// 自定义View,后面的案例都是使用此View进行修改
public class MyView extends View {
    public MyView(Context context) {
        this(context, null);
    }

    public MyView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public MyView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private Paint paint;

    private void init() {
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setAntiAlias(true);
        // 启用阴影
        this.setLayerType(View.LAYER_TYPE_SOFTWARE, paint); // 参数2可以传null, 否则使用同一个paint的地方都会关联LAYER_TYPE_SOFTWARE
        paint.setTextSize(80);
        // 指定阴影的半径为10px,x和y的偏移量为1px,阴影颜色为红色
        paint.setShadowLayer(10, 1, 1, Color.RED);
    }

    @Override
    public void onDraw(Canvas canvas) {
        // 绘制文本
        canvas.drawText("Android 开发", 50, 100, paint);
        // 修改阴影效果
        paint.setShadowLayer(10, 5, 5, Color.BLUE);
        // 再次绘制文本
        canvas.drawText("Android 绘图技术", 50, 220, paint);
    }
}

效果图:
在这里插入图片描述
上面代码中 , 我们绘制了两行文字, 第一 行 “Android 开 发 ”为红色发光效果,setShadowLayer(10, 1, 1, Color.RED)语句定义了一个模糊半径为 10、x 方向和 y 方向偏移量都为 1的红色阴影,当偏移量足够小时,我们看到的其实是发光效果。
paint.setShadowLayer(10, 5, 5,Color.BLUE)语句定义了一个模糊半径为 10、x 方向和 y 方向偏移量为 5 的蓝色阴影。
注意阴影必须在 LAYER_TYPE_SOFTWARE 模式下才能工作。

最后要强调的是,一旦定义了阴影层,接下来的所有绘制都会带阴影效果了(假设使用了同一个paint),如果想取消阴影,请将 setShadowLayer()方法的 radius 参数设置为 0,并且在调用View的setLayerType方法的第二个参数Paint传入null而不是全局的Paint; 或者调用paint的clearShadowLayer方法也行。

二、 渐变

渐变(Gradient)是绘图过程中颜色或位图以特定规律进行变化,能增强物体的质感和审美情趣。生活中的渐变非常多,例如公路两边的电线杆、树木、建筑物的阳台、铁轨的枕木延伸到远方等等,很多的自然理象都充满了渐变的形式特点。Android 同样对渐变进行了完善支持,通过渐变,可以绘制出更加逼真的效果。
Graphics2D 渐变种类有:

  1. 线性渐变:LinearGradient
  2. 径向渐变:RadialGradient
  3. 扫描渐变:SweepGradient
  4. 位图渐变:BitmapShader
  5. 混合渐变:ComposeShader

其中,线性渐变、径向渐变和扫描渐变属于颜色渐变,指定 2 种或 2 种以上的颜色,根据颜色过渡算法自动计算出中间的过渡颜色,从而形成渐变效果,对于开发人员来说,无需关注中间的渐变颜色。位图渐变则不再是简单的颜色渐变,而是以图片做为贴片有规律的变化,类似于壁纸平铺。混合渐变则能将多种渐变进行组合,实现更加复杂的渐变效果。

3种渐变模式
如果 A、B 分别代表 2 种不同的颜色,我们将渐变分为三种:
AABB 型:A、B 两种颜色只出现一次,通过 TileMode 类的 CLAMP 常量来表示, 效果就是:如果超出规定的区域就重复边缘的效果;
ABBA 型:A、B 两种颜色镜像变化,通过 TileMode 类的 MIRROR 常量来表示,效果就是:以镜像的方式显示;
ABAB 型:A、B 两种颜色重复变化,通过 TileMode 类的 REPEAT 常量来表示,效果就是:在竖直和水平方向上重复。
这三种不同的TileMode的渐变模式的效果如下图所示:
在这里插入图片描述
定义渐变时,必须指定一个渐变区域,根据定义的渐变内容和渐变模式填满该区域。每一种渐变都被定义成了一个类,他们都继承自同一个父类—Shader。绘图时,调用 Paint 类的setShader(Shader shader)方法指定一种渐变类型,绘制出来的绘图填充区域都将使用指定的渐变颜色或位图进行填充。

2.1 线性渐变(LinearGradient)

线性渐变(LinearGradient)根据指定的角度、颜色和模式使用渐变颜色填充绘图区域。我们必须定义两个点(x0,y0)和(x1,y1),渐变方向就是从起点指向终点,如下图所示:
在这里插入图片描述
如何通过坐标设置渐变方向?
通过坐标可以轻松实现,渐变方向的控制:

(0,0)->(0,400) // 从上到下
(0,400)->(0,0) //  从下到上
(0,0->(getMeasuredWidth(),0) // 表示从左到右
(getMeasuredWidth(),0)->(0,0) // 表示从右到左
(0,0-> (getMeasuredWidth(),getMeasuredHeight()) // 斜角,从左上角到右下角
(0,getMeasuredHeight()-> (getMeasuredWidth(),0) // 斜角,从左下角到右上角

LinearGradient 的构造方法如下:

/**
 * 构造线型渐变
 * @param x0 用于决定线性方向的第一个点的坐标(x0,y0)
 * @param y0
 * @param x1 用于决定线性方向的第二个点的坐标(x1,y1)
 * @param y1
 * @param color0 第一种颜色
 * @param color1 第二种颜色
 * @param tile 渐变模式
 */
public LinearGradient(float x0, float y0, float x1, float y1, int color0, int color1, TileMode tile) {

}

假设我们绘制了三个矩形,第一个矩形的渐变区域与矩形恰好一致,第二个矩形的渐变区域大于矩形区域,第三个矩形的渐变区域小于矩形区域,均采用 TileMode 的 CLAMP 模式,渐变方向都是左上角->右下角, 代码如下:

private Paint paint;
private static final int OFFSET = 100;
private  Rect rect;
// 三种不同的渐变效果
private LinearGradient lg1;
private LinearGradient lg2;
private LinearGradient lg3;

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setAntiAlias(true);

    // 定义矩形区域
    rect = new Rect(100, 100, 400, 300);
    // 定义和矩形区域大小相等的渐变
    lg1 = new LinearGradient(rect.left, rect.top, rect.right, rect.bottom,
            Color.RED, Color.BLUE, Shader.TileMode.CLAMP);

    // 放大渐变矩形
    Rect rect2 = new Rect(rect);
    // 如果 dx 和 dy 是负的,则两边向外移动,使矩形更宽
    rect2.inset(-100, -100);
    lg2 = new LinearGradient(
            rect2.left, rect2.top, rect2.right, rect2.bottom,
            Color.RED, Color.BLUE, Shader.TileMode.CLAMP);

    // 缩小渐变矩形
    Rect rect3 = new Rect(rect);
    // 如果 dx 和 dy 是正的,则两边向内移动,使矩形更窄
    rect3.inset(100, 100);
    lg3 = new LinearGradient(
            rect2.left, rect2.top, rect2.right, rect2.bottom,
            Color.RED, Color.BLUE, Shader.TileMode.CLAMP);
}

@Override
public void onDraw(Canvas canvas) {
    // 绘制第一个矩型,渐变区域和矩型区域一致
    paint.setShader(lg1);
    canvas.drawRect(rect, paint);

    //坐标往下移动
    canvas.translate(0, rect.height() + OFFSET);
    paint.setShader(lg2);
    // 绘制第二个矩形,渐变区域大于矩形区域
    canvas.drawRect(rect, paint);

    //坐标往下移动
    canvas.translate(0, rect.height() + OFFSET);
    paint.setShader(lg3);
    // 绘制第三个矩形,渐变区域小于矩形区域
    canvas.drawRect(rect, paint);

}

效果图:
在这里插入图片描述
改成边框模式的效果:
在这里插入图片描述
如果两种颜色无法满足绘图需求,LinearGradient 支持三种或者三种以上颜色的渐变,对应的构造方法如下:

/**
 * 多颜色的线性渐变
 * @param x0 起始点的坐标
 * @param y0
 * @param x1 终止点的坐标
 * @param y1
 * @param colors 多种颜色
 * @param positions 颜色的位置(比例)
 * @param tile 渐变模式
 */
public LinearGradient(float x0, float y0, float x1, float y1, int colors[], float positions[], TileMode tile) {

}

参数 colors 和 positions 都是数组,前者用于指定多种颜色,后者用于指定每种颜色的起始比例位置。positions 数组中的元素个数与 colors 要相同,且是 0 至 1 的数值,[0,1]是临界区,如 果小于 0 则当 0 处理,如果大于 1 则当 1 处理。假如在绘图区域和渐变区域大小相同的情况下,colors 包含了三种颜色:red、yellow、green,在渐变区域中这三种颜色的起始比例位置为 0、0.3、 1,则颜色渐变如下图所示:
在这里插入图片描述
如果在比例位置为 0.2、0.5、0.8,则颜色渐变如下图所示:
在这里插入图片描述

2.1.1 案例- 实现圆角矩形环形渐变

效果图如下:
在这里插入图片描述
可以看到渐变颜色有4种, 并且渐变方向是左下角->右上角等分比例, 这里实现圆角矩形环用到了path的图形运算来实现,具体是通过大的圆角矩形减去小的圆角矩形, 代码如下:

class GradientCornerView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    def: Int = 0,
) : View(
    context, attrs, def
) {
    // 外圆角
    private var outRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20f, resources.displayMetrics)

    // 内圆角
    private var innerRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 18f, resources.displayMetrics)
    private var borderWidth = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4f, resources.displayMetrics)
    private val outPath = Path()

    // 是否立即显示
    private var showBorder = true
        set(value) {
            field = value
            invalidate()
        }
    // 设置画笔
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.FILL
        strokeJoin = Paint.Join.ROUND
        strokeCap = Paint.Cap.ROUND
        strokeJoin = Paint.Join.ROUND
        strokeCap = Paint.Cap.ROUND
    }

    private val rect = RectF()


    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        // 设置区域区域
        rect.set(0f, 0f, w.toFloat(), h.toFloat())
        // 添加渐变从左下角->右上角渐变
        paint.shader = LinearGradient(
            rect.left, rect.bottom, rect.right, rect.top,
            // 支持多个颜色值
            intArrayOf(
                Color.parseColor("#00A6FF"),
                Color.parseColor("#4BC2C2"),
                Color.parseColor("#18D19B"),
                Color.parseColor("#AFE646")
            ), floatArrayOf(
                // 设置等分比例
                0f, 0.25f, 0.75f, 1f
            ), Shader.TileMode.CLAMP
        )

        val innerPath = Path()
        // 添加外圆角矩形
        outPath.addRoundRect(rect, outRadius, outRadius, Path.Direction.CCW)
        // 缩小矩形区域
        rect.inset(borderWidth, borderWidth)
        // 添加内圆角矩形
        innerPath.addRoundRect(rect, innerRadius, innerRadius, Path.Direction.CCW)
        // 通过path的op操作, 用外圆角矩形区域-内圆角矩形区域 = 圆角矩形环区域
        outPath.op(innerPath, Path.Op.DIFFERENCE)
    }

    override fun onDraw(canvas: Canvas) {
        if (showBorder) {
            canvas.drawPath(outPath, paint)
        }
    }
}

2.2 径向渐变(RadialGradient)

径向渐变是以指定的点为中心,向四周以渐变颜色进行圆周扩散,和线性渐变一样,支持两种或多种颜色。径向渐变的示意图如图:
在这里插入图片描述
径向渐变的主要构造方法如下:

/**
 * 径向渐变
 * @param x 中心点x坐标
 * @param y 中心点y坐标
 * @param radius 渐变半径
 * @param color0 起始颜色
 * @param color1 结束颜色
 * @param tile 渐变模式
 */
public RadialGradient(float x, float y, float radius, int color0, int color1, TileMode tile) {

}

支持 3 种或 3 种以上颜色的渐变的构造方法如下:

/**
 *
 * @param x 中心点x坐标
 * @param y 中心点y坐标
 * @param radius 渐变半径
 * @param colors 多种颜色
 * @param positions 颜色的位置(比例)
 * @param tile 渐变模式
 */
public RadialGradient(float x, float y, float radius, int colors[], float positions[], TileMode tile) {

}

接下来我们在 View 上绘制相同大小的正方形和圆,使用一致的径向渐变,模式为TileMode.MIRROR,在大部分时候,镜像模式的渐变效果看起来会更舒服更讨人喜欢。

private Paint paint;
private Rect rect;
private RectF oval;

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setAntiAlias(true);

     // 定义矩形区域
    rect = new Rect(100, 100, 500, 500);
    // 定义圆的区域
    oval = new RectF(rect);
    RadialGradient rg = new RadialGradient(300, 300, 200, Color.RED, Color.GREEN, Shader.TileMode.MIRROR);
    paint.setShader(rg);
}

@Override
public void onDraw(Canvas canvas) {
    // 绘制矩形
    canvas.drawRect(rect, paint);
    // 平移
    canvas.translate(0, 500);
    // 绘制圆,虽然用的是drawOval,但是传入的矩形是正方型,所以绘制出来的就是正圆
    canvas.drawOval(oval, paint);

}

上面代码中,我们定义了一个 RadialGradient 对象,中心点坐标为(300, 300),正好是正方形和圆的中心,渐变半径为 200,意味着渐变区域与正方形和圆的大小相同。效果图:
在这里插入图片描述

2.2.1 案例-使用径向渐变绘制棋盘的棋子

利用径向渐变,我们可以画出五子棋的棋子,五子棋分为黑色和白色两种不同的棋子,为了画出更逼真的效果,需要考虑棋子的反光效果,光点不能是正中心,而应该向右下角偏移;同时,为棋子加上阴影,棋子似乎跃然纸上。黑色棋子使用黑白两色绘制,白色棋子则使用灰白两色绘制,效果如下图所示。
在这里插入图片描述
有了棋子,就应该有棋盘,棋盘是一个 m*n 的矩阵,按照一定的规律画好水平线和垂直线就可以了


private Paint paint;
private float chessSize = 100; // 棋子的大小

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setStyle(Paint.Style.FILL);
    paint.setColor(Color.GRAY);
}

enum ChessColor {
    WHITE, BLACK
}

@Override
protected void onDraw(Canvas canvas) {
    int row = (int) (getHeight() / chessSize);
    int column = (int) (getWidth() / chessSize);
    // 棋盘居中
    int offsetX = (int) ((getWidth() - column * chessSize) * 0.5f);
    int offsetY = (int) ((getHeight() - row * chessSize) * 0.5f);
    canvas.translate(offsetX, offsetY);
    // 绘制棋盘
    drawChessBox(canvas, row, column);

    // 绘制棋子
    drawChess(canvas, 3, 5, ChessColor.BLACK);
    drawChess(canvas, 4, 6, ChessColor.WHITE);
    drawChess(canvas, 5, 5, ChessColor.BLACK);
    drawChess(canvas, 5, 6, ChessColor.WHITE);
}


// 绘制棋盘
private void drawChessBox(Canvas canvas, int row, int column) {
    canvas.save();
    // 取消阴影
    paint.setShadowLayer(0, 0, 0, Color.GRAY);
    // 绘制行
    for (int i = 0; i <= row; i++) {
        // 起点x=0,起点y=终点y=chessSize*i, 终点x=column*chessSize
        canvas.drawLine(0, chessSize * i, column * chessSize, chessSize * i, paint);
    }
    // 绘制列
    for (int i = 0; i <= column; i++) {
        // 起点x=终点x=i*chessSize, 起点y=0,终点y=row* chessSize
        canvas.drawLine(i * chessSize, 0, i * chessSize, row * chessSize, paint);
    }
    canvas.restore();
}

// 绘制棋子
private void drawChess(Canvas canvas, int row, int column, ChessColor chessColor) {
    canvas.save();
    // 渐变颜色从白到黑,或者白到灰
    int startColor = Color.WHITE;
    int endColor = chessColor == ChessColor.BLACK ? Color.BLACK : Color.GRAY;
    int centerX = (int) (row * chessSize);
    int centerY = (int) (column * chessSize);
    int radius = (int) (chessSize / 2);
    int offset = 10;  //发光点的偏移大小
    // 定义径向渐变,发光点向右下角偏移 OFFSET
    RadialGradient rg = new RadialGradient(centerX + offset, centerY + offset, radius , startColor, endColor, Shader.TileMode.CLAMP);
    paint.setShader(rg);
    // 设置阴影
    this.setLayerType(LAYER_TYPE_SOFTWARE, null);
    paint.setShadowLayer(6, 4, 4, Color.parseColor("#AACCCCCC"));
    // 绘制棋子
    canvas.drawCircle(centerX, centerY, radius, paint);
    canvas.restore();
}

效果图:
在这里插入图片描述

2.3 扫描渐变(SweepGradient)

SweepGradient 类似于军事题材电影中的雷达扫描效果,固定圆心,将半径假想为有形并旋转一周而绘制的渐变颜色。SweepGradient 定义了两个主要的构造方法:

/**
 * 扫描渐变
 * @param cx 圆点坐标
 * @param cy 圆点坐标
 * @param color0 起始颜色
 * @param color1 结束颜色
 */
public SweepGradient(float cx, float cy, int color0, int color1) {
}

/**
 * 支持多种颜色的扫描渐变
 *
 * @param cx        圆点坐标
 * @param cy        圆点坐标
 * @param colors    多种颜色
 * @param positions 颜色的位置(比例)
 */
public SweepGradient(float cx, float cy, int colors[], float positions[]) {

}

下面通过一个简单的案例说明SweepGradient 的使用方法

private Paint paint;
private Rect rect;

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setStyle(Paint.Style.FILL);
    // 扫描渐变
    SweepGradient sg = new SweepGradient(300, 300, Color.GREEN, Color.YELLOW);
    paint.setShader(sg);
    // 绘制的矩形区域
    rect = new Rect(0, 0, 600, 600);
}


@Override
protected void onDraw(Canvas canvas) {
    // 绘制矩形
    canvas.drawRect(rect, paint);
}

如下图所示,渐变的起始颜色为绿色,终止颜色为黄色,和 RadialGradient 不同,RadialGradient 的渐变是以圆点为中心向四围扩散(想像为涟漪),扩散的距离由参数 radius 决定,一旦超过该距离,将根据 TileMode 渐变模式重复绘制渐变颜色;SweepGradient 是从 0 度方向开始,以指定点为中心,保存中心不动,将半径旋转一周,不需要指定半径和渐变模式,因为颜色的渐变指向无穷远处,而且只旋转 360 度。
在这里插入图片描述
上图所示的效果在绿色和黄色之间没有过渡色,显得特别突兀,可以使用第二个构造方法,定义三种或三种以上的颜色,第一种颜色和最后一种颜色相同就即可。下面的代码演示了这种用法,定义 SweepGradient 对象时,参数 positions 为 null 表示各颜色所占比例平均分配,代码修改如下:

// SweepGradient sg = new SweepGradient(300, 300, Color.GREEN, Color.YELLOW);
SweepGradient sg = new SweepGradient(300, 300, new int[]{Color.GREEN, Color.YELLOW, Color.RED, Color.GRAY}, null);

效果图:
在这里插入图片描述

2.4 位图渐变(BitmapShader)

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

/**
 * 位图渐变
 * @param bitmap 位图
 * @param tileX x 方向的重复模式
 * @param tileY y 方向的重复模式
 */
public BitmapShader(Bitmap bitmap, TileMode tileX, TileMode tileY) {

}

下面编写一个案例用该图片填充到绘制的图形中

private Paint paint;
private Rect rect;
private Bitmap source;

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    source = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher);
    // 位图渐变,模式采用水平方向重复,垂直方向镜像
    BitmapShader bitmapShader = new BitmapShader(source, Shader.TileMode.REPEAT, Shader.TileMode.MIRROR);
    paint.setShader(bitmapShader);
    // 绘制的矩形区域为原图的宽度4倍,高度4倍
    rect = new Rect(0, 0, source.getWidth() * 4, source.getHeight() * 4);
}


@Override
protected void onDraw(Canvas canvas) {
    // 绘制矩形,由于paint已经设置了位图渐变了,所以矩形的填充是以位图填充的
    canvas.drawRect(rect, paint);
}

效果图:
可以看到填充顺序是先填充y轴的(mirror模式) 然后再填充水平方向(repeat模式)
在这里插入图片描述

2.4.1 案例-绘制圆形头像

以glide的Transform为例

  class CircleAvatarTransform(val borderColor: Int = 0, val borderWidth: Float = 0f) : BitmapTransformation() {
    // 头像画笔
    private val mCirclePaint: Paint = Paint().apply {
        isFilterBitmap = true
        isAntiAlias = true
        isDither = true
        style = Paint.Style.FILL

    }
    // 描边画笔
    private val mStrokePaint: Paint = Paint().apply {
        isAntiAlias = true
        isDither = true
        style = Paint.Style.STROKE
        color = borderColor
        strokeWidth = borderWidth
        strokeJoin = Paint.Join.ROUND
        strokeCap = Paint.Cap.ROUND
    }

    override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
        val size: Int = toTransform.width.coerceAtMost(toTransform.height)
        val x: Int = (toTransform.width - size) / 2
        val y: Int = (toTransform.height - size) / 2
        // 取中心图形
        val squared = Bitmap.createBitmap(toTransform, x, y, size, size)
        var result: Bitmap? = pool[size, size, Bitmap.Config.ARGB_8888]
        if (result == null) {
            result = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
        }
        Canvas(result!!).apply {
            // 关键代码
            mCirclePaint.shader = BitmapShader(squared, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
            val r = size.toFloat() / 2.0f
            // 绘制头像
            drawCircle(r, r, r, mCirclePaint)

            // 绘制描边
            if (borderColor != 0 && borderWidth != 0f) {
                drawCircle(r, r, r - borderWidth / 2f, mStrokePaint)
            }
        }
        return result
    }

    private fun getId(): String {
        return this.javaClass.name
    }
    
    // 去除重用
    override fun updateDiskCacheKey(messageDigest: MessageDigest) {
        messageDigest.update(this.getId().toByteArray(CHARSET))
    
        val borderResData: ByteArray = ByteBuffer.allocate(4)
            .putInt(borderColor)
            .array()
        messageDigest.update(borderResData)
    
        val widthResData: ByteArray = ByteBuffer.allocate(4)
            .putFloat(borderWidth)
            .array()
        messageDigest.update(widthResData)
}

    override fun hashCode(): Int {
        return Util.hashCode(borderColor,Util.hashCode(borderWidth))
    }
    
    override fun equals(other: Any?): Boolean {
        if (other is ZhiyaCircleAvatarTransform) {
            return borderColor == other.borderColor && borderWidth == other.borderWidth
        }
        return false
    }
}

需要注意的是BitmapShader的填充起点永远都是view的顶点左上角(0,0)的位置开始填充的,终点就是用来填充的Bitmap的右下角,这个和绘制的几何区域无关,绘制的几何区域只会影响我们所看到的区域的形状和位置以及大小,而没有绘制的区域其实已经存在的只是没有显示出来而已。假设用来填充的bitmap的大小是200200,而我们绘制几何图形的区域是100100,那么最终可见的区域就是bitmap的1/4的区域且位于其左上角。

2.4.2 案例-实现地图望远镜效果

在这里插入图片描述
代码如下:


/**
 * @Author: chenyousheng
 * @Description: 望远镜效果
 */
class TelescopeView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    def: Int = 0

) : View(context, attrs, def) {
    private val mPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    private var mBackgroundBmp: Bitmap? = null

    private var mDy = -1f
    private var mDx = -1f

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                mDx = event.x
                mDy = event.y
                postInvalidate()
                return true
            }
            MotionEvent.ACTION_MOVE -> {
                mDx = event.x
                mDy = event.y
            }
            else -> {
                mDx = -1f
                mDy = -1f
            }
        }
        postInvalidate()
        return super.onTouchEvent(event)
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        if (mBackgroundBmp == null) {
            // 创建背景图,充满整个View
            mBackgroundBmp = Bitmap.createScaledBitmap(
                BitmapFactory.decodeResource(resources, R.drawable.ic_bg),
                width, height, true
            )
            // 关联到shader中
            mPaint.shader = BitmapShader(
                mBackgroundBmp,
                Shader.TileMode.REPEAT,
                Shader.TileMode.REPEAT
            )
        }
        // 绘制圆形望远镜, 这里关联的mPaint是设置了BitmapShader的
        if (mDx != -1f && mDy != -1f) {
            canvas?.drawCircle(mDx, mDy, 150f, mPaint)
        }
    }
}

2.5 混合渐变(ComposeShader)

混合渐变ComposeShader是将两种不同的渐变通过位图运算后得到的一种更加复杂的渐变。位图运算有 16 种之多,在下一个小节中将介绍,本节我们无法向您详细解释,我们暂且先掌握ComposeShader 的基本使用方法。ComposeShader 有两个构造方法:

/**
 * 混合渐变
 * @param shaderA 渐变对象A
 * @param shaderB 渐变对象B
 * @param mode 混合模式(位图运算类型)
 */
public ComposeShader(Shader shaderA, Shader shaderB, Xfermode mode) {

}

/**
 * 混合渐变
 *
 * @param shaderA 渐变对象A
 * @param shaderB 渐变对象B
 * @param mode    混合模式(位图运算类型)
 */
public ComposeShader(Shader shaderA, Shader shaderB, Mode mode){

}

下图就是16中混合模式
在这里插入图片描述
下面的案例是将两种渐变(线性渐变和位图渐变)进行 Mode.SRC_ATOP 运算得到的新的混合渐变,Mode.SRC_ATOP 运算是指显示第一个位图的全部,而第二个位图只显示二者的交集部分并显示在上面

private Paint paint;
private Rect rect;
private Bitmap source;

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    source = BitmapFactory.decodeResource(getResources(), R.drawable.ic_launcher);
    // 绘制的矩形区域为原图的宽度4倍,高度4倍
    rect = new Rect(0, 0, source.getWidth() * 4, source.getHeight() * 4);
    // 位图渐变,模式采用水平方向重复,垂直方向镜像
    BitmapShader bs = new BitmapShader(source, Shader.TileMode.REPEAT, Shader.TileMode.MIRROR);
    //线性渐变,起点在左上角,终点在右下角
    LinearGradient lg = new LinearGradient(rect.left, rect.top, rect.right, rect.bottom,
            Color.RED, Color.BLUE, Shader.TileMode.CLAMP);
    // 混合渐变 SRC_ATOP: bs会全部显示,但是lg会覆盖其上面
    ComposeShader cs = new ComposeShader(bs, lg, PorterDuff.Mode.SRC_ATOP);
    // 使用混合渐变
    paint.setShader(cs);

}


@Override
protected void onDraw(Canvas canvas) {
    // 绘制矩形区域,该区域会被混合渐变填充
    canvas.drawRect(rect, paint);
}

代码中,shaderA 为位图渐变,shaderB 为线性渐变,根据 Mode.SRC_ATOP 的运算规则,shaderA 会全部显示(此处为小机器人),shaderB 只显示二者相交部分并位于最上面(TOP),所以,得到的运算效果如图:
在这里插入图片描述

2.6 渐变与Matrix

渐变类都继承自同一个父类—Shader,该类并不复杂,不过定义了一个非常有用的方法:

public void setLocalMatrix(Matrix localM)

该方法能和渐变结合,在填充渐变颜色的时候实现移位、旋转、缩放和拉斜的效果。

下面的案例中,我们做了一个旋转的圆,圆内使用 SweepGradient 渐变填充,看起来像一张光盘。首先,我们创建了一个 Matrix 对象 mMatrix,mMatrix 定义了以圆点为中心渐变的旋转效果,注意不是旋转 Canvas 而是旋转 SweepGradient。onDraw()方法中不断调用 invalidate()重绘自己,每重绘一次就旋转 3 度,于是就形成了一个旋转的动画。



private Paint paint;
private RectF rect;
private SweepGradient sweepGradient;
private Matrix matrix = new Matrix();
private float degree; // 当前SweepGradient旋转角度


private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    // 定义绘制的矩形区域
    rect = new RectF(0, 0, 300, 300);
    // 扫描渐变
    sweepGradient = new SweepGradient(rect.width() / 2, rect.height() / 2,
            new int[]{Color.RED, Color.YELLOW, Color.GREEN, Color.BLUE}, null);

    // 使用扫描渐变
    paint.setShader(sweepGradient);
}


@Override
protected void onDraw(Canvas canvas) {

    canvas.translate(300,  300);
    // 清屏
    canvas.drawColor(Color.WHITE);
    // 变化角度
    degree += 3;
    if (degree >= 360) {
        degree = 0;
    }
    // 修改Matrix的旋转角度
    matrix.setRotate(degree,rect.width()/2,rect.height()/2);
    // 给渐变设置Matrix,这样渐变就可以旋转起来了,注意:这里并不是canvas在旋转
    sweepGradient.setLocalMatrix(matrix);
    // 不断刷新onDraw方法, 这样就能看到动画效果了
    invalidate();

    // 绘制圆形区域
    canvas.drawOval(rect, paint);
}

效果图:
在这里插入图片描述

三、位图运算

位图运算为位图的功能繁衍提供了强大的技术基础,大大增强了位图的可塑性和延伸性,使很多看起来非常复杂的效果和功能都能轻易实现,比如圆形头像、不规则图片、橡皮擦、稀奇古怪的自定义进度条等等。注意:位图运算必须要有2个位图(bitmap)存在,否则会达不到预期效果。

3.1 PorterDuffXfermode

Graphics2D 中,类 PorterDuffXfermode 提供对位图运算模式的定义与支持, “ProterDuff”是两个人名组合:TomasProter 和 TomDuff,他们是最早在 SIGGRAPH 上提出图形混合概念的大神级人物。创建 PorterDuffXfermode 对象时,可以提供多达 16 种运算模式。位图运算模式定义在 PorterDuff 类的内部枚举类型 Mode 中,对应了 16 个不同的枚举值:注意观察这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
}

我们通过下面的表格来理解各运算模式的作用。
在这里插入图片描述
在这里插入图片描述

其实上面的XOR(补集)、SRC_OVER/DST_OVER(并集)、DST_OUT(差集)、SRC_OUT(反差集)、SRC_IN/DST_IN(交集)、SRC/DST(替代,Path的op没有这个)和前面学习canvas的裁剪以及path的图形运算很相似,只不过在位图运算中多了层级的关系(谁在上层显示)这里就占了9种类型了,剩下7种才需要特别记忆,其中clear模式最简单,就是清空,那只需要留意以下6种即可。
在这里插入图片描述
这6种看名字和解释也很容易记住,为了实现位图运算,创建 PorterDuffXfermode 对象后,调用 Paint 类的如下方法

public Xfermode setXfermode(Xfermode xfermode)

PorterDuffXfermode 是 Xfermode 的 子 类 ,将PorterDuffXfermode 对象作为实际参数传入即可,形如

paint.setXfermode(new PorterDuffXfermode(Mode.CLEAR));

3.2 图层(Layer)

Canvas在一般的情况下可以看作是一张画布,所有的绘图操作如位图、圆、直线等都在这张画布上绘制,Canvas 同时还定义了相关属性如 Matrix、颜色等等。但是,倘若需要实现一些相对复杂的绘图操作,比如多层动画、地图(地图可以有多个地图层叠加而成,比如:政区层、道路层、兴趣点层)等,需要 Canvas 提供的图层(layer)支持,缺省情况下可以看作只有一个图层 layer。如果需要按层次来绘图, Canvas 需要创建一些中间层。layer 按照“栈结构”来管理,示意图如下图所示。
在这里插入图片描述
既然是栈结构,自然存在入栈和出栈两种行为。layer 入栈时,后续的绘图操作都发生在这个layer上,而layer出栈时,将把本图层绘制的图像“绘制”到它的下层或是 Canvas 上,在复制layer到Canvas上时,还可以指定 layer 的透明度。其实在介绍“阴影”这一小节中,我就向大家介绍了 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()的返回值。

3.3 位图运算技巧

要实现位图的混合运算,一方面需要通过 PorterDuffXfermode 指定运算的模式,另一方面还需要借助 layer 进行“离屏缓存”,达到类似 Photoshop 中“遮罩层”的效果。归纳起来,大概有下面几个参考步骤(其实可以有更加简化的步骤):

  1. 准备好分别代表 DST 和 SRC 的位图(bitmap),同时准备第三个位图,该位图用于绘制 DST 和SRC 运算后的结果;
  2. 创建大小合适的图层(layer)并入栈;
  3. 先将 DST 位图绘制在第三个位图上;
  4. 调用 Paint 的 setXfermode()方法定义位图运算模式;
  5. 再将 SRC 位图绘制在第三个位图上;
  6. 清除位图运算模式;
  7. 图层(layer)出栈
  8. 将第三个位图绘制在 View 的 Canvas 上以便显示。

注意: 位图运算一定要用在2个位图上才有效果,也就是说必须要直接操作的是2个bitmap对象,否则看到的效果和预期的会有出入.

3.3.1 错误示例-位图运算

为了让大家能理解 layer 在位图运算中的作用,我们层层递进由浅入深地进行学习。首先,在不使用 layer 的情况下,按照上面的思路绘制一个圆和一个正方形,圆作为 DST,正方形作为SRC,并执行 Mode.SRC 的运算模式。


private Paint paint;
private Bitmap resultBitmap;

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    // 圆
    Bitmap dst = Bitmap.createBitmap(300, 300, Bitmap.Config.ARGB_8888);
    // 正方形
    Bitmap src = dst.copy(Bitmap.Config.ARGB_8888, true);
    // 最终图形
    resultBitmap = Bitmap.createBitmap(450, 450, Bitmap.Config.ARGB_8888);
    // 创建对应的画布
    Canvas dstCanvas = new Canvas(dst);
    Canvas srcCanvas = new Canvas(src);
    Canvas resultCanvas = new Canvas(resultBitmap);

    // 绘制dst
    paint.setColor(Color.RED);
    dstCanvas.drawCircle(150, 150, 150, paint);

    // 绘制src
    paint.setColor(Color.GREEN);
    srcCanvas.drawRect(new Rect(0, 0, 300, 300), paint);

    // 定义画笔
    paint = new Paint();
    // 将圆画到最终画布上
    resultCanvas.drawBitmap(dst, 0, 0, null);
    //定义位图的运算模式,最终将显示src
    paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC));
    // 将正方向绘制到最终画布
    resultCanvas.drawBitmap(src, 150, 150, paint);
    //清除运算效果
    paint.setXfermode(null);
}


@Override
protected void onDraw(Canvas canvas) {
    canvas.translate(300, 300);
    // 将最终图形绘制到 Canvas 上
    canvas.drawBitmap(resultBitmap, 0, 0, null);
}

运行这段代码,结果却大失所望,从下图看出,与我们前面介绍的相差甚远,根本不是我们想要的结果。
在这里插入图片描述
正确的效果应该如下图所示,只保留矩形:
在这里插入图片描述

3.3.2 正确示例-位图运算

上面案例的问题需要用到图层(layer)来解决。事实上,在 Mode.SRC 运算模式中,如果不愿意看到DST(圆)的非交集部分,不使用 layer 是解决不了问题的。我们必须在正方形区域定义一个图层,绘图后,图层区域内的部分将会显示,而图层区域外的部分即会消失,如下示意图可以帮助大家理解前面这段话。
在这里插入图片描述
虚线部分表示图层(layer),绘图时,先创建图层并入栈,该图层的 left 和top 应该与圆的圆点坐标相同,right 和 bottom 则应该与正方形的 right 和 bottom 相同,接下来依次绘制圆形和正方形,所有绘制都作用在前面创建的图层上,通过 restoreToCount()方法将图层出栈后,显示出来的其实是图层之内的部分,图层之外的部分不会显示了。我们重构一下上面的代码,修改init方法,其他地方不需要改动即可:

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    // 圆
    Bitmap dst = Bitmap.createBitmap(300, 300, Bitmap.Config.ARGB_8888);
    // 正方形
    Bitmap src = dst.copy(Bitmap.Config.ARGB_8888, true);
    // 最终图形
    resultBitmap = Bitmap.createBitmap(450, 450, Bitmap.Config.ARGB_8888);
    // 创建对应的画布
    Canvas dstCanvas = new Canvas(dst);
    Canvas srcCanvas = new Canvas(src);
    Canvas resultCanvas = new Canvas(resultBitmap);

    // 绘制dst
    paint.setColor(Color.RED);
    dstCanvas.drawCircle(150, 150, 150, paint);

    // 绘制src
    paint.setColor(Color.GREEN);
    srcCanvas.drawRect(new Rect(0, 0, 300, 300), paint);

    // 定义画笔
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    //创建图层(关键代码1), 图层大小和区域与正方形一样
    int layer = resultCanvas.saveLayer(150, 150, 450, 450, null, Canvas.ALL_SAVE_FLAG);
    // 将圆画到最终画布上
    resultCanvas.drawBitmap(dst, 0, 0, null);
    //定义位图的运算模式,最终将显示src
    paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC));
    // 将正方向绘制到最终画布
    resultCanvas.drawBitmap(src, 150, 150, paint);
    //清除运算效果
    paint.setXfermode(null);
    //恢复(关键代码2),将图层的内容恢复到关联的resultCanvas上
    resultCanvas.restoreToCount(layer);
}

运行后,不出所料,效果出来了。注意观察上面的改动,在关键代码1中使用canvas创建图层,在关键代码2种恢复到canvas, 就这2个地方改动,有和没有差别就是天壤之别.
本节我们为大家提供了一个位图运算的基本思路,需要定义 3 个 Bitmap 对象,这是为了方便您理解,但这不是必要条件,当最少也得有2个bitmap才能进行位图运算!!!

3.4 ColorFilter的使用

通过paint的setColorFilter方法可以设置ColorFilter, 它的特点就是: 以图片作为Dst,以颜色作为Src.
注意如果设置的PorterDuffColorFilter的颜色是带透明值的, 那么Paint的color自带颜色是会影响结果的!!!通常Paint不需要设置颜色!!!

例如这里有一张背景图如下,这张图片的中间是透明的,只有4个角是实心的。
在这里插入图片描述
要实现只显示中间的不规则椭圆,并且可以自定义颜色,如下图所示:颜色采用黄色,中间是一个iconfont图标,这个可以用通过继承TextView,然后设置字体库来完成。
在这里插入图片描述
实现这个效果有2种模式,SRC_OUT和XOR。
1)SRC_OUT:图片作为Dst,颜色作为Src,然后这种效果取的就是2者叠加后的交集的补集(Src-Dst&Src),也就是取Src非交集的部分,由于Dst只有4个角是实心的,所以它们的交集就是4个角,然后取非交集的部分刚好就是Dst中间透明的区域
2)XOR:这个模式的意思就是取2者非交集的部分,刚好由于Dst图形的特殊性,所以效果和SRC_OUT是一样的。
下面直接贴出代码:


class ShapeIconFontTextView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
) : AppCompatTextView(context, attrs, defStyleAttr) {

    private val filterPaint: Paint = Paint().apply {
        isAntiAlias = true
        style = Paint.Style.FILL
    }

    // 形状的颜色
    private var shapeColor: Int
    private lateinit var defaultShapeBmp: Bitmap

    init {
        val array = context.obtainStyledAttributes(attrs, R.styleable.ShapeIconFontTextView)
        val fontFile = array.getString(R.styleable.ShapeIconFontTextView_fontFile)
        typeface = if (fontFile.isNullOrEmpty()) {
            Typeface.createFromAsset(context.assets, "iconfont/myfont.ttf")
        } else {
            Typeface.createFromAsset(context.assets, fontFile)
        }
        shapeColor = array.getColor(R.styleable.ShapeIconFontTextView_shapeColor, Color.parseColor("#ffffffff"))
        filterPaint.colorFilter = PorterDuffColorFilter(shapeColor, PorterDuff.Mode.SRC_OUT)
        array.recycle()
        gravity = Gravity.CENTER
        initShapeBitmap()
    }
	// 初始化Dst图片
    private fun initShapeBitmap() {
        defaultShapeBmp = BitmapFactory.decodeResource(context.resources, R.drawable.common_head_avatar_mask,
            BitmapFactory.Options().apply {
                inSampleSize = 3
            })
    }

    private val src = Rect()
    private val dest = Rect()

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        // 原图的大小区域
        src.set(0, 0, defaultShapeBmp.width, defaultShapeBmp.height)
        // 最终绘制时的大小区域
        dest.set(0, 0, measuredWidth, measuredHeight)

    }

    override fun onDraw(canvas: Canvas?) {
        canvas?.drawBitmap(defaultShapeBmp, src, dest, filterPaint)
        super.onDraw(canvas)
    }

}

四、位图运算案例

4.1 圆形头像

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

private Paint paint;
private Bitmap bmpCat; // 存储绘制猫
private Bitmap bmpCircleMask; // 存储绘制圆
private Matrix mMatrix = new Matrix(); // 控制canvas的缩小,因为图片太大了

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    bmpCat = BitmapFactory.decodeResource(getResources(), R.drawable.ic_bg);
    int minWidth = Math.min(bmpCat.getWidth(), bmpCat.getHeight());
    // 创建空白bitmap
    bmpCircleMask = Bitmap.createBitmap(minWidth, minWidth, Bitmap.Config.ARGB_8888);
    // 关联canvas
    Canvas canvas = new Canvas(bmpCircleMask);
    // 绘制圆形的mask到bmpCircleMask
    int r = (int) (minWidth / 2f);
    canvas.drawCircle(r, r, r, paint);
    // 设置缩小,以圆形作为缩放中心, x和y都缩放一半
    mMatrix.setScale(0.5f, 0.5f, r, r);
}
@Override
protected void onDraw(Canvas canvas) {
    // 先绘制Dst, Dst是猫
    canvas.drawBitmap(bmpCat, mMatrix, null);
    // 设置混合模式为DST_IN
    paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
    // 然后在绘制Src,Src是一个圆形的遮罩
    canvas.drawBitmap(bmpCircleMask, mMatrix, paint);
}

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

private Paint paint;
private Bitmap resultBmp; // 最终结果
private Matrix mMatrix = new Matrix(); // 控制canvas的缩小,因为图片太大了

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    // 获取猫的位图
    Bitmap bmpCat = BitmapFactory.decodeResource(getResources(), R.drawable.cat);
    int minWidth = Math.min(bmpCat.getWidth(), bmpCat.getHeight());
    // 创建空白bitmap,用来存储绘制的圆
    Bitmap bmpCircleMask = Bitmap.createBitmap(minWidth, minWidth, Bitmap.Config.ARGB_8888);
    // 关联canvas
    Canvas canvas = new Canvas(bmpCircleMask);
    // 绘制圆
    int r = (int) (minWidth / 2f);
    canvas.drawCircle(r, r, r, paint);
    // 设置缩小
    mMatrix.setScale(0.5f, 0.5f, r, r);


    // 下面开始运用位图运算将dst(猫)和src(圆)先绘制到图层layer上,然后在恢复到关联resultBmp的canvas上
    // 创建展示最终结果的bitmap
    resultBmp = Bitmap.createBitmap(minWidth, minWidth, Bitmap.Config.ARGB_8888);
    Canvas resultCanvas = new Canvas(resultBmp);
    // 关键代码1:创建图层
    int layer = resultCanvas.saveLayer(0, 0, minWidth, minWidth, null, Canvas.ALL_SAVE_FLAG);
    // 先绘制dst(猫)
    resultCanvas.drawBitmap(bmpCat, 0, 0, null);
    // 关键代码2:设置位图运算模式
    paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
    // 在绘制src(圆)
    resultCanvas.drawBitmap(bmpCircleMask, 0, 0, paint);
    paint.setXfermode(null);
    // 关键代码3:图层出栈,将内容还原到canvas上
    resultCanvas.restoreToCount(layer);
}


@Override
protected void onDraw(Canvas canvas) {
    // 绘制最终结果,并应用缩放效果
    canvas.drawBitmap(resultBmp, mMatrix, null);
}

利用这个方法,我们其实可以实现任意形状的图片,本质上,遮罩层是什么形状,图片就会显示什么形状,如果读者熟悉 PhotoShop 中的蒙版,其实 layer 和蒙版概念基本相同, 例如我使用如下图形作为src(蒙层)
在这里插入图片描述
进行位图运算DST_IN后的结果如下:
在这里插入图片描述
代码如下:

private Paint paint;
private Bitmap resultBmp; // 最终结果
private Matrix mMatrix = new Matrix(); // 控制canvas的缩小,因为图片太大了

private void init() {
    paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    // 获取猫的位图
    Bitmap bmpCat = BitmapFactory.decodeResource(getResources(), R.drawable.cat);
    // 获取机器人的位图,作为蒙层
    Bitmap bmpMask = BitmapFactory.decodeResource(getResources(), R.drawable.robot);
    // 缩放半径
    int minWidth = Math.min(bmpCat.getWidth(), bmpCat.getHeight());
    int r = (int) (minWidth / 2f);
    mMatrix.setScale(0.5f, 0.5f, r, r);// 设置缩小


    // 下面开始运用位图运算将dst(猫)和src(机器人)先绘制到图层layer上,然后在恢复到关联resultBmp的canvas上
    // 创建展示最终结果的bitmap
    resultBmp = Bitmap.createBitmap(minWidth, minWidth, Bitmap.Config.ARGB_8888);
    Canvas resultCanvas = new Canvas(resultBmp);
    // 关键代码1:创建图层, 这里图层的大小和蒙层(src)的大小一样
    int layer = resultCanvas.saveLayer(0, 0, bmpMask.getWidth(), bmpMask.getHeight(), null, Canvas.ALL_SAVE_FLAG);
    // 先绘制dst(猫)
    resultCanvas.drawBitmap(bmpCat, 0, 0, null);
    // 关键代码2:设置位图运算模式
    paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
    // 在绘制src(机器人), 注意,这里必现要传入paint,否则位图运算不会生效
    resultCanvas.drawBitmap(bmpMask, 0, 0, paint);
    paint.setXfermode(null);
    // 关键代码3:图层出栈,将内容还原到canvas上
    resultCanvas.restoreToCount(layer);
}


@Override
protected void onDraw(Canvas canvas) {
    // 绘制最终结果,并应用缩放效果
    canvas.drawBitmap(resultBmp, mMatrix, null);
}

4.2 刮刮乐

刮刮乐是个很好玩的小应用,娱乐性很强,靠的是运气。在图片上随机产生一个中奖信息,蒙上一层颜色,用户使用手指在屏幕上涂刮,颜色即被擦除,最后看到中奖信息。从技术实现上来说,我们发现刮刮乐有两个图层,一个是不会变化的中奖信息图层,一个是蒙上了一层灰色的图层。当用户手指在屏幕上涂抹时,我们需要将灰色抹掉。中奖信息其实并不需要改变,换句话说,涂抹时,无需重绘中奖信息。所以,对于中奖信息位图来说,应该采用更简单的实现,可以在图片上写上中奖信息后作为
View 的背景(Background),当手指在屏幕上涂抹时就不需要考虑他的重绘问题了。我们再创建一个 Bitmap 对象,初始蒙上一层灰色,手指在屏幕上移动时同时绘制线条,将线条与灰色做Mode.CLEAR 运算,相交的部分即被清除,变成了透明效果,于是我们就能看到背景了。

实现刮刮乐需要经历下面两个步骤:

  1. 绘制背景

背景需要一张图片,资源中的图片不能编辑,所以必须调用 Bitmap 的 copy()方法复制一张同样的图片并设置可编辑标识,画上随机生成的中奖信息,调用 View 类的setBackground(Drawable)方法设置为背景,因为setBackground会自动放大图片和控件大小匹配,View的draw方法会先触发drawBackground其次才是onDraw方法,所以调用setBackground是可以绘制背景的,当然你也可以在onDraw方法内绘制背景的bitmap,只是需要自己处理放大效果。

  1. 在屏幕上绘制线条

定义一个 Bitmap 对象,初始画上一层灰色,当手指在屏幕上移动时,不断绘制曲线,曲线和灰色做 Mode.CLEAR 运算,实现清除的效果。

首先准备一个背景图
在这里插入图片描述
代码如下:


private Paint textPaint;
private Random rnd;
private Paint clearPaint;
private static final String[] PRIZE = {
        "恭喜,您中了一等奖,奖金 1 亿元",
        "恭喜,您中了二等奖,奖金 5000 万元",
        "恭喜,您中了三等奖,奖金 100 元",
        "很遗憾,您没有中奖,继续加油哦"
};

// 涂抹的粗细
private static final int FINGER = 80;
// 缓冲区
private Bitmap bmpBuffer;
// 缓冲区画布
private Canvas cvsBuffer;
// 当前按下位置
private int curX, curY;

private void init() {
    rnd = new Random();
    // 绘制中奖信息的画笔
    textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    textPaint.setTextSize(30);
    textPaint.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);
}

// 可修改的背景图
Bitmap bmpBackgroundMutable;

private void drawBackground() {
	// 获取底图
    Bitmap bmpBackground = BitmapFactory.decodeResource(getResources(), R.drawable.konglong);
    //从资源中读取的 bmpBackground 是不可以修改的,需要复制出一张可以修改的图片
    bmpBackgroundMutable = bmpBackground.copy(Bitmap.Config.ARGB_8888, true);
    // 在图片上画上中奖信息
    Canvas cvsBackground = new Canvas(bmpBackgroundMutable);
    //计算出文字所占的区域,将文字放在正中间
    String text = PRIZE[getPrizeIndex()];
    Rect rect = new Rect();
    textPaint.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, textPaint);
     // 添加文字阴影,让文字看起来有发光效果
    textPaint.setShadowLayer(10, 0, 0, Color.GREEN);
    cvsBackground.drawText(text, x, y, textPaint);
    textPaint.setShadowLayer(0, 0, 0, Color.YELLOW);
    //画背景, 之所有使用此方式,是因为setBackground会自动放大图片和控件大小匹配,View的draw方法会先触发drawBackground其次才是onDraw方法,所以调用setBackground是可以绘制背景的
    //当然你也可以在onDraw方法内绘制背景bmpBackgroundMutable,只是需要自己处理放大效果
    this.setBackground(new BitmapDrawable(getResources(), bmpBackgroundMutable));
}


@Override
protected void onDraw(Canvas canvas) {
    // 绘制背景图,由于采用了setBackground的方式,这里就不需要了,而且背景图是不会变的,所以不需要重复绘制,不建议使用这种
    /*canvas.drawBitmap(bmpBackgroundMutable, new Rect(0, 0, bmpBackgroundMutable.getWidth(), bmpBackgroundMutable.getHeight()),
            new Rect(0, 0, getWidth(), getHeight()), null);*/
    // 绘制缓冲区
    canvas.drawBitmap(bmpBuffer, 0, 0, null);
}

// 处理手势
@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:
            // 绘制直线,这里并没有使用图层layer,因为我们的位图运算模式是PorterDuff.Mode.CLEAR
            cvsBuffer.drawLine(curX, curY, x, y, clearPaint);
            invalidate();
            curX = x;
            curY = y;
            break;
        case MotionEvent.ACTION_UP:
            invalidate();
            break;
        default:
            break;
    }
    return true;
}

效果图:
在这里插入图片描述

4.3 自定义ImageView绘制8边形的头像

效果图:
在这里插入图片描述
观察效果图可知,必须要用到位图运算的api以及colorFilter的api.
我们需要准备一个蒙层图片(八边形的)作为Src(bitmap), 如下图所示:
这是一个8边形蒙层,只有4个角是纯色的,中间区域是透明区域,只是AS在MAC上预览透明效果不是马赛克而已.
在这里插入图片描述
然后头像的bitmap作为Dst
在这里插入图片描述
至于如何在代码中获取ImageView的src属性或者background属性,可以通过如下代码:

// 原图
private var mSrcBmp: Bitmap? = null

init {
    val srcRes: Int? = attrs?.getAttributeResourceValue("http://schemas.android.com/apk/res/android", "src", 0)
    srcRes?.let {
        try {
            // 针对src属性
            mSrcBmp = BitmapFactory.decodeStream(resources.openRawResource(srcRes))//.copy(Bitmap.Config.ARGB_8888, true)
        } catch (e: Exception) {
            if (mSrcBmp == null && background is BitmapDrawable) {
                // 这对background属性
                mSrcBmp = (background as BitmapDrawable).bitmap//.copy(Bitmap.Config.ARGB_8888, true)
                // 获取后清空背景
                setBackgroundDrawable(null)
            }
        }
    }
}

至于描边的功能就需要用到colorFilter了.准备一张带描边的原图:
下图黑色区域是透明区域,之所以没有显示马赛克完全是因为mac系统的AS预览效果.
在这里插入图片描述
图片的描边是透明的蓝色, 我们通过colorFilter就可以实现自定义的颜色了.通过下面代码设置描边的画笔

// 描边画笔
private val mStrokePaint: Paint = Paint().apply {
    isAntiAlias
    isFilterBitmap = true
    style = Paint.Style.STROKE
    isDither = true
    // 设置描边的颜色Filter, 颜色作为src,图片作为dst
    colorFilter = PorterDuffColorFilter(borderColor, PorterDuff.Mode.SRC_IN)
}

然后通过此画笔去绘制描边原图的时候就可以实现自定义的颜色了.完整的代码如下:

class PolygonImageView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, def: Int = 0) :
    AppCompatImageView(context, attrs, def) {

    // 蒙层
    private var mMaskBmp: Bitmap = BitmapFactory.decodeResource(resources, R.drawable.common_polygon_shape_mask)

    // 原图
    private var mSrcBmp: Bitmap? = null

    // 最终图
    private var mResultBmp: Bitmap? = null

    // 描边蒙层
    private var mMaskBorderBmp: Bitmap = BitmapFactory.decodeResource(resources, R.drawable.common_polygon_shape_border)

    // 描边颜色
    private var borderColor: Int = Color.parseColor("#ff0000")

    //填充画笔
    private val mFillPaint: Paint = Paint().apply {
        isAntiAlias
        isFilterBitmap = true
        style = Paint.Style.FILL
        isDither = true
        // 设置蒙层和头像的xfermode,头像作为dst,蒙层作为src
        xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
    }
    // 描边画笔
    private val mStrokePaint: Paint = Paint().apply {
        isAntiAlias
        isFilterBitmap = true
        style = Paint.Style.STROKE
        isDither = true
        // 设置描边的颜色Filter, 颜色作为src,图片作为dst
        colorFilter = PorterDuffColorFilter(borderColor, PorterDuff.Mode.SRC_IN)

    }

    init {
        // 从src属性获取图片
        val srcRes: Int? = attrs?.getAttributeResourceValue("http://schemas.android.com/apk/res/android", "src", 0)
        srcRes?.let {
            try {
                // 针对src属性
                mSrcBmp = BitmapFactory.decodeStream(resources.openRawResource(srcRes))//.copy(Bitmap.Config.ARGB_8888, true)
            } catch (e: Exception) {
                if (mSrcBmp == null && background is BitmapDrawable) {
                    // 这对background属性
                    mSrcBmp = (background as BitmapDrawable).bitmap//.copy(Bitmap.Config.ARGB_8888, true)
                    // 获取后清空背景
                    setBackgroundDrawable(null)
                }
            }
        }
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        // 保证是方形的
        val size = w.coerceAtMost(h)
        mResultBmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
        Canvas(mResultBmp!!).apply {
            // 绘制原图头像dst
            val dst = RectF(0f, 0f, size.toFloat(), size.toFloat())
            val layer = saveLayer(dst, null)
            mSrcBmp?.let {
                drawBitmap(it, Rect(0, 0, it.width, it.height), dst, null)
            }
            // 绘制蒙层src
            drawBitmap(mMaskBmp, Rect(0, 0, mMaskBmp.width, mMaskBmp.height), dst, mFillPaint)

            // 方式2: 头像在src,蒙层在dst
//            drawBitmap(mMaskBmp, Rect(0, 0, mMaskBmp.width, mMaskBmp.height), dst, null)
//            mFillPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_OUT)
//            mSrcBmp?.let {
//                drawBitmap(it, Rect(0, 0, it.width, it.height), dst, mFillPaint)
//            }
            restoreToCount(layer)

            // 绘制描边
            drawBitmap(mMaskBorderBmp, Rect(0, 0, mMaskBorderBmp.width, mMaskBorderBmp.height), dst, mStrokePaint)
        }
    }

    override fun onDraw(canvas: Canvas?) {
        mResultBmp?.let {
            // 居中绘制
            canvas?.translate((measuredWidth - it.width) / 2f, (measuredHeight - it.height) / 2f)
            // 绘制最终效果图
            canvas?.drawBitmap(it, 0f, 0f, null)
        }
    }
}

使用方式:
1.通过background属性指定图片

<com.example.sample1.demo2.PolygonImageView
    android:id="@+id/iv_head2"
    android:layout_width="200dp"
    android:layout_height="80dp"
    android:layout_marginTop="10dp"
    android:background="@drawable/ic_sample" />

2.通过src属性指定图片

<com.example.sample1.demo2.PolygonImageView
    android:id="@+id/iv_head2"
    android:layout_width="200dp"
    android:layout_height="80dp"
    android:layout_marginTop="10dp"
    android:src="@drawable/ic_sample" />
  • 2
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值