背景介绍
Android在5.0版本时经历了一个彻底变革,一套适用于所有设备类型和任意软件版本的设计准则 —– Material Design出现了,其界面风格发生了很大的变化。
UI即User Interface(用户界面)的简称,泛指用户的操作界面,view是用户界面最基本的组件,其扩展了View类,更控制着屏幕上的绘制和实践,例如被触控。屏幕上显示的所有元素都依赖view。其中主要分为两类view:
除此之外,动画在应用中的地位也逐渐增加,许多行为操作需要借助动画过度,Material Design也特别强调了设计的流畅性,涉及到了Android系统中的View动画、属性动画。
目标说明
在了解以上背景后,可见UI对Android开发的重要性,这里的UI开发就是界面绘制,包括图片、文本、自定义控件等等,实现基本后还要考虑使用色彩滤镜、着色器和一些高级技巧来绘制任何想要实现的效果。
在自定义View或Layout时,不仅要了解测量(onMeasure)、布局(onLayout)、展示(onDraw),更要考虑处理不同的点击、滑动输入事件(onTouchEvent)和事件传递,甚至需要注意其保存、恢复状态。其中更是涉及到Paint、Canvas、Path等graphics包中各类的灵活运用,还需要结合动画。
这是一个庞大的工程,不仅仅是一个自定义控件,专业来说是界面绘制,需要考虑很多方面,也是一个考验开发人员的技能点。此系列文章将重点着于界面绘制(自定义控件)展开,归纳总结相关知识技术,基于其根本,拓展UI相关重点部分,最终达成界面绘制学习实践目的。涉及到的内容约为以下部分:
此系列的第一步由graphics包中Paint类归纳开始,它是基本的绘制工具,可搭配Canvas轻松完成图形、文字绘制,更有各种渲染器、颜色过滤器实现高级绘制。此篇涉及到的知识点如下:
Paint类可根据设置的风格(style)和颜色(color)信息来绘制几何图形(geometry)、文字(text)、图片(bitmap)。
方法返回值 | Paint相关方法 | 方法含义 |
---|---|---|
void | reset() | 重置Paint相关设置 |
void | setColor(int color) | 设置Paint画笔颜色 |
void | setAlpha(int a) | 设置画笔颜色的透明度,范围是[0,255] |
一般将Paint绘制分为两部分,即图形相关绘制和文字相关绘制,以下来详细介绍。
方法返回值 | Paint相关方法 | 方法含义 |
---|---|---|
void | setStyle(Paint.Style style) | 设置画笔的风格,用来描述最初的几何图形如何展现(Paint.Style.FILL、Paint.Style.FILL_AND_STROKE、Paint.Style.STROKE) |
void | setStrokeWidth(float width) | 设置Paint画笔描边(stoking)时的宽度 |
void | setStrokeCap(Paint.Cap cap) | 设置画笔笔帽(Paint.Cap.BUTT、Paint.Cap.ROUND、Paint.Cap.SQUARE) |
void | setStrokeJoin(Paint.Join join) | 设置画笔的Join,即交汇处的展示形式(Paint.Join.MITER、Paint.Join.ROUND、Paint.Join.BEVEL) |
void | setAntiAlias(boolean aa) | 设置或清除ANTI_ALIAS_FLAG,图形保真,消除混叠现象,使几何图形边缘平滑过渡,对图形内部无影响。(注:会损耗性能) |
void | setDither(boolean dither)) | 设置或清除DITHER_FLAG,抖动在高版本上会影响颜色绘制,使用图像抖动处理会使绘制的图片等颜色更加的清晰以及饱满。(注:会损耗性能) |
(1)setStyle
paint.setStyle(Paint.Style style)
注意:很明显三种类型的区别就在于是否“描边”,而这里的描边也就是Paint画笔绘制时的宽度,需要搭配setStrokeWidth
理解,线条宽度。单位为像素,默认值是 0。
如下图实例,在设置画笔paint宽度之后,绘制圆时设定半径为100,则在FILL_AND_STROKE模式下的圆实际半径有120,因为还包含了画笔的宽度20,STROKE模式下的圆只呈现出描边的宽度20。
mPaint.setStrokeWidth(20);//画笔的宽度
//设置画笔的样式
mPaint.setStyle(Paint.Style.FILL);//填充内容
canvas.drawCircle(200, 200, 100, mPaint);
mPaint.setStyle(Paint.Style.FILL_AND_STROKE);
...
mPaint.setStyle(Paint.Style.STROKE);//描边
...
(2)setStrokeCap
paint.setStrokeCap(Paint.Cap cap)
注意:此API多用于线条之间的绘制,决定图形初始端和末端的结尾形式,ROUND相当于BUTT则是多出一段圆形笔帽,整体看起来更圆润;而SQUARE则是方形笔帽,看似与BUTT无差别,但实际长度会多出一截。
(当所绘制的图形宽度为1个像素时,三者区别并无差异,若是宽度较为明显,则根据器模式展现出不同样式)
(3)setStrokeJoin
paint.setStrokeJoin(Paint.Join join)
注意:设置图形的交汇展示形式,重点在于“交汇”,直接调用drawLine
没有效果,以下例子是采用path进行展示,path.lineTo
可体现出线条绘画的连接处。
如下图实例,根据不同的类型设置分别体现出量线条交汇处风格。
mPaint.setStrokeWidth(30);//画笔的宽度
mPaint.setStyle(Paint.Style.STROKE);//填充内容
Path path = new Path();
path.moveTo(100, 100);
path.lineTo(300, 100);
path.lineTo(100, 300);
mPaint.setStrokeJoin(Paint.Join.MITER);
canvas.drawPath(path, mPaint);
(另外还有个setStrokeMiter(float miter)
是设置笔画的倾斜度,miter > = 0
。 如小时候用的铅笔,削的时候斜与垂直削出来的笔尖效果是不一样的。 主要是用来设置笔触的连接处的样式。)
(1)常用API总结
方法返回值 | Paint相关方法 | 方法含义 |
---|---|---|
void | setTextSize(float textSize) | 设置文字大小 |
void | setTypeface(Typeface typeface) | 设置文字格式类型(Typeface.BOLD、Typeface.BOLD_ITALIC、Typeface.ITALIC、Typeface.NORMAL) |
void | setStrikeThruText(boolean strikeThruText) | 设置文本删除线,即STRIKE_THRU_TEXT_FLAG标识位 |
void | setUnderlineText(boolean underlineText) | 设置文本下划线,即UNDERLINE_TEXT_FLAG标识位 |
void | setTextSkewX(float skewX) | 设置文本倾斜长度(默认0,官方推荐的-0.25f是斜体) |
void | setTextAlign(Paint.Align align) | 设置文本对齐方式(Paint.Align.CENTER、Paint.Align.LEFT、Paint.Align.RIGHT) |
float | getFontSpacing() | 根据字体当前所设置的类型、大小返回其行间距 |
float | getLetterSpacing() | 返回字符之间的间距 |
int | breakText(String text, boolean measureForwards, float maxWidth, float[] measuredWidth) | 测量文本,若超出maxsize 则停止测量 |
void | getTextBounds(String text, int start, int end, Rect bounds) | 返回包含所有字符的最小矩形,即参数中的bounds(暗含的原点为(0,0)) |
int | getTextWidths(String text, float[] widths) | 返回文本的字符数,参数widths数组中含有每个字符的宽度(注:一个中文是2个字符) |
float | measureText(String text, int start, int end) | 返回文本的宽度(注:只是一个粗略的结果 ) |
(2)基线baseline
下面需要强调的是与文字绘制相关的一个重点——基线。注意此部分的标题是“文字绘制”,但文字不仅限于中文,另包含标点符号、英文等其他语言,那么需要注意一个细节:文字之间的排序规则如何保证它们看起来错落有致,像一条直线。
查看以上图片中的三行文字:
规则概念
通过以上的例子简单认识到文字排序的规则:
西文字体中线条含义
由于西文的特殊性引入了“基线”的概念,特别是在混排时,中西文也是根据“基线”来进行调整。在了解“基线”核心——字母的主体底部对齐的位置,这条“基线”具体是指哪个部分呢?
以上就是这几条线的定义,最简单的是接触文字的顶部ascent 和 底部descent;再者是文字的最大顶部top 和 最大底部bottom,它与文字相间稍许距离;最后是基线baseline,位于一个中下方的位置,即字母的主体底部对齐的位置。
获取线条高度API
FontMetrics fontMetrics = mPaint.getFontMetrics();
fontMetrics.top;
fontMetrics.ascent;
fontMetrics.descent;
fontMetrics.bottom;
如上代码,官方提供了API来获取对应线条的值,但时只能根据API获取 最大顶部top、最大底部bottom、文字顶部ascent、文字底部descent这四个值。“基线”本身是一个抽象的概念,因此所有的四个值都是以基线baseLine为基准来计算的。即baseline以上的值(最大顶部top、文字顶部ascent)是负的,以下的值(最大底部bottom、文字底部descen)是正的。
Canvas.drawText(String text, float x, float y, Paint paint)
在自定义控件中难免涉及到文字的绘画,在Canvas类中有一个方法如上,顾名思义就是根据画笔、坐标设置绘画出文字,需要注意的是:根据给出的x、y坐标绘画文字的细节,这个坐标是基于文字的左上角还是最左部中心点?
都不是,此坐标是文字最左部基线处,即y坐标就是文字的基线处! 在做自定义控件的时候canvas.drawText(x,y)
这个y并不是text的左上角,而是以baseline为基准的。
因此在绘制自定义控件时,若未理解以上重点,很难达到“对齐”效果。
问题定位:如何可以做到真正的“对齐”?
唯一的难点是我们绘制文字时指定的坐标(x,y)是文字的基线baseline部分,并非是其top位置,导致文字难以对齐。如此看来,我们一般可轻易获取到一个已知的y值,这个y值可能是文字部分的topY值,或者是其centerY值,但不应直接将y值设置到drawText
方法中,应当根据此y值求得baselineY值,再调用方法绘制。
分情况讨论
如上问题已定位到在已知一个 y值的情况下,如何求得baselineY值?这里分两种最经典的情况进行讨论:自定义控件内部是纵向和横向,即已知的y值是topY和centerY。以此进行举例:
(1)已知top值,求baselineY值?(例如内部是纵向的自定义控件)
这个top值,也就是已知的y值,也是手机屏幕坐标中的y值,但要是要获取基于手机屏幕的baseline值,还需要加上baseline线到top线之间的距离。前面已经讲解过,可根据API获得基于baseline坐标的top值(即baseline线到top线之间的距离),需注意该数值是负数,因此计算时使用“-”而不是“+”,公式如下:
//已知绘制文字部分的 top值(基于手机屏幕坐标),求baseline值(基于手机屏幕坐标)?
......
FontMetrics fontMetrics = mPaint.getFontMetrics();
baselineY = topY - fontMetrics.top; //⭐️⭐️⭐️⭐️⭐️
......
canvas.drawText("abcdefg", 100, baselineY, mPaint);
实例
查看上图实例展示,已知红线的y坐标,而我们的需求就是将文字摆放在红线下方。 此时红线的y坐标相当于是文字部分的top值,这里将文字赋予了两种不同的y值进行绘制:
canvas.drwaText
进行绘制,再次验证了之前的理论:该坐标是以文字的基线为标准的,即给出的坐标实际上是绘画出文字的基线最左侧坐标,从而导致文字整体偏上,与红线交叠,与需求严重不符!(2)已知centerY值,求baselineY值?(例如内部是横向的自定义控件)
这个已知的y值,也就是文字部分需要对齐的标准值,即文字部分的centerY值(下图中的紫线,也是文字的中心线),但要是要获取基于手机屏幕的baseline值,还需要加上baseline线到centerY线之间的距离。如下图展示,这段距离用a表示,同样借助已知API求出已知值,由于centerY是中心线,因此centerY到top、bottom的距离相同,可求出centerY到bottom的距离,再根据API获得基于baseline到bottom的距离,前者减去后者即可得到a的值。
//已知绘制文字部分的 centerY值(基于手机屏幕坐标),求baseline值(基于手机屏幕坐标)?
......
FontMetrics fontMetrics = mPaint.getFontMetrics();
baselineY = centerHeight + (fontMetrics.bottom-fontMetrics.top)/2 - fontMetrics.bottom;
//⭐️⭐️⭐️⭐️⭐️
......
canvas.drawText("abcdefg", 100, baselineY, mPaint);
实例
查看上图实例展示,已知红线的y坐标,而我们的需求就是将文字与红线对齐,即红线就是文字部分的中心线。 此时红线的y坐标相当于是文字部分的centerY值,这里将文字赋予了两种不同的y值进行绘制:
canvas.drwaText
进行绘制,与上例同理,与需求严重不符!小结
通过以上讲解得知在进行文字绘制时,需要注意其“基线”概念、明确字体内部几条线的API获取方法、Canvas 中drawText
的内含重点,若要真正做到摆放整齐、对齐,其y值需要另外计算。
在了解Paint画笔的基本用法API,完成简单的自定义控件是不费吹灰之力。例如上图中的进度条展示,这是一个典型又简单的自定义控件。
定义一个CustomProgressBar 类,继承自View ,重写它的onDraw
方法进行绘制,同时定义资源文件attr.xml
中的相关属性。以上是自定义控件的常规步骤,重点就在于重写的onDraw
方法,主要可分为以下3个步骤:
以上3个步骤都是通过设置Paint画笔相关属性,最后使用Canvas 的简单API绘制方法进行绘制,第1、3步骤绘制图形较为常规,都是一些简单的API方法,主要是第2步骤中绘制文字部分涉及到以上所讲解的有关文字“基线”问题,在设置其绘画位置需要先通过公式进行简单计算,才能达到“对齐中心”的效果。
核心代码如下:(完整代码文章末尾提供)
public class CustomProgressBar extends View {
......
//成员变量属性值
public CustomProgressBar(Context context, AttributeSet attrs) {
super(context, attrs);
paint = new Paint();
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomProgressBar);
//获取自定义控件属性
......
typedArray.recycle();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//1. 画默认的大圆环
int center = getWidth()/2;//中心坐标点
float radius = center-roundWidth/2;//半径
paint.setColor(roundColor);
paint.setStyle(Paint.Style.STROKE);//设置空心(描边)
paint.setStrokeWidth(roundWidth);//圆环的宽度
paint.setAntiAlias(true);
canvas.drawCircle(center, center, radius , paint);
//2. 画文字------进度百分比
paint.setColor(textColor);
paint.setStrokeWidth(0);//圆环的宽度
paint.setTextSize(textSize);
paint.setTypeface(Typeface.DEFAULT_BOLD);
int percent = (int) (progress/(float)max * 100);
if(percent!=0){
canvas.drawText(percent+"%", (getWidth()-paint.measureText(percent+"%"))/2f,
//y公式: float baselineY = centerY + (fontMetrics.bottom-fontMetrics.top)/2 - fontMetrics.bottom
getWidth()/2f-(paint.descent()+paint.ascent())/2f,
paint);
}
//3. 画圆弧
RectF oval = new RectF(center-radius, center-radius, center+radius, center+radius);//矩形区域,定义圆弧的形状大小
paint.setColor(roundProgressColor);
paint.setStrokeWidth(roundWidth);
paint.setStyle(Paint.Style.STROKE);
canvas.drawArc(oval , 0, 360*progress/max, false, paint);
}
//属性的get、set方法
......
}
Shader setShader(Shader shader)
以上是Paint类的一个高级渲染使用方法,设置Shader到画笔中。这里涉及到一个新类Shader,以下是对它的解释:
着色器是在绘制过程中返回水平跨度颜色的对象的一个基础类。调用paint.setShader方法将Shader的子类设置其中,再用该画笔Paint 绘制的任何对象(除位图之外)将从着色器中获取它的颜色。
以上解释仍旧有些模糊,并且将Shader翻译成“着色器”,它是一个特有的概念,与直接设置颜色setColor
不同的是,setShader
它会设置出一套颜色变化的规则,而不单出只是设置一种颜色。理论解释到此为止,接下来直接用其子类来实践高级渲染的效果,Shader的子类有以下5个:
BitmapShader(Bitmap bitmap, Shader.TileMode tileX, Shader.TileMode tileY)
API含义:使用一张位图作为纹理来对某一区域进行填充,参数依次:
API参数注意:此API作用是对某一区域进行图片填充,后两位参数类型是 Shader.TileMode,具体有以下三种:
原图:
CLAMP类型:
REPEAT类型:
MIRROR类型:
三种类型展示效果如上,可知若并非是纯色彩形的Bitmap,REPEAT、MIRROR类型的展示效果有些眼花缭乱,并非十分理想。若图片大小和展示大小差异较大,CLAMP类型下的显示效果也不尽人意。因此,应当根据实际情况合理选择类型展示。
自定义控件实例——圆形图像控件
圆形图像控件在各APP中运用广泛,在网上已有多种实现方式,不乏有一些优化版本,而此处只是为了实践以上API所实现的一个简单版本,效果如下:
为了实践BimapShader位图的图像渲染器,实现以上效果的简单版本,这里只需要对图片做出裁剪,同时运用到了Matrix 来设置像素矩阵调整大小,解决图片宽高不一致而无法获取中心图片的问题。
以下是核心代码,完整源码文章末尾提供
public class CircleImageView extends View {
......
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
BitmapShader bitmapShader = new BitmapShader(mBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT);
mPaint.setShader(bitmapShader);
//设置像素矩阵,来调整大小,为了解决宽高不一致的问题。
float scale = Math.max(mWidth, mHeight)*1.0f/Math.min(mWidth, mHeight);
Matrix matrix = new Matrix();
matrix.setScale(scale, scale);//缩放比例
bitmapShader.setLocalMatrix(matrix);
canvas.drawCircle(Math.min(mWidth, mHeight)/2f, scale*Math.max(mWidth, mHeight)/2f, Math.max(mWidth, mHeight)/2f, mPaint);
}
}
LinearGradient(float x0, float y0, float x1, float y1, int[] colors, float[] positions, Shader.TileMode tile);
API含义:实现某一区域内颜色的线性渐变效果,参数依次是:
API参数注意:这里的坐标位置参数指定并未代表只有这一块区域颜色线性变化,而是指颜色沿着这两点连线趋势线性渐变!例如下图中,xy皆变化,则变化趋势是沿着斜线的;y不变化,则颜色是沿着横向渐变;x不变化,颜色是沿着竖向渐变。
使用场景:歌词滚动。
LinearGradient linearGradient = new LinearGradient(100, 100, 600, 600, colors, null, TileMode.REPEAT);
paint.setShader(linearGradient);
canvas.drawRect(100, 100, 600, 600, paint);
自定义控件实例——滚动歌词
LinearGradient的作用是线性渲染,即实现某一区域内颜色的线性渐变,上图也简单演示了该效果,但实际中实用最多的情况是“滚动歌词”。例如移动端上各音乐APP展示的歌词滚动:颜色的变化代表歌曲的演唱进度,这种颜色变化的原理正是线性渐变。以下为了实践此API实现了一个简单的自定义控件实例,效果如下:
以上只提供了一句歌词的颜色滚动,只是为了呈现此效果的实际运用,颇为简陋,读者了解其思路后可举一反三自行完善。其原理就是充分利用 线性渐变 API,步骤如下:
注意:根据以上API位置参数的讲解,这里的颜色线性渐变是沿着横轴变换,因此y轴不变;另外源码中的一个优化:由于onDraw
方法会被多次调用,因此一些基本参数配置代码放到onSizeChanged
方法中。
以下是核心代码,完整源码文章末尾提供
public class LinearGradientTextView extends TextView {
//成员变量
......
/*
* onDraw方法会被调用多次,因此一些基本设置放到onSizeChanged中
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
paint = getPaint();
//GradientSize=3个文字的大小
String text = getText().toString();
float textWidth = paint.measureText(text);
int GradientSize =(int) (3*textWidth/text.length());
linearGradient = new LinearGradient(-GradientSize, 0, 0, 0, colors, new float[]{0,0.5f,1}, TileMode.CLAMP);//边缘融合
paint.setShader(linearGradient);
matrix = new Matrix();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float textWidth = getPaint().measureText(getText().toString());
translateX += deltaX;
if(translateX > textWidth + 1|| translateX < 1){
return;
}
matrix.setTranslate(translateX, 0);
linearGradient.setLocalMatrix(matrix);
//歌词颜色不断变化
postInvalidateDelayed(50);
}
}
public RadialGradient (float x, float y, float radius, int[] colors, float[] positions, Shader.TileMode tile);
API含义:实现某一环形区域内颜色的环形渐变效果,参数依次是:
API参数注意:以上API解释为是实现环形区域内的颜色渐变,查看参数
,colors颜色数组代表需要指定的颜色渐变顺序。而positions位置数组则对应颜色数组,指定颜色占据环形区域内的位置。
使用场景:水波纹效果(充电水波纹扩散效果)、调色板。
radialGradient = new RadialGradient(500, 500, 300, colors, null, TileMode.REPEAT);
paint.setShader(radialGradient);
canvas.drawCircle(500, 500, 300, paint);
public SweepGradient (float cx, float cy, int[] colors, float[] positions)
API含义:扫描渲染,就是以某个点位中心旋转一周所形成的效果。参数依次是:
注意: SweepGradient和RadialGradient的区别,一个是扫描式的渲染(一个颜色占据区域的一部分),一个是环形式的渲染(一个颜色占据环形的一圈),因此前者创建的参数中并无半径、平铺方式。
使用场景:雷达扫描效果,手机卫士垃圾扫描。(动态改变颜色位置即可达到目的)
sweepGradient = new SweepGradient(500, 500, colors, null);
paint.setShader(sweepGradient);
canvas.drawCircle(500, 500, 300, paint);
ComposeShader(Shader shaderA, Shader shaderB, PorterDuff.Mode mode)
API含义:渲染效果的叠加,参数依次:
看到PorterDuff就知道什么了吧?比如将BitmapShader与LinearGradient的混合渲染效果等。
上面这张图从一定程度上形象地说明了运用PorterDuff.Mode进行图像合成的作用,两个图形一圆一方通过一定的计算产生了不同的合成效果,我们在实际工作中需要做图片处理时可以参考这张图的示意快速地选择合适的Mode。
PorterDuff.Mode类型全解:
本篇内容核心围绕Paint使用API展开进行归纳总结,大部分API方法易懂,使用简单。主要重点在于文字绘制中对“基线”进行深度剖析,其运算公式的总结需要注意,还有第二大点开始介绍Paint的高级渲染方法—-五大“着色器”学习归纳。
整体而言,此篇文章较为基础,更偏向科普性质,笔者同时结合API做了大量的简单demo,体现API之间使用的差异性,并随之拓展实践了几个简单的自定义控件(进度条、圆形头像、歌词显示器)。由于Paint部分较多,还有过滤器、矩阵变换相关内容留给下一篇继续归纳。
笔者废话
在我之前做的项目中涉及到一些或简单、或复杂的自定义控件等,大部分都是百度copy他人现成的结果学习使用,很少亲自动手实践,这一整个界面绘制流程并不熟练,在之前校招面试还被问到给出一个具体UI需求的实现思路,确实认识到其重要性,狠下心来系统性地学习归纳UI相关知识点,因而得出此系列。(不得不说写博客归纳这些知识点时间消耗太大了,进度颇慢,但还是会坚持完成,其收获我心自知!我觉得我可以不做程序员当编辑写文章去咯~)
(代码整理中,后续会提供)
若有错误,欢迎指教~