1、贝塞尔曲线
在Path系列函数中,除了一些基本的设置和绘图外,还有一个强大的工具---贝塞尔曲线。可以是moveTo、lineTo连接的生硬路径变得顺滑。
1.1、一阶贝塞尔曲线
一阶贝塞尔曲线的公式如下:
B(t)=(1-t)P0+tP1 ,t的范围[0,1]
对应的动画演示如下图
在这里P0为起始点,P1为终点,t表示当前时间,B(t)表示公式的结果值。注意:曲线的含义就是随着时间的变化,结果值B(t)所形成的轨迹,在动画中黑色的点表示B(t)的结果值,而红色的线表示在各个时间点下不同的取值B(t)所形成的轨迹。
对于一阶贝塞尔曲线可以理解为从起点到终点的这条直线上,匀速运动的点。
1.2、二阶贝塞尔曲线
二阶贝塞尔曲线的公式如下:
对应的动画演示如下:
在这里P0是起始点,P2是终点,P1是控制点。
假定将时间定在t=0.25的时刻,此时的状态如下图
首先P0和P1形成一条一阶贝塞尔曲线,上面我们说了一阶贝塞尔曲线就是一个点在一条直线上匀速移动。Q0就是这条直线上移动的点。
其次,P1和P2形成一条一阶贝塞尔曲线,在上面匀速移动的点是Q1.
最后,Q0和Q1形成一条一阶贝塞尔曲线,在上面匀速移动的点是B,二阶贝塞尔曲线的结果就是B点运动的轨迹。
之所以称为二阶贝塞尔曲线,是因为B点的移动轨迹是建立在两条一阶贝塞尔曲线的中间点Q0和Q1的基础上的。
1.3、三阶贝塞尔曲线
三阶贝塞尔曲线的公式如下:
对应的动画如下
同样我们取其中一点来讲解轨迹的形成原理,当t=0.25时,曲线的状态如下
同样P0是起点,P3是终点,P1是第一个控制点,P2是第二个控制点。
首先在这里有三条一阶贝塞尔曲线,分别是P0-P1、P1-P2、P2-P3,它们随时间变化的点分别是Q0、Q1、Q2。
然后由Q0、Q1、Q2再次连接形成两条一阶贝塞尔曲线,分别为Q0-Q1、Q1-Q2,它们随时间变化的点是R0、R1,同样R0和R1连接形成一条一阶贝塞尔曲线,在R0-R1这条贝塞尔曲线上移动的点是B,而B点的移动轨迹就是三阶贝塞尔曲线的最终形态。
从以上分析可知:所谓几阶贝塞尔曲线都是由一条条一阶贝塞尔曲线搭起来的。在上图中P0,P1,P2,P3形成一阶贝塞尔曲线,Q0、Q1、Q2形成二阶贝塞尔曲线,R0、R1形成三阶贝塞尔曲线。
2、使用Path构造贝塞尔曲线
在Path类中有4个方法和贝塞尔曲线相关
//二阶贝塞尔曲线
public void quadTo(float x1, float y1, float x2, float y2)
public void rQuadTo(float dx1, float dy1, float dx2, float dy2)
//三阶贝塞尔曲线
public void cubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
public void rCubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
2.1、quadTo的使用原理
public void quadTo(float x1, float y1, float x2, float y2)
(x1,y1)是控制点,(x2,y2)是终点,起始点是由moveTo(x,y)指定的,如果连续调用quadTo(),那么上一次调用quadTo()的终点就这一次quadTo()的起点,如果未指定起点,那么默认为把(0,0)作为起始点。
下面实现一下下图的效果
代码如下:
path.moveTo(0f,300f)
path.quadTo(150f,150f,300f,300f)
path.quadTo(450f,450f,600f,300f)
canvas?.drawPath(path,paint)
注意:首先需要调用moveTo()将起始点移动到(0,300),第二次调用quadTo()时路径的起点就是上一次quadTo()的终点。
2.2、使用quadTo()实现捕捉手势轨迹
其实只需要在onTouchEvent()捕捉手势的规则,利用Path.moveTo()和quadTo()将手势的轨迹用Path记录下来,然后使用Canvas.drawPath()即可。具体代码如下:
override fun onTouchEvent(event: MotionEvent?): Boolean {
event?.apply {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
path.moveTo(event.x, event.y)
return true
}
MotionEvent.ACTION_MOVE -> {
path.lineTo(event.x, event.y)
invalidate()
}
}
}
return super.onTouchEvent(event)
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas?.drawPath(path, paint)
}
注意:需要在ACTION_DOWN中返回true,否则ACTION_MOVE、ACTION_UP都无法接受待手势事件了。
在ACTION_MOVE中我们并使用Path.lineTo()绘制手势轨迹,放大发现在连接处会有明显的转折。就需要使用贝塞尔曲线进行优化,可是使用贝塞尔曲线,如何找到它的控制点和终点呢,具体原理看下图
将两条线段的中点作为起始点和终点,上一个手指的位置作为控制点,手指触点B和手指触点C的中点作为第一次quadTo()的终点,也会作为下次quadTo()的起点。
这样做虽然会导致A-P0、P1-C点的线段丢失,但是由于每次滑动,每两个点的距离较小,线段丢失可忽略不计。
根据上图就可得到如下代码
override fun onTouchEvent(event: MotionEvent?): Boolean {
event?.apply {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
path.moveTo(event.x, event.y)
preX=event.x
preY=event.y
return true
}
MotionEvent.ACTION_MOVE -> {
//取上一个点和当前点的中点作为终点
val endX=(preX+event.x)/2
val endY=(preY+event.y)/2
path.quadTo(preX,preY,endX,endY)
//preX、preY作为下次quadTo()的控制点,所以在此处将即将成为上一个点的(event.x,event.y)分别赋值给preX、preY
preX=event.x
preY=event.y
invalidate()
}
}
}
return super.onTouchEvent(event)
}
2.3、贝塞尔曲线之rQuadTo
函数声明如下
public void rQuadTo(float dx1, float dy1, float dx2, float dy2)
- float dx1:表示相对于上一个终点X坐标的偏移量,正值相加,负值相减,结果作为控制点的X坐标。
- float dy1:同样dy1是相对于上移终点Y坐标的偏移量,正值相加,负值相减,结果作为控制点的Y坐标
- float dx2:相对于上一个终点的X坐标的偏移量,正值相加,负值相减,结果作为终点的X坐标
- float dy2:相对于上移终点Y坐标的偏移量,正值相加,负值相减,结果作为终点的Y坐标。
比如利用quadTo()定义的绝对坐标
path.moveTo(300 , 400) ;
path. quadTo(500,300 , 500,500);
和下面的使用rQuadTo()实现的效果相同
path.moveTo(300 , 400) ;
path. rQuadTo(200,-100 , 200,100);
2.4、实现波浪效果
先看下效果
具体代码如下:
class PaintView(context: Context, attrs: AttributeSet) : View(context, attrs) {
private val paint: Paint = Paint()
private val path = Path()
private val waveHeight = 300f
private var mItemWaveLenght = 1200f
private var dx = 0f
init {
paint.setColor(Color.GREEN)
paint.style = Paint.Style.FILL
startAnim()
}
private fun startAnim() {
val valueAnimator = ValueAnimator.ofFloat(0f, mItemWaveLenght)
valueAnimator.duration = 3000
valueAnimator.repeatMode = RESTART
valueAnimator.repeatCount = INFINITE
valueAnimator.addUpdateListener {
dx = it.getAnimatedValue() as Float
invalidate()
}
valueAnimator.start()
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
path.reset()
path.moveTo(-mItemWaveLenght + dx, waveHeight)
for (i in -mItemWaveLenght.toInt()..width + mItemWaveLenght.toInt() step mItemWaveLenght.toInt()) {
path.rQuadTo(mItemWaveLenght / 4, -100f, mItemWaveLenght / 2, 0f)
path.rQuadTo(mItemWaveLenght / 4, 100f, mItemWaveLenght / 2, 0f)
}
path.lineTo(width.toFloat(), height.toFloat())
path.lineTo(-mItemWaveLenght, height.toFloat())
path.close()
canvas?.drawPath(path, paint)
}
}
3、setShadowLayer与阴影效果
通过使用setShadowLayer可以给文字、图片设置阴影效果。
setShadowLayer的函数声明如下:
public void setShadowLayer(float radius, float dx, float dy, int shadowColor)
- float radius:模糊半径,radius越大则越模糊,越小越清晰,若为0则阴影消失不见。
- float dx:阴影的横向偏移距离,正值向右偏移,负值向左偏移
- float dy:阴影的纵向偏移距离,正值向下偏移,负值向上偏移
-
int color:绘制阴影的颜色
使用setShowLayer实现如下效果
image.png
具体代码如下
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
paint.setShadowLayer(10f,30f,30f,Color.BLACK)
paint.setColor(Color.RED)
paint.textSize=120f
canvas?.drawText("天启元年",0f,200f,paint)
canvas?.translate(0f,300f)
paint.setShadowLayer(10f,30f,30f,Color.GRAY)
canvas?.drawCircle(100f,100f,50f,paint)
canvas?.translate(0f,300f)
paint.setShadowLayer(10f,30f,30f,Color.RED)
canvas?.drawBitmap(flagBitmap,0f,0f,paint)
}
从上图可以得到:
- 文字和图形的阴影都是使用自定义阴影画笔颜色来绘制的。
- 而图片的阴影则是则是产生一张相同的图片,只是对图片的边缘进行模糊处理。
注意:setShowLayer()不支持硬件加速。
清除阴影
清除阴影有两种方法:
- 1、将setShowLayer的radius半径设置为0
- 2、使用clearShowLayer()方法
上面方法使用起来很简单,只需要在重绘时调用即可。
4、BlurMaskFilter和发光效果
4.1、setMaskFilter()函数的简单使用
函数的声明如下
public MaskFilter setMaskFilter(MaskFilter maskfilter)
- MaskFilter maskfilter:MaskFilter 的派生类有两个:BlurMaskFilter(发光效果)、EmbossMaskFilter(浮雕效果已弃用)。setMaskFilter()不支持硬件加速。
BlurMaskFilter的构造函数如下:
public BlurMaskFilter(float radius, BlurMaskFilter.Blur style)
- float radius:模糊半径
- BlurMaskFilter.Blur style:发光样式,有Blur.INNER(内发光)、Blur.SOLID(外发光)、Blur.NORMAL(内外发光)、Blur.OUTER(仅显示发光效果)4种样式。
下面举例展示其用法
init {
setLayerType(LAYER_TYPE_SOFTWARE, null)
paint.setColor(Color.BLACK)
paint.setMaskFilter(BlurMaskFilter(50f, BlurMaskFilter.Blur.INNER))
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas?.drawCircle(200f, 200f, 100f, paint)
}
其他三种发光样式的效果
4.2、使用setMaskFilter()函数给图片绘制纯色阴影
还记得我们上面使用setShowLayer给图片设置阴影时,只是将图片复制一份并对图片边缘进行模糊处理,其实按照这个思路我们可以生成一个和图片大小相同纯色背景,然后对这个纯色背景进行外发光的处理,然后偏移一定距离即可。
具体步骤如下:
- 1、绘制一幅跟图片一样大小的灰色图像
- 2、对灰色图像应用MaskFilter使其外发光
- 3、偏移原图形一段距离绘制阴影
4.2.1、抽取灰色图像
首先来看如何绘制一张位图所对应的的灰色图像。我们知道canvas.drawBitmap(Bitmap bitmap,Rect src,Rect dst,Paint paint)中画笔颜色对画出来的位图没有任何影响,所以如果我们需要画一张位图所对应的灰色图像,就需要新建一个一样大小的空白图片,而且新图片的透明度要和原图片保持一致。这样一来,抽取原图的透明度就变成了关键。其实Bitmap中就存在抽取出只存在Alpha图片的函数,声明如下:
public Bitmap extractAlpha()
这个函数的功能就是:新建一张空白图片,该图片具有和原图相同的alpha值,把这个新建的Bitmap作为结果返回。在这张空白图片中,具体的颜色可以在使用Canvas.drawBitmap()时传入。
这样我们就可以利用空白图片绘制出一个纯色的灰色图片了。
抽取出灰色图像也就完成了最关键的一步了,其他都比较简单了,直接看代码
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
alphaBitmap=bitmap.extractAlpha()
paint.setColor(Color.GRAY)
paint.setMaskFilter(maskFilter)
canvas?.drawBitmap(alphaBitmap,0f,0f,paint)
canvas?.translate(-5f,-5f)
paint.setMaskFilter(null)
canvas?.drawBitmap(bitmap,0f,0f,paint)
}
5、Shader与BitmapShader
Shader是一个着色器,用于给空白图形上色,可以通过给Shader指定对应的图像、颜色、渐变色,来给空白图形上色。
Shader是一个基类,其中只有getLocalMatrix()和setLocalMatrix()来获取和设置矩阵。Shader的派生类有5个:BitmapShader、LinearGradient、RadialGradient、SweepGradient、ComposeShader。
5.1、BitmapShader
BitmapShader就是设置样式为图像,然后将设置的图像绘制在空白的图形,BitmapShader的构造函数如下
public BitmapShader(@NonNull Bitmap bitmap, @NonNull TileMode tileX, @NonNull TileMode tileY)
- Bitmap bitmap:用来指定绘制的图像
- TileMode tileX:表示指定当X轴超出单张图像的大小时所使用的重复策略。
- TileMode tileY:表示指定当Y轴超出单张图片大小时所使用的重复策略。
TileMode的取值如下: - CLAMP:用边缘颜色来填充多余的空间
- REPEAT:重复原图像来填充多余空间
- MIRROR:重复使用镜像图像来填充多余空间
TileMode.REPEAT
示例:
实现如下效果
image.png
实现起来很简单,通过setShader()设置印章图像,并且TileModel设置为重复模式即可,具体代码如下:
init {
val bitmap = BitmapFactory.decodeResource(context.resources, R.mipmap.dog_edge)
val shader = BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
paint.setShader(shader)
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas?.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
}
TilMode.MIRROR
这里只需在创建BitmapShader时将TilMode改成TilMode.MIRROR即可,看下效果图
可以看到在X轴上和Y轴上没两张图片都进行了镜像。
TilMode.CLAMP
在创建BitmapShader重复模式使用TilMode.CLAMP,效果如下
TileMode为TilMode.CLAMP时,会使用图片的边缘颜色进行填充空白部分,从效果来看,先进行竖向填充,然后再横向填充。填充过程如下
理解了填充顺序后,使用混合重复策略就很好理解了
val shader = BitmapShader(bitmap, Shader.TileMode.MIRROR, Shader.TileMode.REPEAT)
效果如下图
无论那两种模式混合,都会先按照TileMode tileY进行竖向填充,然后再按照TileMode tileX进行横向填充。
BitmapShader之绘图位置和图像显示
上面示例中我们利用drawRect()覆盖了整个控件大小,假如我们只画一个小矩形而不是整个控件大小,那么setShader()函数中所设置的图片是从哪里开始绘制的呢?
这里我们只需修改drawRect()中rect的大小即可,即将下面代码
canvas?.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
修改为
canvas?.drawRect(width*1.0f/3, height*1.0f/3, width*2.0f/3, height*2.0f/3, paint)
效果图如下
从图中可以看到:无论利用绘制函数绘制多大的图像,在哪里绘制,都和Shader无关。因为Shader都是总是从控件的左上角开始的,而我们绘制的只是显示出来的部分图像而已,没有绘制的部分虽然已经生成,但是面没有显示出来。
示例:实现望远镜的效果
private val paint: Paint = Paint()
private val path: Path = Path()
private var dx = -1f
private var dy = -1f
private val bitmap = BitmapFactory.decodeResource(context.resources, R.mipmap.scenery)
private var mBitmap: Bitmap? = null
init {
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
event?.apply {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
dx = event.x
dy = event.y
return true
}
MotionEvent.ACTION_MOVE -> {
dx = event.x
dy = event.y
}
MotionEvent.ACTION_UP -> {
dx = -1f
dy = -1f
}
}
invalidate()
}
return super.onTouchEvent(event)
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
if (mBitmap == null) {
mBitmap = Bitmap.createBitmap(width, height, bitmap.config)
val mCanvas = Canvas(mBitmap!!)
mCanvas.drawBitmap(
bitmap,
Rect(0, 0, bitmap.width, bitmap.height),
Rect(0, 0, width, height),
paint
)
paint.setShader(BitmapShader(mBitmap!!, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT))
}
if (dx != -1f && dy != -1f)
canvas?.drawCircle(dx, dy, 150f, paint)
}
5.2、LinearGradient
LinearGradient是Shader的另一个派生类,下面看下它的构造函数
public LinearGradient(float x0, float y0,
float x1, float y1,
@ColorInt int color0, @ColorInt int color1,
@NonNull TileMode tile)
- 参数中(x0,y0)是渐变的起始点
- 参数中(x1,y1)是渐变的终点
- color0渐变的起始颜色,color1渐变的终点颜色,注意:颜色必须使用0xAARRGGBB格式的,其中表示透明度的AA一定不能少。
- TileMode tile:和BitmapShader一样,指当空间区域大于渐变区域时空白区域的填充的重复模式。
第二个构造函数
public LinearGradient(float x0, float y0,
float x1, float y1,
@NonNull @ColorInt int[] colors,
@Nullable float[] positions,
@NonNull TileMode tile)
- colors:是指渐变颜色的数组,同样颜色必须使用0xAARRGGBB的十六进制格式使用。
- positions:和colors对应,取值范围为[0~1]的float型数据,表示每种颜色在渐变中的百分比
位置
,注意是位置百分比,而不是范围百分比。
示例一:自定义一个双色渐变的效果
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
val shader = LinearGradient(
0f,
height * 1.0f / 2,
width.toFloat(),
height * 1.0f / 2,
Color.RED,
Color.BLACK,
Shader.TileMode.REPEAT
)
paint.setShader(shader)
canvas?.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
}
代码很简单,主需要在绘制时通过构造LinearGradient,并通过setShader设置进去。
这里构造的LinearGradient是从(0,getHeight()/2)到(getWidth(),getHeight()/2)的渐变。
使用XML引入并指定大小
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<com.example.valueanimatortest.LinearGradientView
android:layout_width="match_parent"
android:layout_height="100dp" />
</LinearLayout>
效果如下图
示例二:自定义一个多色渐变的效果
只需要在构造LinearGradient时修改下构造参数即可,代码如下
private val colorArray = intArrayOf(Color.RED, Color.BLUE, Color.MAGENTA)
private val positionArray = floatArrayOf(0f, 0.5f, 1.0f)
val shader = LinearGradient(
0f,
height * 1.0f / 2,
width.toFloat(),
height * 1.0f / 2,
colorArray,
positionArray,
Shader.TileMode.REPEAT
)
上面我们定义了一个颜色的数组和位置百分比的数组,它们是相对应的。这里构造的LinearGradient表示在0f-0.5f这个位置区间,做Color.RED-Color.BLUE的渐变,在0.5f-1.0f这个位置区间,做Color.BLUE-Color.MAGENTA的渐变。如果对应颜色数组中最后的一个颜色的位置百分比小于1.0f,比如0.6f,则从0.6f-1.0f位置区间就不再做渐变,只会使用纯色去填充。
TileMode填充模式
LinearGradient的TileMode不像BitmapShader可以对X轴、Y轴分别设置填充模式,LinearGradient只有一个参数,表示X轴、Y轴使用同一种填充模式。
下面看下填充模式的使用
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
val shader = LinearGradient(
0f,
0f,
width * 1.0f / 2,
height * 1.0f / 2,
colorArray,
positionArray,
Shader.TileMode.CLAMP
)
paint.setShader(shader)
canvas?.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
}
渐变点是从(0,0)至(getWidth()/2,getHeight()/2)。各种填充模式效果如下:
原图只覆盖控件的左半部分,右边部分为空白,所以会使用对应TileMode进行填充。
Shader的派生类SweepGradient和RadialGradient的使用类似LinearGradient,不再赘述。