赞
踩
显示在屏幕上的各种视图控件,如TextView、LinearLayout等。
主要分为两类:
Android中的UI组件都由View、ViewGroup共同组成。
视图的核心类是:View类。
View类是Android中各种组件的基类,如View是ViewGroup基类。
View的构造函数:共有4个,自定义View必须重写至少一个构造函数,具体如下:
// 构造函数1 // 调用场景:View是在Java代码里面new的 public CarsonView(Context context) { super(context); } // 构造函数2 // 调用场景:View是在.xml里声明的 // 自定义属性是从AttributeSet参数传进来的 public CarsonView(Context context, AttributeSet attrs) { super(context, attrs); } // 构造函数3 // 应用场景:View有style属性时 // 一般是在第二个构造函数里主动调用;不会自动调用 public CarsonView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } // 构造函数4 // 应用场景:View有style属性时、API21之后才使用 // 一般是在第二个构造函数里主动调用;不会自动调用 public CarsonView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); }
这里有几个不错的指导原则:
在代码中创建View的时候用View(Context)。
当从XML inflate view的时候重写View(Context, AttributeSet)。
剩余的知识其实可以忽略,因为你很可能并不需要。
构造函数参数最多有四个。简单总结下:
Context - View中随处都会用到。
AttributeSet - XML属性(当从XML inflate的时候)。
int defStyleAttr - 应用到View的默认风格(定义在主题中)。
int defStyleResource - 如果没有使用defStyleAttr,应用到View的默认风格。
除了Context,其它的参数只是用来通过XML属性配置View的初始状态(从布局,style以及theme中)。
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/icon"
/>
ImageView中有三个属性,你有没有想过它们是从哪里来的?它们可不是凭空产生的;实际上是通过把这些属性明确的声明为系统需要处理的东西。比如,src就是在这里定义的:
<declare-styleable name="ImageView">
<!-- Sets a drawable as the content of this ImageView. -->
<attr name="src" format="reference|color" />
<!-- ...snipped for brevity... -->
</declare-styleable>
因此,上面的代码会产生R.styleable.ImageView和R.styleable.ImageView_src。
这些资源是什么东西呢?
如果你把它想象成一个cursor,R.styleable.[name]就可以看成是一个待查找的column列表,而R.styleable.[name]_[attribute]就是一个column的索引。
上面写的XML是以一个AttributeSet的形式传递给View的。
通常你不直接使用AttributeSet。而是使用Theme.obtainStyledAttributes()。
这是因为原始的属性通常需要引用和应用样式。比如,如果你在XML中定义了style=@style/MyStyle,这个方法先获取MyStyle,然后把它的属性混合进去。最终obtainStyledAttributes() 返回一个TypedArray,你可以用它来获取属性值。
这个过程简化之后就像这样:
public ImageView(Context context, AttributeSet attrs) {
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.ImageView, 0, 0);
Drawable src = ta.getDrawable(R.styleable.ImageView_src);
setImageDrawable(src);
ta.recycle();
}
这里我们向obtainStyledAttributes()传递了两个参数。
当获得了TypedArray之后,我们就可以获取单个属性了。我们需要使用 R.styleable.ImageView_src来正确索引数组中的属性。
通常我们可以一次获取多个属性。实际上,真实ImageView的实现要比上面的复杂多了。
你可能注意到了obtainStyledAttributes()的最后两个参数我使用的值为0。实际上它们是两个资源引用-defStyleAttr 和 defStyleRes。这里我将关注的是第一个。
defStyleAttr是obtainStyledAttributes()最令人困惑的参数。其实它就是一个为某个类型的View定义一个基本样式的方法。比如,如果你想一次性修改app中所有TextView,你可以在主题中设置textViewStyle。如果不存在这个东西的话,你就需要手动为每个TextView定义样式了。
以此为例:
首先,它是一个属性。这里是安卓系统定义textViewStyle的地方:
<resources>
<declare-styleable name="Theme">
<!-- ...snip... -->
<!-- Default TextView style. -->
<attr name="textViewStyle" format="reference" />
<!-- ...etc... -->
</declare-styleable>
</resource>
<resources>
<style name="Theme">
<!-- ...snip... -->
<item name="textViewStyle">@style/Widget.TextView</item>
<!-- ...etc... -->
</style>
</resource>
<activity
android:name=".MyActivity"
android:theme="@style/Theme"
/>
TypedArray ta = theme.obtainStyledAttributes(attrs, R.styleable.TextView, R.attr.textViewStyle, 0);
最终的效果是,任何没有在AttributeSet中定义的属性都将用textViewStyle引用的样式填充。
defStyleRes就要比defStyleAttr简单多了。它只是一个style资源(@style/Widget.TextView)。不需要间接的通过theme。
只有在defStyleAttris没有定义的情况下,才会应用style中的defStyleRes(设置为0,或者没有在主题中设置)。
我们现在有了一些列通过obtainStyledAttributes()获取属性值的方式。这里是它们的优先级,从高到低:
1.定义在AttributeSet中的值
2.定义在AttributeSet中的style资源(比如:style=@style/blah)
3.defStyleAttr指定的默认样式。
4.defStyleResource指定的默认样式资源(如果没有defStyleAttr)
5.主题中的值
换句话说,任何直接在XML中设置的属性都将首先被使用。但是如果你没有设置,这些属性也可以从其它地方获取。
了解了以上各个参数,我们来看构造函数。总共有四个构造函数,依次增加一个参数:
View(Context)
View(Context, AttributeSet)
View(Context, AttributeSet, defStyleAttr)
View(Context, AttributeSet, defStyleAttr, defStyleRes)
注意:最后一个是在API 21添加的。因此除非你的minSdkVersion为21,否则不要使用它。(如果你想使用defStyleRes只需自己调用obtainStyledAttributes()就好了)
它们是串联的,因此如果你调用了一个,所有的都会被调用(通过super)。串联还意味着你只需重写你需要的构造函数。一般来说,你只需实现前两个(一个用于代码,一个用于XML inflation)。
我一般这样设置我的自定义View:
SomeView(Context context) {
this(context, null);
}
SomeView(Context context, AttributeSet attrs) {
// Call super() so that the View sets itself up properly
super(context, attrs);
// ...Setup View and handle all attributes here...
}
只需要这个两个参数的构造方法你就能随意的使用obtainStyledAttributes()了。实现默认样式的一个简便方法是直接提供defStyleRes给它。那样你就不需要忍受defStyleAttr的痛苦了(其实它更多时候是一个framework工具,app中通常不需要)。
注意:在View的绘制过程中,永远都是从View树结构的根节点开始(即从树的顶端开始),一层一层、一个个分支地自上而下遍历进行(即树形递归),最终计算整个View树中各个View,从而最终确定整个View树的相关属性。
Android的坐标系定义为:
具体如下图:
注:区别于一般的数学坐标系
视图的位置由四个顶点决定,如图1-3所示的A、B、C、D。
视图的位置是相对于父控件而言的,四个顶点的位置描述分别由四个与父控件相关的值决定:
具体如图1-4所示。
可根据视图位置的左上顶点、右下顶点进行记忆:
视图的位置获取是通过View.getXXX()方法进行获取。
获取顶部距离(Top):getTop()
获取左边距离(Left):getLeft()
获取右边距离(Right):getRight()
获取底部距离(Bottom):getBottom()
//get() :触摸点相对于其所在组件坐标系的坐标
event.getX();
event.getY();
//getRaw() :触摸点相对于屏幕默认坐标系的坐标
event.getRawX();
event.getRawY();
具体如下图:
这会涉及到画布的相关操作(旋转)、正余弦函数计算等,即会涉及到角度(angle)与弧度(radian)的相关知识。
角度和弧度都是描述角的一种度量单位,区别如下图:
在默认的屏幕坐标系中角度增大方向为顺时针。
注:在常见的数学坐标系中角度增大方向为逆时针。
Android支持的颜色模式主要包括:
这里需要特别注意的是:
示例说明:ARGB8888,表示有四个通道(ARGB);每个对应的通道均用8位来描述。
以ARGB8888为例介绍颜色定义:
主要分为xml定义 / java定义。
/** * 定义方式1:xml * 在/res/values/color.xml文件中定义 */ <?xml version="1.0" encoding="utf-8"?> <resources> //定义了红色(没有alpha(透明)通道) <color name="red">#ff0000</color> //定义了蓝色(没有alpha(透明)通道) <color name="green">#00ff00</color> </resources> // 在xml文件中以”#“开头定义颜色,后面跟十六进制的值,有如下几种定义方式: #f00 //低精度 - 不带透明通道红色 #af00 //低精度 - 带透明通道红色 #ff0000 //高精度 - 不带透明通道红色 #aaff0000 //高精度 - 带透明通道红色 /** * 定义方式2:Java */ // 使用Color类定义颜色 int color = Color.GRAY; //灰色 // Color类使用ARGB值表示 int color = Color.argb(127, 255, 0, 0); //半透明红色 int color = 0xaaff0000; //带有透明度的红色
主要分为xml应用 / java引用。
/** * 引用方式1:xml */ // 1. 在style文件中引用 <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> <item name="colorPrimary">@color/red</item> </style> // 2. 在layout文件中引用 android:background="@color/red" // 3. 在layout文件中创建并使用颜色 android:background="#ff0000" /** * 引用方式2:Java */ //方法1 int color = getResources().getColor(R.color.mycolor); //方法2(API 23及以上) int color = getColor(R.color.myColor);
// 在主线程中,Activity对象被创建后:
// 1. 自动将DecorView添加到Window中 & 创建ViewRootImpll对象
root = new ViewRootImpl(view.getContent(),display);
// 3. 将ViewRootImpll对象与DecorView建立关联
root.setView(view,wparams,panelParentView)
顶层View
即 Android 视图树的根节点;同时也是 FrameLayout 的子类。
显示 & 加载布局
View层的事件都先经过DecorView,再传递到View。
内含1个竖直方向的LinearLayout,分为2部分:上 = 标题栏(titlebar)、下 = 内容栏(content)
在Activity中通过==setContentView()==所设置的布局文件其实是被加到内容栏之中的,成为其唯一子View = id为content的FrameLayout中。
// 在代码中可通过content得到对应加载的布局
// 1. 得到content
ViewGroup content = (ViewGroup)findViewById(android.R.id.content);
// 2. 得到设置的View
ViewGroup rootView = (ViewGroup) content.getChildAt(0);
View的绘制流程开始于:ViewRootImpl对象的performTraversals()。
/**
* 源码分析:ViewRootImpl.performTraversals()
*/
private void performTraversals() {
// 1. 执行measure流程
// 内部会调用performMeasure()
measureHierarchy(host, lp, res,desiredWindowWidth, desiredWindowHeight);
// 2. 执行layout流程
performLayout(lp, mWidth, mHeight);
// 3. 执行draw流程
performDraw();
}
从上面的performTraversals()可知:View的绘制流程从顶级View(DecorView)的ViewGroup开始,一层一层从ViewGroup至子View遍历测绘。
自上而下遍历、由父视图到子视图、每一个 ViewGroup 负责测绘它所有的子视图,而最底层的 View 会负责测绘自身。
绘制的流程 = measure过程、layout过程、draw过程,具体如下:
测量View的宽 / 高
1.在某些情况下,需要多次测量(measure)才能确定View最终的宽/高;
2.该情况下,measure过程后得到的宽 / 高可能不准确;
3.此处建议:在layout过程中onLayout()去获取最终的宽 / 高。
布局参数类
1.ViewGroup 的子类(RelativeLayout、LinearLayout)有其对应的 ViewGroup.LayoutParams 子类
2.如:RelativeLayout的 ViewGroup.LayoutParams子类
= RelativeLayoutParams
指定视图View 的高度(height) 和 宽度(width)等布局参数。
通过以下参数指定
参数 | 解释 |
---|---|
具体值 | dp / px |
fill_parent | 强制性使子视图的大小扩展至与父视图大小相等(不含 padding ) |
match_parent | 与fill_parent相同,用于Android 2.3 & 之后版本 |
wrap_content | 自适应大小,强制性地使视图扩展以便显示其全部内容(含 padding ) |
android:layout_height="wrap_content" //自适应大小
android:layout_height="match_parent" //与父视图等高
android:layout_height="fill_parent" //与父视图等高
android:layout_height="100dip" //精确设置高度值为 100dip
构造函数 = View的入口,可用于初始化 & 获取自定义属性。
// View的构造函数有四种重载 public DIY_View(Context context){ super(context); } public DIY_View(Context context,AttributeSet attrs){ super(context, attrs); } public DIY_View(Context context,AttributeSet attrs,int defStyleAttr ){ super(context, attrs,defStyleAttr); // 第三个参数:默认Style // 默认Style:指在当前Application或Activity所用的Theme中的默认Style // 且只有在明确调用的时候才会生效, } public DIY_View(Context context,AttributeSet attrs,int defStyleAttr ,int defStyleRes){ super(context, attrs,defStyleAttr,defStyleRes); } // 最常用的是1和2 }
测量规格(MeasureSpec)是由测量模式(mode)和测量大小(size)组成,共32位(int类型),其中:
测量模式(mode):占测量规格(MeasureSpec)的高2位;
测量大小(size):占测量规格(MeasureSpec)的低30位。
测量模式(Mode)的类型有3种:
// 1. 获取测量模式(Mode)
int specMode = MeasureSpec.getMode(measureSpec)
// 2. 获取测量大小(Size)
int specSize = MeasureSpec.getSize(measureSpec)
// 3. 通过Mode 和 Size 生成新的SpecMode
int measureSpec=MeasureSpec.makeMeasureSpec(size, mode);
public class MeasureSpec { // 进位大小 = 2的30次方 // int的大小为32位,所以进位30位 = 使用int的32和31位做标志位 private static final int MODE_SHIFT = 30; // 运算遮罩:0x3为16进制,10进制为3,二进制为11 // 3向左进位30 = 11 00000000000(11后跟30个0) // 作用:用1标注需要的值,0标注不要的值。因1与任何数做与运算都得任何数、0与任何数做与运算都得0 private static final int MODE_MASK = 0x3 << MODE_SHIFT; // UNSPECIFIED的模式设置:0向左进位30 = 00后跟30个0,即00 00000000000 // 通过高2位 public static final int UNSPECIFIED = 0 << MODE_SHIFT; // EXACTLY的模式设置:1向左进位30 = 01后跟30个0 ,即01 00000000000 public static final int EXACTLY = 1 << MODE_SHIFT; // AT_MOST的模式设置:2向左进位30 = 10后跟30个0,即10 00000000000 public static final int AT_MOST = 2 << MODE_SHIFT; /** * makeMeasureSpec()方法 * 作用:根据提供的size和mode得到一个详细的测量结果吗,即measureSpec **/ public static int makeMeasureSpec(int size, int mode) { return size + mode; // measureSpec = size + mode;此为二进制的加法 而不是十进制 // 设计目的:使用一个32位的二进制数,其中:32和31位代表测量模式(mode)、后30位代表测量大小(size) // 例如size=100(4),mode=AT_MOST,则measureSpec=100+10000...00=10000..00100 } /** * getMode()方法 * 作用:通过measureSpec获得测量模式(mode) **/ public static int getMode(int measureSpec) { return (measureSpec & MODE_MASK); // 即:测量模式(mode) = measureSpec & MODE_MASK; // MODE_MASK = 运算遮罩 = 11 00000000000(11后跟30个0) //原理:保留measureSpec的高2位(即测量模式)、使用0替换后30位 // 例如10 00..00100 & 11 00..00(11后跟30个0) = 10 00..00(AT_MOST),这样就得到了mode的值 } /** * getSize方法 * 作用:通过measureSpec获得测量大小size **/ public static int getSize(int measureSpec) { return (measureSpec & ~MODE_MASK); // size = measureSpec & ~MODE_MASK; // 原理类似上面,即 将MODE_MASK取反,也就是变成了00 111111(00后跟30个1),将32,31替换成0也就是去掉mode,保留后30位的size } }
View的MeasureSpec值计算取决于两个因素:
View自身的布局参数(LayoutParams)
父容器的测量规格(MeasureSpec)
即View的大小是由自身布局参数(LayoutParams)和父容器的测量规格(MeasureSpec)共同决定的。
MeasureSpec值的具体计算逻辑封装在getChildMeasureSpec()里,具体计算逻辑如下源码所示:
/** * 源码分析:getChildMeasureSpec() * 作用:根据父视图的MeasureSpec & 布局参数LayoutParams,计算单个子View的MeasureSpec * 注:子view的大小由父view的MeasureSpec值 和 子view的LayoutParams属性 共同决定 **/ public static int getChildMeasureSpec(int spec, int padding, int childDimension) { // 参数说明 // * @param spec 父view的详细测量值(MeasureSpec) // * @param padding view当前尺寸的的内边距和外边距(padding,margin) // * @param childDimension 子视图的布局参数(宽/高) //父view的测量模式 int specMode = MeasureSpec.getMode(spec); //父view的大小 int specSize = MeasureSpec.getSize(spec); //通过父view计算出的子view = 父大小-边距(父要求的大小,但子view不一定用这个值) int size = Math.max(0, specSize - padding); //子view想要的实际大小和模式(需要计算) int resultSize = 0; int resultMode = 0; //通过父view的MeasureSpec和子view的LayoutParams确定子view的大小 // 当父view的模式为EXACITY时,父view强加给子view确切的值 //一般是父view设置为match_parent或者固定值的ViewGroup switch (specMode) { case MeasureSpec.EXACTLY: // 当子view的LayoutParams>0,即有确切的值 if (childDimension >= 0) { //子view大小为子自身所赋的值,模式大小为EXACTLY resultSize = childDimension; resultMode = MeasureSpec.EXACTLY; // 当子view的LayoutParams为MATCH_PARENT时(-1) } else if (childDimension == LayoutParams.MATCH_PARENT) { //子view大小为父view大小,模式为EXACTLY resultSize = size; resultMode = MeasureSpec.EXACTLY; // 当子view的LayoutParams为WRAP_CONTENT时(-2) } else if (childDimension == LayoutParams.WRAP_CONTENT) { //子view决定自己的大小,但最大不能超过父view,模式为AT_MOST resultSize = size; resultMode = MeasureSpec.AT_MOST; } break; // 当父view的模式为AT_MOST时,父view强加给子view一个最大的值。(一般是父view设置为wrap_content) case MeasureSpec.AT_MOST: // 道理同上 if (childDimension >= 0) { resultSize = childDimension; resultMode = MeasureSpec.EXACTLY; } else if (childDimension == LayoutParams.MATCH_PARENT) { resultSize = size; resultMode = MeasureSpec.AT_MOST; } else if (childDimension == LayoutParams.WRAP_CONTENT) { resultSize = size; resultMode = MeasureSpec.AT_MOST; } break; // 当父view的模式为UNSPECIFIED时,父容器不对view有任何限制,要多大给多大 // 多见于ListView、GridView case MeasureSpec.UNSPECIFIED: if (childDimension >= 0) { // 子view大小为子自身所赋的值 resultSize = childDimension; resultMode = MeasureSpec.EXACTLY; } else if (childDimension == LayoutParams.MATCH_PARENT) { // 因为父view为UNSPECIFIED,所以MATCH_PARENT的话子类大小为0 resultSize = 0; resultMode = MeasureSpec.UNSPECIFIED; } else if (childDimension == LayoutParams.WRAP_CONTENT) { // 因为父view为UNSPECIFIED,所以WRAP_CONTENT的话子类大小为0 resultSize = 0; resultMode = MeasureSpec.UNSPECIFIED; } break; } return MeasureSpec.makeMeasureSpec(resultSize, resultMode); }
总结如下:
其中的规律总结:(以子View为标准,横向观察)
注:
区别于顶级View(即DecorView)的测量规格MeasureSpec计算逻辑:取决于 自身布局参数 & 窗口尺寸
measure过程 根据View的类型分为2种情况:
在无现成的控件View满足需求、需自定义单一View时。
如:制作一个支持加载网络图片的ImageView控件
注:自定义View在多数情况下都有替代方案:图片 / 组合动画,但二者可能会导致内存耗费过大,从而引起内存溢出等问题。
/** * 源码分析:measure() * 定义:Measure过程的入口;属于View.java类 & final类型,即子类不能重写此方法 * 作用:基本测量逻辑的判断 */ public final void measure(int widthMeasureSpec, int heightMeasureSpec) { // 参数说明:View的宽 / 高测量规格 ... int cacheIndex = (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT ? -1 : mMeasureCache.indexOfKey(key); if (cacheIndex < 0 || sIgnoreMeasureCache) { onMeasure(widthMeasureSpec, heightMeasureSpec); // 计算视图大小 ->>分析1 } else { ... } /** * 分析1:onMeasure() * 作用:a. 根据View宽/高的测量规格计算View的宽/高值:getDefaultSize() * b. 存储测量后的View宽 / 高:setMeasuredDimension() */ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 参数说明:View的宽 / 高测量规格 setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec)); // setMeasuredDimension() :获得View宽/高的测量值 ->>分析2 // 传入的参数通过getDefaultSize()获得 ->>分析3 } /** * 分析2:setMeasuredDimension() * 作用:存储测量后的View宽 / 高 * 注:该方法即为我们重写onMeasure()所要实现的最终目的 */ protected final void setMeasuredDimension(int measuredWidth, int measuredHeight) { //参数说明:测量后子View的宽 / 高值 // 将测量后子View的宽 / 高值进行传递 mMeasuredWidth = measuredWidth; mMeasuredHeight = measuredHeight; mPrivateFlags |= PFLAG_MEASURED_DIMENSION_SET; } // 由于setMeasuredDimension()的参数是从getDefaultSize()获得的 // 下面继续看getDefaultSize()的介绍 /** * 分析3:getDefaultSize() * 作用:根据View宽/高的测量规格计算View的宽/高值 */ public static int getDefaultSize(int size, int measureSpec) { // 参数说明: // size:提供的默认大小 // measureSpec:宽/高的测量规格(含模式 & 测量大小) // 设置默认大小 int result = size; // 获取宽/高测量规格的模式 & 测量大小 int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); switch (specMode) { // 模式为UNSPECIFIED时,使用提供的默认大小 = 参数Size case MeasureSpec.UNSPECIFIED: result = size; break; // 模式为AT_MOST,EXACTLY时,使用View测量后的宽/高值 = measureSpec中的Size case MeasureSpec.AT_MOST: case MeasureSpec.EXACTLY: result = specSize; break; } // 返回View的宽/高值 return result; }
上面提到,当测试规格的模式(mode)是UNSPECIFIED时,使用的是提供的默认大小(即getDefaultSize()的第一个参数size)。那么,提供的默认大小具体是多少呢?
答:getSuggestedMinimumWidth() / getSuggestedMinimumHeight()。具体请看下面源码分析。
protected int getSuggestedMinimumWidth() { return (mBackground == null) ? mMinWidth : max(mMinWidth,mBackground.getMinimumWidth()); } // 逻辑说明 // 1. 若View无设置背景,那么View的宽度 = mMinWidth // 即android:minWidth属性所指定的值,若无指定则为0. // 2. 若View设置了背景,View的宽度为mMinWidth和mBackground.getMinimumWidth()中的最大值 // 下面继续看mBackground.getMinimumWidth()的源码分析 /** * mBackground.getMinimumWidth()源码分析 */ public int getMinimumWidth() { final int intrinsicWidth = getIntrinsicWidth(); // 即mBackground.getMinimumWidth()的大小 = 背景图Drawable的原始宽度 return intrinsicWidth > 0 ? intrinsicWidth :0 ; // 若无原始宽度,则为0; }
对于单一View的测量流程(Measure)各个方法说明如下所示。
测量宽高的关键在于getDefaultSize(),该方法的测量逻辑如下图所示。
利用现有的多个组件根据特定的布局方式组成一个新的组件(即包含多个子View)。
如:底部导航条中的条目,一般都是上图标(ImageView)、下文字(TextView),那么这两个就可以用自定义ViewGroup组合成为一个Veiw,提供两个属性分别用来设置文字和图片,使用起来会更加方便。
从ViewGroup至子View、自上而下遍历进行(即树形递归),通过计算整个ViewGroup中各个View的属性,从而最终确定整个ViewGroup的属性。即:
1.遍历测量所有子View的尺寸(宽/高);
2.合并所有子View的尺寸(宽/高),最终得到ViewGroup父视图的测量值。
需要特别注意的是:若需进行自定义ViewGroup,则需重写onMeasure(),在下面的章节会详细讲解。
/** * 源码分析:measure() * 作用: * 1. 基本测量逻辑的判断; * 2. 调用onMeasure() * 注:与单一View measure过程中讲的measure()一致 */ public final void measure(int widthMeasureSpec, int heightMeasureSpec) { // 仅展示核心代码 // ... int cacheIndex = (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT ? -1 : mMeasureCache.indexOfKey(key); if (cacheIndex < 0 || sIgnoreMeasureCache) { // 调用onMeasure()计算视图大小 -> 分析1 onMeasure(widthMeasureSpec, heightMeasureSpec); mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT; } else { // ... } /** * 分析1:onMeasure() * 作用:遍历子View &测量 * 注:ViewGroup = 一个抽象类 = 无重写View的onMeasure(),需自身复写 **/
根据上一小节可知,单一View的measure过程对onMeasure()有统一的实现(如下代码),但为什么ViewGroup的measure过程没有呢?
/**
* onMeasure()
* 作用:a. 根据View宽/高的测量规格计算View的宽/高值:getDefaultSize()
* b. 存储测量后的View宽 / 高:setMeasuredDimension()
*/
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 参数说明:View的宽 / 高测量规格
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
// setMeasuredDimension() :获得View宽/高的测量值 ->>分析2
// 传入的参数通过getDefaultSize()获得 ->>分析3
}
原因是:onMeasure()方法的作用是测量View的宽/高值,而不同的ViewGroup(如LinearLayout、RelativeLayout、自定义ViewGroup子类等)具备不同的布局特性,这导致它们的子View测量方法各有不同,所以onMeasure()的实现也会有所不同。
因此,ViewGroup无法对onMeasure()作统一实现。这个也是单一View的measure过程与ViewGroup的measure过程最大的不同。
针对Measure流程,自定义ViewGroup的关键在于:根据需求复写onMeasure(),从而实现子View的测量逻辑。复写onMeasure()的步骤主要分为三步:
1.遍历所有子View及测量:measureChildren()
2.合并所有子View的尺寸大小,最终得到ViewGroup父视图的测量值:需自定义实现
3.存储测量后View宽/高的值:setMeasuredDimension()
具体如下所示。
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //仅展示关键代码 ... // 步骤1:遍历所有子View & 测量 -> 分析1 measureChildren(widthMeasureSpec, heightMeasureSpec); // 步骤2:合并所有子View的尺寸大小,最终得到ViewGroup父视图的测量值 void measureCarson{ ... // 需自定义实现 } // 步骤3:存储测量后View宽/高的值 setMeasuredDimension(widthMeasure, heightMeasure); // 类似单一View的过程,此处不作过多描述 } /** * 分析1:measureChildren() * 作用:遍历子View & 调用measureChild()进行下一步测量 */ protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) { // 参数说明:父视图的测量规格(MeasureSpec) final int size = mChildrenCount; final View[] children = mChildren; // 遍历所有子view for (int i = 0; i < size; ++i) { final View child = children[i]; // 调用measureChild()进行下一步的测量 ->分析2 measureChild(child, widthMeasureSpec, heightMeasureSpec); } } /** * 分析2:measureChild() * 作用:1. 计算单个子View的MeasureSpec * 2. 测量每个子View最后的宽 / 高:调用子View的measure() */ protected void measureChild(View child, int parentWidthMeasureSpec,int parentHeightMeasureSpec) { // 1. 获取子视图的布局参数 final LayoutParams lp = child.getLayoutParams(); // 2. 根据父视图的MeasureSpec & 布局参数LayoutParams,计算单个子View的MeasureSpec final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,mPaddingLeft + mPaddingRight, lp.width); final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,mPaddingTop + mPaddingBottom, lp.height); // 3. 将计算好的子View的MeasureSpec值传入measure(),进行最后的测量 // 下面的流程即类似单一View的过程,此处不作过多描述 child.measure(childWidthMeasureSpec, childHeightMeasureSpec); }
对于视图组ViewGroup的测量流程(Measure)各个方法说明总结如下所示。
为了让大家更好地理解ViewGroup的measure过程(特别是复写onMeasure()),下面,我将用ViewGroup的子类LinearLayout来分析下ViewGroup的measure过程。
为了更好理解ViewGroup的measure过程(特别是复写onMeasure()),本小节将用ViewGroup的子类LinearLayout来分析ViewGroup的measure过程。
此处主要分析的是LinearLayout的onMeasure(),具体如下所示。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 根据不同的布局属性进行不同的计算 // 此处只选垂直方向的测量过程,即measureVertical() ->分析1 if (mOrientation == VERTICAL) { measureVertical(widthMeasureSpec, heightMeasureSpec); } else { measureHorizontal(widthMeasureSpec, heightMeasureSpec); } } /** * 分析1:measureVertical() * 作用:测量LinearLayout垂直方向的测量尺寸 */ void measureVertical(int widthMeasureSpec, int heightMeasureSpec) { // 获取垂直方向上的子View个数 final int count = getVirtualChildCount(); // 遍历子View获取其高度,并记录下子View中最高的高度数值 for (int i = 0; i < count; ++i) { final View child = getVirtualChildAt(i); // 子View不可见,直接跳过该View的measure过程,getChildrenSkipCount()返回值恒为0 // 注:若view的可见属性设置为VIEW.INVISIBLE,还是会计算该view大小 if (child.getVisibility() == View.GONE) { i += getChildrenSkipCount(child, i); continue; } // 记录子View是否有weight属性设置,用于后面判断是否需要二次measure totalWeight += lp.weight; if (heightMode == MeasureSpec.EXACTLY && lp.height == 0 && lp.weight > 0) { // 如果LinearLayout的specMode为EXACTLY且子View设置了weight属性,在这里会跳过子View的measure过程 // 同时标记skippedMeasure属性为true,后面会根据该属性决定是否进行第二次measure // 若LinearLayout的子View设置了weight,会进行两次measure计算,比较耗时 // 这就是为什么LinearLayout的子View需要使用weight属性时候,最好替换成RelativeLayout布局 final int totalLength = mTotalLength; mTotalLength = Math.max(totalLength, totalLength + lp.topMargin + lp.bottomMargin); skippedMeasure = true; } else { int oldHeight = Integer.MIN_VALUE; // 步骤1:该方法内部最终会调用measureChildren(),从而 遍历所有子View & 测量 measureChildBeforeLayout(child, i, widthMeasureSpec, 0, heightMeasureSpec,totalWeight == 0 ? mTotalLength : 0); ... } // 步骤2:合并所有子View的尺寸大小,最终得到ViewGroup父视图的测量值(需自定义实现) final int childHeight = child.getMeasuredHeight(); // 1. mTotalLength用于存储LinearLayout在竖直方向的高度 final int totalLength = mTotalLength; // 2. 每测量一个子View的高度, mTotalLength就会增加 mTotalLength = Math.max(totalLength, totalLength + childHeight + lp.topMargin + lp.bottomMargin + getNextLocationOffset(child)); // 3. 记录LinearLayout占用的总高度 // 即除了子View的高度,还有本身的padding属性值 mTotalLength += mPaddingTop + mPaddingBottom; int heightSize = mTotalLength; // 步骤3:存储测量后View宽/高的值 setMeasureDimension(resolveSizeAndState(maxWidth,width)) ... }
计算视图(View)的位置
即计算View的四个顶点位置:Left、Top、Right 和 Bottom
类似measure过程,layout过程根据View的类型分为2种情况:
在无现成的控件View满足需求、需自己实现时,则使用自定义单一View
1.如:制作一个支持加载网络图片的ImageView控件
2.注:自定义View在多数情况下都有替代方案:图片 / 组合动画,但二者可能会导致内存耗费过大,从而引起内存溢出等问题。
继承自View、SurfaceView 或 其他View;不包含子View
layout过程的入口 = layout(),具体如下:
/** * 源码分析起始点:layout() * 作用:确定View本身的位置,即设置View本身的四个顶点位置 */ public void layout(int l, int t, int r, int b) { // 当前视图的四个顶点 int oldL = mLeft; int oldT = mTop; int oldB = mBottom; int oldR = mRight; // 1. 确定View的位置:setFrame() / setOpticalFrame() // 即初始化四个顶点的值、判断当前View大小和位置是否发生了变化 & 返回 // setFrame() ->分析1 // setOpticalFrame() ->分析2 boolean changed = isLayoutModeOptical(mParent) ? setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b); // 2. 若视图的大小 & 位置发生变化 // 会重新确定该View所有的子View在父容器的位置:onLayout() if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) { onLayout(changed, l, t, r, b); // 对于单一View的laytou过程:由于单一View是没有子View的,故onLayout()是一个空实现 ->分析3 // 对于ViewGroup的laytou过程:由于确定位置与具体布局有关,所以onLayout()在ViewGroup为1个抽象方法,需自定义重写实现(下面的章节会详细说明) } /** * 分析1:setFrame() * 作用:根据传入的4个位置值,设置View本身的四个顶点位置 * 即:最终确定View本身的位置 */ protected boolean setFrame(int left, int top, int right, int bottom) { // 通过以下赋值语句记录下了视图的位置信息,即确定View的四个顶点 // 从而确定了视图的位置 mLeft = left; mTop = top; mRight = right; mBottom = bottom; mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom); } /** * 分析2:setOpticalFrame() * 作用:根据传入的4个位置值,设置View本身的四个顶点位置 * 即:最终确定View本身的位置 */ private boolean setOpticalFrame(int left, int top, int right, int bottom) { Insets parentInsets = mParent instanceof View ? ((View) mParent).getOpticalInsets() : Insets.NONE; Insets childInsets = getOpticalInsets(); // 内部实际上是调用setFrame() return setFrame( left + parentInsets.left - childInsets.left, top + parentInsets.top - childInsets.top, right + parentInsets.left + childInsets.right, bottom + parentInsets.top + childInsets.bottom); } // 回到调用原处 /** * 分析3:onLayout() * 注:对于单一View的laytou过程 * 1. 由于单一View是没有子View的,故onLayout()是一个空实现 * 2. 由于在layout()中已经对自身View进行了位置计算:setFrame() / setOpticalFrame() * 3. 所以单一View的layout过程在layout()后就已完成了 */ protected void onLayout(boolean changed, int left, int top, int right, int bottom) { // 参数说明 // changed 当前View的大小和位置改变了 // left 左部位置 // top 顶部位置 // right 右部位置 // bottom 底部位置 }
单一View的layout过程解析如下:
利用现有的组件根据特定的布局方式来组成新的组件。
继承自ViewGroup 或 各种Layout;含有子 View
如:底部导航条中的条目,一般都是上图标(ImageView)、下文字(TextView),那么这两个就可以用自定义ViewGroup组合成为一个Veiw,提供两个属性分别用来设置文字和图片,使用起来会更加方便。
从ViewGroup至子View、自上而下遍历进行(即树形递归),通过计算整个ViewGroup中各个View的属性,从而最终确定整个ViewGroup的属性。即:
a. 步骤2 类似于 单一View的layout过程
b. 自上而下、一层层地传递下去,直到完成整个View树的layout()过程
这里需要特别注意的是:
ViewGroup 和 View 同样拥有方法:layout()、onLayout(),但二者应用场景是不一样的:
1.一开始计算ViewGroup位置时,调用的是ViewGroup的layout()和onLayout();
2.当开始遍历子View及计算子View位置时,调用的是子View的layout()和onLayout(),类似于单一View的layout过程。
/** * 源码分析:layout() * 作用:确定View本身的位置,即设置View本身的四个顶点位置 * 注:与单一View的layout()源码一致 */ public void layout(int l, int t, int r, int b) { // 当前视图的四个顶点 int oldL = mLeft; int oldT = mTop; int oldB = mBottom; int oldR = mRight; // 1. 确定View的位置:setFrame() / setOpticalFrame() // 即初始化四个顶点的值、判断当前View大小和位置是否发生了变化 & 返回 // setFrame() ->分析1 // setOpticalFrame() ->分析2 boolean changed = isLayoutModeOptical(mParent) ? setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b); // 2. 若视图的大小 & 位置发生变化 // 会重新确定该View所有的子View在父容器的位置:onLayout() if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) { onLayout(changed, l, t, r, b); // 对于单一View的laytou过程:由于单一View是没有子View的,故onLayout()是一个空实现(上面已分析完毕) // 对于ViewGroup的laytou过程:由于确定位置与具体布局有关,所以onLayout()在ViewGroup为1个抽象方法,需重写实现 ->分析3 ... } /** * 分析1:setFrame() * 作用:确定View本身的位置,即设置View本身的四个顶点位置 */ protected boolean setFrame(int left, int top, int right, int bottom) { ... // 通过以下赋值语句记录下了视图的位置信息,即确定View的四个顶点 // 从而确定了视图的位置 mLeft = left; mTop = top; mRight = right; mBottom = bottom; mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom); } /** * 分析2:setOpticalFrame() * 作用:确定View本身的位置,即设置View本身的四个顶点位置 */ private boolean setOpticalFrame(int left, int top, int right, int bottom) { Insets parentInsets = mParent instanceof View ? ((View) mParent).getOpticalInsets() : Insets.NONE; Insets childInsets = getOpticalInsets(); // 内部实际上是调用setFrame() return setFrame( left + parentInsets.left - childInsets.left, top + parentInsets.top - childInsets.top, right + parentInsets.left + childInsets.right, bottom + parentInsets.top + childInsets.bottom); } // 回到调用原处 /** * 分析3:onLayout() * 作用:计算该ViewGroup包含所有的子View在父容器的位置() * 注: * a. 定义为抽象方法,需重写,因:子View的确定位置与具体布局有关,所以onLayout()在ViewGroup没有实现 * b. 在自定义ViewGroup时必须复写onLayout()!!!!! * c. 复写原理:遍历子View 、计算当前子View的四个位置值 & 确定自身子View的位置(调用子View layout()) */ protected void onLayout(boolean changed, int left, int top, int right, int bottom) { // 参数说明 // changed 当前View的大小和位置改变了 // left 左部位置 // top 顶部位置 // right 右部位置 // bottom 底部位置 // 1. 遍历子View:循环所有子View for (int i=0; i<getChildCount(); i++) { View child = getChildAt(i); // 2. 计算当前子View的四个位置值 // 2.1 位置的计算逻辑 ...// 需自己实现,也是自定义View的关键 // 2.2 对计算后的位置值进行赋值 int mLeft = Left int mTop = Top int mRight = Right int mBottom = Bottom // 3. 根据上述4个位置的计算值,设置子View的4个顶点:调用子view的layout() & 传递计算过的参数 // 即确定了子View在父容器的位置 child.layout(mLeft, mTop, mRight, mBottom); // 该过程类似于单一View的layout过程中的layout()和onLayout(),此处不作过多描述 } } }
对于视图组ViewGroup的布局流程(Layout)流程及各个方法说明总结如下:
这里需要特别注意的是:
ViewGroup 和 View 同样拥有方法:layout()、onLayout(),但二者应用场景是不一样的:
- 一开始计算ViewGroup位置时,调用的是ViewGroup的layout()和onLayout();
- 当开始遍历子View及计算子View位置时,调用的是子View的layout()和onLayout(),类似于单一View的layout过程。
计算出LinearLayout本身在父布局的位置
计算出LinearLayout中所有子View在容器中的位置
在上述流程中,对于LinearLayout的layout()的实现与上面小节所说是一样的,此处不作过多阐述。故直接进入LinearLayout复写的onLayout()分析。
/** * 源码分析:LinearLayout复写的onLayout() * 注:复写的逻辑 和 LinearLayout measure过程的 onMeasure()类似 */ @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { // 根据自身方向属性,而选择不同的处理方式 if (mOrientation == VERTICAL) { layoutVertical(l, t, r, b); } else { layoutHorizontal(l, t, r, b); } } // 由于垂直/水平方向类似,所以此处仅分析垂直方向(Vertical)的处理过程 ->分析1 /** * 分析1:layoutVertical(l, t, r, b) */ void layoutVertical(int left, int top, int right, int bottom) { // 子View的数量 final int count = getVirtualChildCount(); // 1. 遍历子View for (int i = 0; i < count; i++) { final View child = getVirtualChildAt(i); if (child == null) { childTop += measureNullChild(i); } else if (child.getVisibility() != GONE) { // 2. 计算子View的测量宽 / 高值 final int childWidth = child.getMeasuredWidth(); final int childHeight = child.getMeasuredHeight(); // 3. 确定自身子View的位置 // 即:递归调用子View的setChildFrame(),实际上是调用了子View的layout() ->分析2 setChildFrame(child, childLeft, childTop + getLocationOffset(child), childWidth, childHeight); // childTop逐渐增大,即后面的子元素会被放置在靠下的位置 // 这符合垂直方向的LinearLayout的特性 childTop += childHeight + lp.bottomMargin + getNextLocationOffset(child); i += getChildrenSkipCount(child, i); } } } /** * 分析2:setChildFrame() */ private void setChildFrame( View child, int left, int top, int width, int height){ child.layout(left, top, left ++ width, top + height); // setChildFrame()仅仅只是调用了子View的layout()而已 // 在子View的layout()又通过调用setFrame()确定View的四个顶点 // 即确定了子View的位置 // 如此不断循环确定所有子View的位置,最终确定ViewGroup的位置 }
实例视图 = 1个ViewGroup(灰色视图),包含1个黄色的子View,如下图:
此处需复写ViewGroup的onLayout(),其具体实现逻辑是指:计算子View的位置,即计算四顶点位置 = 计算Left、Top、Right和Bottom。计算公式及示意图如下:
// 变量说明
h:子View的高度
w:子View的宽度
R:父View的Right值
B:父View的Bottom值
// 具体计算逻辑
R = Left + w + Left;// 因左右间距一样
B = Top + h + Top;// 因上下间距一样
Left = (R - w) / 2;
Top = (B - h) / 2;
Right = w + Left;
Bottom = h + Top;
因为其余方法同上,这里不作过多描述,所以这里只分析复写的onLayout()
@Override protected void onLayout(boolean changed, int l, int t, int r, int b) { // 参数说明 // changed 当前View的大小和位置改变了 // left 左部位置 // top 顶部位置 // right 右部位置 // bottom 底部位置 // 1. 遍历子View:循环所有子View // 注:本例中其实只有一个 for (int i=0; i<getChildCount(); i++) { View child = getChildAt(i); // 取出当前子View宽 / 高 int width = child.getMeasuredWidth(); int height = child.getMeasuredHeight(); // 2. 计算当前子View的四个位置值 // 2.1 位置的计算逻辑 int mLeft = (r - width) / 2; int mTop = (b - height) / 2; int mRight = mLeft + width; int mBottom = mTop + height; // 3. 根据上述4个位置的计算值,设置子View的4个顶点 // 即确定了子View在父容器的位置 child.layout(mLeft, mTop, mRight,mBottom); } }
布局文件如下:
<?xml version="1.0" encoding="utf-8"?>
<scut.carson_ho.layout_demo.Demo_ViewGroup xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#eee998"
tools:context="scut.carson_ho.layout_demo.MainActivity">
<Button
android:text="ChildView"
android:layout_width="200dip"
android:layout_height="200dip"
android:background="#333444"
android:id="@+id/ChildView" />
</scut.carson_ho.layout_demo.Demo_ViewGroup >
效果图:
答:
首先明确定义:
- getWidth() / getHeight():获得View最终的宽 / 高
- getMeasuredWidth() / getMeasuredHeight():获得 View测量的宽 / 高
先看下各自的源码:
// 获得View测量的宽 / 高 public final int getMeasuredWidth() { return mMeasuredWidth & MEASURED_SIZE_MASK; // measure过程中返回的mMeasuredWidth } public final int getMeasuredHeight() { return mMeasuredHeight & MEASURED_SIZE_MASK; // measure过程中返回的mMeasuredHeight } // 获得View最终的宽 / 高 public final int getWidth() { return mRight - mLeft; // View最终的宽 = 子View的右边界 - 子view的左边界。 } public final int getHeight() { return mBottom - mTop; // View最终的高 = 子View的下边界 - 子view的上边界。 }
二者的区别:
上面标红:一般情况下,二者获取的宽 / 高是相等的。那么,“非一般”情况是什么?
答:人为设置:通过重写View的 layout()强行设置
@Override
public void layout( int l , int t, int r , int b){
// 改变传入的顶点位置参数
super.layout(l,t,r+100,b+100);
// 如此一来,在任何情况下,getWidth() / getHeight()获得的宽/高 总比 getMeasuredWidth() / getMeasuredHeight()获取的宽/高大100px
// 即:View的最终宽/高 总比 测量宽/高 大100px
}
虽然这样的人为设置无实际意义,但证明了View的最终宽 / 高 与 测量宽 / 高是可以不一样
网上流传这么一个原因描述:
在非人为设置的情况下,View的最终宽/高(getWidth() / getHeight())
与 View的测量宽/高 (getMeasuredWidth() / getMeasuredHeight())永远是相等
作者:Carson带你学安卓
链接:https://www.jianshu.com/p/158736a2549d
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
本文主要讲解了自定义View中的Layout过程,总结如下:
绘制View视图
类似measure过程、layout过程,draw过程根据View的类型分为2种情况:
在无现成的控件View满足需求、需自己实现时,则使用自定义单一View
- 如:制作一个支持加载网络图片的ImageView控件
- 注:自定义View在多数情况下都有替代方案:图片 / 组合动画,但二者可能会导致内存耗费过大,从而引起内存溢出等问题。
继承自View、SurfaceView 或 其他View;不包含子View
下面我将一个个方法进行详细分析:draw过程的入口 = draw()
/** * 源码分析:draw() * 作用:根据给定的 Canvas 自动渲染View包括其所有子 View)。 * 绘制过程: * 1. 绘制view背景 * 2. 绘制view内容 * 3. 绘制子View * 4. 绘制装饰(渐变框,滑动条等等) * 注: * a. 在调用该方法之前必须要完成 layout 过程 * b. 所有的视图最终都是调用 View 的 draw()绘制视图( ViewGroup 没有复写此方法) * c. 在自定义View时,不应该复写该方法,而是复写 onDraw(Canvas) 方法进行绘制 * d. 若自定义的视图确实要复写该方法,那么需先调用 super.draw(canvas)完成系统的绘制,然后再进行自定义的绘制 */ public void draw(Canvas canvas) { ...// 仅贴出关键代码 int saveCount; // 步骤1: 绘制本身View背景 if (!dirtyOpaque) { drawBackground(canvas); // ->分析1 } // 若有必要,则保存图层(还有一个复原图层) // 优化技巧:当不需绘制 Layer 时,“保存图层“和“复原图层“这两步会跳过 // 因此在绘制时,节省 layer 可以提高绘制效率 final int viewFlags = mViewFlags; if (!verticalEdges && !horizontalEdges) { // 步骤2:绘制本身View内容 if (!dirtyOpaque) onDraw(canvas); // 单一View中:默认为空实现,需复写 // ViewGroup中:需复写 // ->分析2 // 步骤3:绘制子View // 由于单一View无子View,故View中:默认为空实现 // ViewGroup中:系统已经复写好对其子视图进行绘制我们不需要复写 dispatchDraw(canvas); // ->分析3 // 步骤4:绘制装饰,如滑动条、前景色等等 onDrawScrollBars(canvas); // ->分析4 return; } ... } /** * 分析1:drawBackground(canvas) * 作用:绘制View本身的背景 */ private void drawBackground(Canvas canvas) { // 获取背景 drawable final Drawable background = mBackground; if (background == null) { return; } // 根据在 layout 过程中获取的 View 的位置参数,来设置背景的边界 setBackgroundBounds(); ... // 获取 mScrollX 和 mScrollY值 final int scrollX = mScrollX; final int scrollY = mScrollY; if ((scrollX | scrollY) == 0) { background.draw(canvas); } else { // 若 mScrollX 和 mScrollY 有值,则对 canvas 的坐标进行偏移 canvas.translate(scrollX, scrollY); // 调用 Drawable 的 draw 方法绘制背景 background.draw(canvas); canvas.translate(-scrollX, -scrollY); } } /** * 分析2:onDraw(canvas) * 作用:绘制View本身的内容 * 注: * a. 由于 View 的内容各不相同,所以该方法是一个空实现 * b. 在自定义绘制过程中,需由子类去实现复写该方法,从而绘制自身的内容 * c. 谨记:自定义View中 必须且只需复写onDraw() */ protected void onDraw(Canvas canvas) { ... // 复写从而实现绘制逻辑 } /** * 分析3: dispatchDraw(canvas) * 作用:绘制子View * 注:由于单一View中无子View,故为空实现 */ protected void dispatchDraw(Canvas canvas) { ... // 空实现 } /** * 分析4: onDrawScrollBars(canvas) * 作用:绘制装饰,如滚动指示器、滚动条、和前景等 */ public void onDrawForeground(Canvas canvas) { onDrawScrollIndicators(canvas); onDrawScrollBars(canvas); final Drawable foreground = mForegroundInfo != null ? mForegroundInfo.mDrawable : null; if (foreground != null) { if (mForegroundInfo.mBoundsChanged) { mForegroundInfo.mBoundsChanged = false; final Rect selfBounds = mForegroundInfo.mSelfBounds; final Rect overlayBounds = mForegroundInfo.mOverlayBounds; if (mForegroundInfo.mInsidePadding) { selfBounds.set(0, 0, getWidth(), getHeight()); } else { selfBounds.set(getPaddingLeft(), getPaddingTop(), getWidth() - getPaddingRight(), getHeight() - getPaddingBottom()); } final int ld = getLayoutDirection(); Gravity.apply(mForegroundInfo.mGravity, foreground.getIntrinsicWidth(), foreground.getIntrinsicHeight(), selfBounds, overlayBounds, ld); foreground.setBounds(overlayBounds); } foreground.draw(canvas); } }
单一View的draw过程解析如下:
即 只需绘制View自身
利用现有的组件根据特定的布局方式来组成新的组件
继承自ViewGroup 或 各种Layout;含有子 View
如:底部导航条中的条目,一般都是上图标(ImageView)、下文字(TextView),那么这两个就可以用自定义ViewGroup组合成为一个Veiw,提供两个属性分别用来设置文字和图片,使用起来会更加方便。
类似于单一View的draw过程
自上而下、一层层地传递下去,直到完成整个View树的draw过程
下面我将对每个步骤和方法进行详细分析:draw过程的入口 = draw()
/** * 源码分析:draw() * 与单一View的draw()流程类似 * 作用:根据给定的 Canvas 自动渲染 View(包括其所有子 View) * 绘制过程: * 1. 绘制view背景 * 2. 绘制view内容 * 3. 绘制子View * 4. 绘制装饰(渐变框,滑动条等等) * 注: * a. 在调用该方法之前必须要完成 layout 过程 * b. 所有的视图最终都是调用 View 的 draw ()绘制视图( ViewGroup 没有复写此方法) * c. 在自定义View时,不应该复写该方法,而是复写 onDraw(Canvas) 方法进行绘制 * d. 若自定义的视图确实要复写该方法,那么需先调用 super.draw(canvas)完成系统的绘制,然后再进行自定义的绘制 */ public void draw(Canvas canvas) { ...// 仅贴出关键代码 int saveCount; // 步骤1: 绘制本身View背景 if (!dirtyOpaque) { drawBackground(canvas); } // 若有必要,则保存图层(还有一个复原图层) // 优化技巧:当不需绘制 Layer 时,“保存图层“和“复原图层“这两步会跳过 // 因此在绘制时,节省 layer 可以提高绘制效率 final int viewFlags = mViewFlags; if (!verticalEdges && !horizontalEdges) { // 步骤2:绘制本身View内容 if (!dirtyOpaque) onDraw(canvas); // View 中:默认为空实现,需复写 // ViewGroup中:需复写 // 步骤3:绘制子View // ViewGroup中:系统已复写好对其子视图进行绘制,不需复写 dispatchDraw(canvas); // 步骤4:绘制装饰,如滑动条、前景色等等 onDrawScrollBars(canvas); return; } ... }
由于 步骤2:drawBackground()、步骤3:onDraw()、步骤5:onDrawForeground(),与单一View的draw过程类似,此处不作过多描述
/** * 源码分析:dispatchDraw() * 作用:遍历子View & 绘制子View * 注: * a. ViewGroup中:由于系统为我们实现了该方法,故不需重写该方法 * b. View中默认为空实现(因为没有子View可以去绘制) */ protected void dispatchDraw(Canvas canvas) { ...... // 1. 遍历子View final int childrenCount = mChildrenCount; ...... for (int i = 0; i < childrenCount; i++) { ...... if ((transientChild.mViewFlags & VISIBILITY_MASK) == VISIBLE || transientChild.getAnimation() != null) { // 2. 绘制子View视图 ->>分析1 more |= drawChild(canvas, transientChild, drawingTime); } .... } } /** * 分析1:drawChild() * 作用:绘制子View */ protected boolean drawChild(Canvas canvas, View child, long drawingTime) { // 最终还是调用了子 View 的 draw ()进行子View的绘制 return child.draw(canvas, this, drawingTime); }
ViewGroup的draw过程如下:
/** * 源码分析:setWillNotDraw() * 定义:View 中的特殊方法 * 作用:设置 WILL_NOT_DRAW 标记位; * 注: * a. 该标记位的作用是:当一个View不需要绘制内容时,系统进行相应优化 * b. 默认情况下:View 不启用该标记位(设置为false);ViewGroup 默认启用(设置为true) */ public void setWillNotDraw(boolean willNotDraw) { setFlags(willNotDraw ? WILL_NOT_DRAW : 0, DRAW_MASK); } // 应用场景 // a. setWillNotDraw参数设置为true:当自定义View继承自 ViewGroup 、且本身并不具备任何绘制时,设置为 true 后,系统会进行相应的优化。 // b. setWillNotDraw参数设置为false:当自定义View继承自 ViewGroup 、且需要绘制内容时,那么设置为 false,来关闭 WILL_NOT_DRAW 这个标记位。
是安卓平台2D图形绘制的基础
- 记住:绘制内容是根据画布的规定绘制在屏幕上的
- 理解为:画布只是绘制时的规则,但内容实际上是绘制在屏幕上的
请务必记住:
绘制内容是根据画布(Canvas)的规定绘制在屏幕上的
画布(Canvas)只是绘制时的规则,但内容实际上是绘制在屏幕上的
// 画一个矩形(蓝色)
canvas.drawRect(100, 100, 150, 150, mPaint1);
// 将画布的原点移动到(400,500)
canvas.translate(400,500);
// 再画一个矩形(红色)
canvas.drawRect(100, 100, 150, 150, mPaint2);
- 内容实际上是绘制在屏幕上;
- 画布,即Canvas,只是规定了绘制内容时的规则;
- 内容的位置由坐标决定,而坐标是相对于画布而言的
在绘制内容时需要画笔Paint
具体使用如下:
// 步骤1:创建一个画笔 private Paint mPaint = new Paint(); // 步骤2:初始化画笔 // 根据需求设置画笔的各种属性,具体如下: private void initPaint() { // 设置最基本的属性 // 设置画笔颜色 // 可直接引入Color类,如Color.red等 mPaint.setColor(int color); // 设置画笔模式 mPaint.setStyle(Style style); // Style有3种类型: // 类型1:Paint.Style.FILLANDSTROKE(描边+填充) // 类型2:Paint.Style.FILL(只填充不描边) // 类型3:Paint.Style.STROKE(只描边不填充) // 具体差别请看下图: // 特别注意:前两种就相差一条边 // 若边细是看不出分别的;边粗就相当于加粗 //设置画笔的粗细 mPaint.setStrokeWidth(float width) // 如设置画笔宽度为10px mPaint.setStrokeWidth(10f); // 不常设置的属性 // 得到画笔的颜色 mPaint.getColor() // 设置Shader // 即着色器,定义了图形的着色、外观 // 可以绘制出多彩的图形 // 具体请参考文章:http://blog.csdn.net/iispring/article/details/50500106 Paint.setShader(Shader shader) //设置画笔的a,r,p,g值 mPaint.setARGB(int a, int r, int g, int b) //设置透明度 mPaint.setAlpha(int a) //得到画笔的Alpha值 mPaint.getAlpha() // 对字体进行设置(大小、颜色) //设置字体大小 mPaint.setTextSize(float textSize) // 文字Style三种模式: mPaint.setStyle(Style style); // 类型1:Paint.Style.FILLANDSTROKE(描边+填充) // 类型2:Paint.Style.FILL(只填充不描边) // 类型3:Paint.Style.STROKE(只描边不填充) // 设置对齐方式 setTextAlign() // LEFT:左对齐 // CENTER:居中对齐 // RIGHT:右对齐 //设置文本的下划线 setUnderlineText(boolean underlineText) //设置文本的删除线 setStrikeThruText(boolean strikeThruText) //设置文本粗体 setFakeBoldText(boolean fakeBoldText) // 设置斜体 Paint.setTextSkewX(-0.5f); // 设置文字阴影 Paint.setShadowLayer(5,5,5,Color.YELLOW); } // 步骤3:在构造函数中初始化 public CarsonView(Context context, AttributeSet attrs) { super(context, attrs); initPaint(); }
Style模式效果如下:
Path只用于描述顺序 & 区域,单使用Path无法产生效果
Path类封装了由直线和曲线(2、3次贝塞尔曲线)构成的几何路径。
2.10.3.2.2.1 开放路径与闭合路径的区别
2.10.3.2.2.2 如何判断点在图形内还是图形外
判断方法分为奇偶规则 & 非零环绕规则,具体介绍如下:
- p1发出的射线与图形相交1个点,即奇数点,所以P1点在图形内
- p2发出的射线与图形相交2个点,即偶数点,所以P2点在图形内
2.10.3.2.3.1 对象创建
// 使用Path首先要new一个Path对象
// Path的起点默认为坐标为(0,0)
Path path = new Path();
// 特别注意:建全局Path对象,在onDraw()按需修改;尽量不要在onDraw()方法里new对象
// 原因:若View频繁刷新,就会频繁创建对象,拖慢刷新速度。
2.10.3.2.3.2 具体方法使用
因为path类的方法都是联合使用,所以下面将一组组方法进行介绍。
第一组:设置路径
采用moveTo()、setLastPoint()、lineTo()、close()组合
// 设置当前点位置
// 后面的路径会从该点开始画
moveTo(float x, float y) ;
// 当前点(上次操作结束的点)会连接该点
// 如果没有进行过操作则默认点为坐标原点。
lineTo(float x, float y) ;
// 闭合路径,即将当前点和起点连在一起
// 注:如果连接了最后一个点和第一个点仍然无法形成封闭图形,则close什么也不做
close() ;
实例介绍:(含setLastPoint()与moveTo()):
// 使用moveTo() // 起点默认是(0,0) //连接点(400,500) path.lineTo(400, 500); // 将当前点移动到(300, 300) path.moveTo(300, 300) ; //连接点(900, 800) path.lineTo(900, 800); //连接点(200,700) path.lineTo(200, 700); // 闭合路径,即连接当前点和起点 // 即连接(200,700)与起点2(300, 300) // 注:此时起点已经进行变换 path.close(); // 画出路径 canvas.drawPath(path, mPaint1); // 使用setLastPoint() // 起点默认是(0,0) //连接点(400,500) path.lineTo(400, 500); // 将当前点移动到(300, 300) // 会影响之前的操作 // 但不将此设置为新起点 path.setLastPoint(300, 300) ; //连接点(900,800) path.lineTo(900, 800); //连接点(200,700) path.lineTo(200, 700); // 闭合路径,即连接当前点和起点 // 即连接(200,700)与起点(0,0) // 注:起点一直没变化 path.close(); // 画出路径 canvas.drawPath(path, mPaint1);
关于重置路径
方法 | 是否保留FillType设置 | 是否保留原有数据结构 |
---|---|---|
Path.reset() | 是 | 否 |
Path.rewind() | 否 | 是 |
- FillType影响显示效果;数据结构影响重建速度
- 所以一般选择Path.reset()
第二组: 添加路径
采用addXxx()、arcTo()组合
添加基本图形
如圆形路径、圆弧路径等等
// 添加圆弧 // 方法1 public void addArc (RectF oval, float startAngle, float sweepAngle) // startAngle:确定角度的起始位置 // sweepAngle : 确定扫过的角度 // 方法2 // 与上面方法唯一不同的是:如果圆弧的起点和上次最后一个坐标点不相同,就连接两个点 public void arcTo (RectF oval, float startAngle, float sweepAngle) // 方法3 // 参数forceMoveTo:是否将之前路径的结束点设置为圆弧起点 // true:在新的起点画圆弧,不连接最后一个点与圆弧起点,即与之前路径没有交集(同addArc()) // false:在新的起点画圆弧,但会连接之前路径的结束点与圆弧起点,即与之前路径有交集(同arcTo(3参数)) public void arcTo (RectF oval, float startAngle, float sweepAngle, boolean forceMoveTo) // 下面会详细说明 // 加入圆形路径 // 起点:x轴正方向的0度 // 其中参数dir:指定绘制时是顺时针还是逆时针:CW为顺时针, CCW为逆时针 // 路径起点变为圆在X轴正方向最大的点 addCircle(float x, float y, float radius, Path.Direction dir) // 加入椭圆形路径 // 其中,参数oval作为椭圆的外切矩形区域 addOval(RectF oval, Path.Direction dir) // 加入矩形路径 // 路径起点变为矩形的左上角顶点 addRect(RectF rect, Path.Direction dir) //加入圆角矩形路径 addRoundRect(RectF rect, float rx, float ry, Path.Direction dir) // 注:添加图形路径后会改变路径的起点
主要说一下dir这个参数:
dir = Direction = 图形的方向,为枚举类型:
CW:clockwise,顺时针
CCW:counter-clockwise,逆时针
图形的方向影响的是:
添加图形时确定闭合顺序(各个点的记录顺序)
图形的渲染结果(是判断图形渲染的重要条件)
**图形绘制的本质:先画点,再将点连接起来。**所以,点与点之间是存在一个先后顺序的;顺时针和逆时针用于确定这些点的顺序。
下面实例将说明:
// 为了方便观察,平移坐标系
canvas.translate(350, 500);
// 顺时针
path.addRect(0, 0, 400, 400, Path.Direction.CW);
// 逆时针
// path.addRect(0,0,400,400, Path.Direction.CCW);
canvas.drawPath(path,mPaint1);
关于加入图形路径后会影响路径的起点,实例如下:
// 轨迹1 // 将Canvas坐标系移到屏幕正中 canvas.translate(400,500); // 起点是(0,0),连接点(-100,0) path.lineTo(-100,0); // 连接点(-100,200) path.lineTo(-100,200); // 连接点(200,200) path.lineTo(200,200); // 闭合路径,即连接当前点和起点 // 即连接(200,200)与起点是(0,0) path.close(); // 画出路径 canvas.drawPath(path,paint); // 具体请看下图 // 轨迹2 // 将Canvas坐标系移到屏幕正中 canvas.translate(400,500); // 起点是(0,0),连接点(-100,0) path.lineTo(-100,0); // 画圆:圆心=(0,0),半径=100px // 此时路径起点改变 = (0,100)(记为起点2) // 起点改变原则:新画图形在x轴正方向的最后一个坐标 // 后面路径的变化以这个点继续下去 path.addCircle(0,0,100, Path.Direction.CCW); // 起点为:(0,100),连接 (-100,200) path.lineTo(-100,200); // 连接 (200,200) path.lineTo(200,200); // 闭合路径,即连接当前点和起点(注:闭合的是起点2) // 即连接(200,200)与起点2(0,100) path.close(); // 画出路径 canvas.drawPath(path,paint); // // 具体请看下图
这里着重说明:添加圆弧路径(addArc与arcTo)
// addArc // 直接添加一个圆弧到path中 // startAngle:确定角度的起始位置 // sweepAngle : 确定扫过的角度 public void addArc (RectF oval, float startAngle, float sweepAngle) // arcTo // 方法1 // 同样是添加一个圆弧到path // 与上面方法唯一不同的是:如果圆弧的起点和上次最后一个坐标点不相同,就连接两个点 public void arcTo (RectF oval, float startAngle, float sweepAngle) // 方法2 // 参数forceMoveTo:是否将之前路径的结束点设置为圆弧起点 // true:在新的起点画圆弧,不连接最后一个点与圆弧起点,即与之前路径没有交集(同addArc()) // false:在新的起点画圆弧,但会连接之前路径的结束点与圆弧起点,即与之前路径有交集(同arcTo(3参数)) public void arcTo (RectF oval, float startAngle, float sweepAngle, boolean forceMoveTo)
具体请看下面实例
// 将一个圆弧路径添加到一条直线路径里 // 为了方便观察,平移坐标系 canvas.translate(350, 500); // 先将原点(0,0)连接点(100,100) path.lineTo(50, 200); // 添加圆弧路径(2分之1圆弧) // 不连接最后一个点与圆弧起点 path.addArc(new RectF(200, 200, 300, 300), 0, 180); // path.arcTo(oval,0,270,true); // 与上面一句作用等价 // 连接之前路径的结束点与圆弧起点 path.arcTo(new RectF(200, 200, 300, 300), 0, 180); // path.arcTo(oval,0,270,false); // 与上面一句作用等价 // 画出路径 canvas.drawPath(path, mPaint1);
添加路径
即将路径1加到路径2里
// 方法1 public void addPath (Path src) // 方法2 // 先将src进行(x,y)位移之后再添加到当前path public void addPath (Path src, float dx, float dy) // 方法3 // 先将src进行Matrix变换再添加到当前path public void addPath (Path src, Matrix matrix) // 实例:合并矩形路径和圆形路径 // 为了方便观察,平移坐标系 canvas.translate(350, 500); // 创建路径的对象 Path pathRect = new Path(); Path pathCircle = new Path(); // 画一个矩形路径 pathRect.addRect(-200, -200, 200, 200, Path.Direction.CW); // 画一个圆形路径 pathCircle.addCircle(0, 0, 100, Path.Direction.CW); // 将圆形路径移动(0,200),再添加到矩形路径里 pathRect.addPath(pathCircle, 0, 200); // 绘制合并后的路径 canvas.drawPath(pathRect,mPaint1);
第三组:判断路径属性
采用isEmpty()、 isRect()、isConvex()、 set() 和 offset()组合
具体使用:
// 判断path中是否包含内容 public boolean isEmpty () // 例子: Path path = new Path(); path.isEmpty(); //返回false path.lineTo(100,100); // 返回true // 判断path是否是一个矩形 // 如果是一个矩形的话,会将矩形的信息存放进参数rect中。 public boolean isRect (RectF rect) // 实例 path.lineTo(0,400); path.lineTo(400,400); path.lineTo(400,0); path.lineTo(0,0); RectF rect = new RectF(); boolean b = path.isRect(rect); // b返回ture, // rect存放矩形参数,具体如下: // rect.left = 0 // rect.top = 0 // rect.right = 400 // rect.bottom = 400 // 将新的路径替代现有路径 public void set (Path src) // 实例 // 设置一矩形路径 Path path = new Path(); path.addRect(-200,-200,200,200, Path.Direction.CW); // 设置一圆形路径 Path src = new Path(); src.addCircle(0,0,100, Path.Direction.CW); // 将圆形路径代替矩形路径 path.set(src); // 绘制图形 canvas.drawPath(path,mPaint); // 平移路径 // 与Canvas.translate ()平移画布类似 // 方法1 // 参数x,y:平移位置 public void offset (float dx, float dy) // 方法2 // 参数dst:存储平移后的路径状态,但不影响当前path // 可通过dst参数绘制存储的路径 public void offset (float dx, float dy, Path dst) // 为了方便观察,平移坐标系 canvas.translate(350, 500); // path中添加一个圆形(圆心在坐标原点) path = new Path(); path.addCircle(0, 0, 100, Path.Direction.CW); // 平移路径并存储平移后的状态 Path dst = new Path(); path.offset(400, 0, dst); // 平移 canvas.drawPath(path, mPaint1); // 绘制path // 通过dst绘制平移后的图形(红色) mPaint1.setColor(Color.RED); canvas.drawPath(dst,mPaint1);
第四组:设置路径填充颜色
均封装在Path类中
填充模式 | 介绍 |
---|---|
EVEN_ODD | 奇偶规则 |
INVERSE_EVEN_ODD | 反奇偶规则 |
WINDING | 非零环绕数规则 |
INVERSE_WINDING | 反非零环绕数规则 |
请记住两个填充规律:
图形是存在方向的(画图 = 连接点成的线 = 有连接顺序)。
具体使用
// 设置填充规则 path.setFillType() // 可填规则 // 1. EVEN_ODD:奇偶规则 // 2. INVERSE_EVEN_ODD:反奇偶规则 // 3. WINDING :非零环绕数规则 // 4. INVERSE_WINDING:反非零环绕数规则 // 理解奇偶规则和反奇偶规则:填充效果相反 // 举例:对于一个矩形而言,使用奇偶规则会填充矩形内部,而使用反奇偶规则会填充矩形外部(下面会举例说明) // 获取当前填充规则 path.getFillType() // 判断是否是反向(INVERSE)规则 path.isInverseFillType() // 切换填充规则(即原有规则与反向规则之间相互切换) path.toggleInverseFillType()
实例1:(奇偶规则)
// 为了方便观察,平移坐标系
canvas.translate(350, 500);
// 在Path中添加一个矩形
path.addRect(-200, -200, 200, 200, Path.Direction.CW);
// 设置Path填充模式为 奇偶规则
path.setFillType(Path.FillType.EVEN_ODD);
// 反奇偶规则
// path.setFillType(Path.FillType.INVERSE_EVEN_ODD);
// 画出路径
canvas.drawPath(path, mPaint1);
举例2:(非零环绕规则)
// 为了方便观察,平移坐标系 canvas.translate(550, 550); // 在路径中添加大正方形 // 逆时针 path.addRect(-400, -400, 400, 400, Path.Direction.CCW); // 在路径中添加小正方形 // 顺时针 // path.addRect(-200, -200, 200, 200, Path.Direction.CW); // 设置为逆时针 path.addRect(-200, -200, 200, 200, Path.Direction.CCW); // 设置Path填充模式为非零环绕规则 path.setFillType(Path.FillType.WINDING); // 设置反非零环绕数规则 // path.setFillType(Path.FillType.INVERSE_WINDING); // 绘制Path canvas.drawPath(path, mPaint1);
第五组:布尔操作
// 方法1
boolean op (Path path, Path.Op op)
// 举例
// 对 path1 和 path2 执行布尔运算,运算方式由第二个参数指定
// 运算结果存入到path1中。
path1.op(path2, Path.Op.DIFFERENCE);
// 方法2
boolean op (Path path1, Path path2, Path.Op op)
// 举例
// 对 path1 和 path2 执行布尔运算,运算方式由第三个参数指定
// 运算结果存入到path3中。
path3.op(path1, path2, Path.Op.DIFFERENCE)
之间的运算方式(即Path.Op参数)如下:
举例:
// 为了方便观察,平移坐标系
canvas.translate(550, 550);
// 画两个圆
// 圆1:圆心 = (0,0),半径 = 100
// 圆2:圆心 = (50,0),半径 = 100
path1.addCircle(0, 0, 100, Path.Direction.CW);
path2.addCircle(50, 0,100, Path.Direction.CW);
// 取两个路径的异或集
path1.op(path2, Path.Op.XOR);
// 画出路径
canvas.drawPath(path1, mPaint1);
任何一条曲线都可以用贝塞尔曲线表示
1.数据点:指路径的起始点和终止点;
2.控制点:决定了路径的弯曲轨迹;
3.n+1阶贝塞尔曲线 = 有n个控制点;
4.(1阶 = 一条直线,高阶可以拆解为多条低阶曲线)
Canvas提供了画二阶 & 三阶贝塞尔曲线的方法,下面是具体方法:
// 绘制二阶贝塞尔曲线
// (x1,y1)为控制点,(x2,y2)为终点
quadTo(float x1, float y1, float x2, float y2)
// (x1,y1)为控制点距离起点的偏移量,(x2,y2)为终点距离起点的偏移量
rQuadTo(float x1, float y1, float x2, float y2)
// 绘制三阶贝塞尔曲线
// (x1,y1),(x2,y2)为控制点,(x3,y3)为终点
cubicTo(float x1, float y1, float x2, float y2, float x3, float y3)
// (x1,y1),(x2,y2)为控制点距离起点的偏移量,(x3,y3)为终点距离起点的偏移量
rCubicTo(float x1, float y1, float x2, float y2, float x3, float y3)
具体参考贝塞尔曲线开发的艺术。
android:hardwareAccelerated="false"
Canvas对象 & 获取的方法有4个:
// 方法1 // 利用空构造方法直接创建对象 Canvas canvas = new Canvas(); // 方法2 // 通过传入装载画布Bitmap对象创建Canvas对象 // CBitmap上存储所有绘制在Canvas的信息 Canvas canvas = new Canvas(bitmap) // 方法3 // 通过重写View.onDraw()创建Canvas对象 // 在该方法里可以获得这个View对应的Canvas对象 @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); //在这里获取Canvas对象 } // 方法4 // 在SurfaceView里画图时创建Canvas对象 SurfaceView surfaceView = new SurfaceView(this); // 从SurfaceView的surfaceHolder里锁定获取Canvas SurfaceHolder surfaceHolder = surfaceView.getHolder(); //获取Canvas Canvas c = surfaceHolder.lockCanvas(); // ...(进行Canvas操作) // Canvas操作结束之后解锁并执行Canvas surfaceHolder.unlockCanvasAndPost(c);
官方推荐方法4来创建并获取Canvas,原因:
仅列出常用方法,更加详细的方法可参考官方文档 Canvas
特别注意
Canvas具体使用时是在复写的onDraw()里:
@Override
protected void onDraw(Canvas canvas){
super.onDraw(canvas);
// 对Canvas进行一系列设置
// 如画圆、画直线等等
canvas.drawColor(Color.BLUE);
// ...
}
}
// 传入一个Color类的常量参数来设置画布颜色
// 绘制蓝色
canvas.drawColor(Color.BLUE);
a. 绘制点(drawPoint)
可画一个点或一组点(多个点)
// 特别注意:需要用到画笔Paint // 所以之前记得创建画笔 // 为了区分,这里使用了两个不同颜色的画笔 // 描绘一个点 // 在坐标(200,200)处 canvas.drawPoint(300, 300, mPaint1); // 绘制一组点,坐标位置由float数组指定 // 此处画了3个点,位置分别是:(600,500)、(600,600)、(600,700) canvas.drawPoints(new float[]{ 600,500, 600,600, 600,700 },mPaint2);
b. 绘制直线(drawLine)
// 画一条直线
// 在坐标(100,200),(700,200)之间绘制一条直线
canvas.drawLine(100,200,700,200,mPaint1);
// 绘制一组线
// 在坐标(400,500),(500,500)之间绘制直线1
// 在坐标(400,600),(500,600)之间绘制直线2
canvas.drawLines(new float[]{
400,500,500,500,
400,600,500,600
},mPaint2);
}
c. 绘制矩形(drawRect)
一般是采用左上角和右下角的两个点的坐标。
// 关于绘制矩形,Canvas提供了三种重载方法 // 方法1:直接传入两个顶点的坐标 // 两个顶点坐标分别是:(100,100),(800,400) canvas.drawRect(100,100,800,400,mPaint); // 方法2:将两个顶点坐标封装为RectRectF Rect rect = new Rect(100,100,800,400); canvas.drawRect(rect,mPaint); // 方法3:将两个顶点坐标封装为RectF RectF rectF = new RectF(100,100,800,400); canvas.drawRect(rectF,mPaint); // 特别注意:Rect类和RectF类的区别 // 精度不同:Rect = int & RectF = float // 三种方法画出来的效果是一样的。
d. 绘制圆角矩形
类似于绘制矩形
// 方法1:直接传入两个顶点的坐标
// API21时才可使用
// 第5、6个参数:rx、ry是圆角的参数,下面会详细描述
canvas.drawRoundRect(100,100,800,400,30,30,mPaint);
// 方法2:使用RectF类
RectF rectF = new RectF(100,100,800,400);
canvas.drawRoundRect(rectF,30,30,mPaint);
实际上,在rx为宽度的一半,ry为高度的一半时,刚好是一个椭圆;但由于当rx大于宽度一半,ry大于高度一半时,无法计算出圆弧,所以drawRoundRect对大于该数值的参数进行了修正,凡是大于一半的参数均按照一半来处理
e. 绘制椭圆
1.椭圆传入的参数和矩形是一样的;
2.绘制椭圆实际上是绘制一个矩形的内切图形。
// 方法1:使用RectF类
RectF rectF = new RectF(100,100,800,400);
canvas.drawOval(rectF,mPaint);
// 方法2:直接传入与矩形相关的参数
canvas.drawOval(100,100,800,400,mPaint);
// 为了方便表示,画一个和椭圆一样参数的矩形
canvas.drawRect(100,100,800,400,mPaint);
f. 绘制圆
// 参数说明:
// 1、2:圆心坐标
// 3:半径
// 4:画笔
// 绘制一个圆心坐标在(500,500),半径为400 的圆。
canvas.drawCircle(500,500,400,mPaint);
g. 绘制圆弧
// 绘制圆弧共有两个方法
// 相比于绘制椭圆,绘制圆弧多了三个参数:
startAngle // 确定角度的起始位置
sweepAngle // 确定扫过的角度
useCenter // 是否使用中心(下面会详细说明)
// 方法1
public void drawArc(@NonNull RectF oval, float startAngle, float sweepAngle, boolean useCenter, @NonNull Paint paint){}
// 方法2
public void drawArc(float left, float top, float right, float bottom, float startAngle,
float sweepAngle, boolean useCenter, @NonNull Paint paint) {}
从示例可以发现:
类似扇形
绘制文字分为三种应用场景:
即指定文本基线位置
基线x默认在字符串左侧,基线y默认在字符串下方
下面分别细说:
文字的样式(大小,颜色,字体等)具体由画笔Paint控制,详细请会看上面基础的介绍
情况1:指定文本开始的位置
// 参数text:要绘制的文本 // 参数x,y:指定文本开始的位置(坐标) // 参数paint:设置的画笔属性 public void drawText (String text, float x, float y, Paint paint) // 实例 canvas.drawText("abcdefg",300,400,mPaint1); // 仅绘制文本的一部分 // 参数start,end:指定绘制文本的位置 // 位置以下标标识,由0开始 public void drawText (String text, int start, int end, float x, float y, Paint paint) public void drawText (CharSequence text, int start, int end, float x, float y, Paint paint) // 对于字符数组char[] // 截取文本使用起始位置(index)和长度(count) public void drawText (char[] text, int index, int count, float x, float y, Paint paint) // 实例:绘制从位置1-3的文本 canvas.drawText("abcdefg",1,4,300,400,mPaint1); // 字符数组情况 // 字符数组(要绘制的内容) char[] chars = "abcdefg".toCharArray(); // 参数为 (字符数组 起始坐标 截取长度 基线x 基线y 画笔) canvas.drawText(chars,1,3,200,500,textPaint); // 效果同上
情况2:分别指定文本的位置
// 参数text:绘制的文本 // 参数pos:数组类型,存放每个字符的位置(坐标) // 注意:必须指定所有字符位置 public void drawPosText (String text, float[] pos, Paint paint) // 对于字符数组char[],可以截取部分文本进行绘制 // 截取文本使用起始位置(index)和长度(count) public void drawPosText (char[] text, int index, int count, float[] pos, Paint paint) // 特别注意: // 1. 在字符数量较多时,使用会导致卡顿 // 2. 不支持emoji等特殊字符,不支持字形组合与分解 // 实例 canvas.drawPosText("abcde", new float[]{ 100, 100, // 第一个字符位置 200, 200, // 第二个字符位置 300, 300, // ... 400, 400, 500, 500 }, mPaint1); // 数组情况(绘制部分文本) char[] chars = "abcdefg".toCharArray(); canvas.drawPosText(chars, 1, 3, new float[]{ 300, 300, // 指定的第一个字符位置 400, 400, // 指定的第二个字符位置 500, 500, // 指定的第三个字符位置 }, mPaint1);
情况3:指定路径,并根据路径绘制文字
// 在路径(540,750,640,450,840,600)写上"在Path上写的字:Carson_Ho"字样
// 1.创建路径对象
Path path = new Path();
// 2. 设置路径轨迹
path.cubicTo(540, 750, 640, 450, 840, 600);
// 3. 画路径
canvas.drawPath(path,mPaint2);
// 4. 画出在路径上的字
canvas.drawTextOnPath("在Path上写的字:Carson_Ho", path, 50, 0, mPaint2);
绘制图片分为:绘制矢量图(drawPicture)和 绘制位图(drawBitmap)
a. 绘制矢量图(drawPicture)
矢量图(Picture)的作用:存储(录制)某个时刻Canvas绘制内容的操作
1.相比于再次调用各种绘图API,使用Picture能节省操作 & 时间
2.如果不手动调用,录制的内容不会显示在屏幕上,只是存储起来
特别注意:使用绘制矢量图时前请关闭硬件加速,以免引起不必要的问题!
具体使用方法:
// 获取宽度 Picture.getWidth (); // 获取高度 Picture.getHeight () // 开始录制 // 即将Canvas中所有的绘制内容存储到Picture中 // 返回一个Canvas Picture.beginRecording(int width, int height) // 结束录制 Picture.endRecording () // 将Picture里的内容绘制到Canvas中 Picture.draw (Canvas canvas) // 还有两种方法可以将Picture里的内容绘制到Canvas中 // 方法2:Canvas.drawPicture() // 方法3:将Picture包装成为PictureDrawable,使用PictureDrawable的draw方法绘制。 // 下面会详细介绍
一般使用的具体步骤
// 步骤1:创建Picture对象
Picture mPicture = new Picture();
// 步骤2:开始录制
mPicture.beginRecording(int width, int height);
// 步骤3:绘制内容 or 操作Canvas
canvas.drawCircle(500,500,400,mPaint);
...(一系列操作)
// 步骤4:结束录制
mPicture.endRecording ();
步骤5:某个时刻将存储在Picture的绘制内容绘制出来
mPicture.draw (Canvas canvas);
下面我将用一个实例去表示如何去使用:
步骤1:创建Picture对象
Picture mPicture = new Picture();
步骤2:开始录制
Canvas recordingCanvas = mPicture.beginRecording(500, 500);
// 注:要创建Canvas对象来接收beginRecording()返回的Canvas对象
步骤3:绘制内容 or 操作Canvas
// 位移
// 将坐标系的原点移动到(450,650)
recordingCanvas.translate(450,650);
// 记得先创建一个画笔
Paint paint = new Paint();
paint.setColor(Color.BLUE);
paint.setStyle(Paint.Style.FILL);
// 绘制一个圆
// 圆心为(0,0),半径为100
recordingCanvas.drawCircle(0,0,100,paint);
步骤4:结束录制
mPicture.endRecording();
步骤5:将存储在Picture的绘制内容绘制出来
有三种方法:
Picture.draw (Canvas canvas)
Canvas.drawPicture()
PictureDrawable.draw()
将Picture包装成为PictureDrawable
主要区别如下:
方法1:Picture提供的draw()
// 在复写的onDraw()里
@Override
protected void onDraw(Canvas canvas){
super.onDraw(canvas);
// 将录制的内容显示在当前画布里
mPicture.draw(canvas);
// 注:此方法绘制后可能会影响Canvas状态,不建议使用
}
方法2:Canvas提供的drawPicture()
不会影响Canvas状态
// 提供了三种方法 // 方法1 public void drawPicture (Picture picture) // 方法2 // Rect dst代表显示的区域 // 若区域小于图形,绘制的内容根据选区进行缩放 public void drawPicture (Picture picture, Rect dst) // 方法3 public void drawPicture (Picture picture, RectF dst) @Override protected void onDraw(Canvas canvas){ super.onDraw(canvas); // 实例1:将录制的内容显示(区域刚好布满图形) canvas.drawPicture(mPicture, new RectF(0, 0, mPicture.getWidth(), mPicture.getHeight())); // 实例2:将录制的内容显示在当前画布上(区域小于图形) canvas.drawPicture(mPicture, new RectF(0, 0, mPicture.getWidth(), 200));
方法3:使用PictureDrawable的draw方法绘制
将Picture包装成为PictureDrawable
@Override protected void onDraw(Canvas canvas){ super.onDraw(canvas); // 将录制的内容显示出来 // 将Picture包装成为Drawable PictureDrawable drawable = new PictureDrawable(mPicture); // 设置在画布上的绘制区域(类似drawPicture (Picture picture, Rect dst)的Rect dst参数) // 每次都从Picture的左上角开始绘制 // 并非根据该区域进行缩放,也不是剪裁Picture。 // 实例1:将录制的内容显示(区域刚好布满图形) drawable.setBounds(0, 0,mPicture.getWidth(), mPicture.getHeight()); // 绘制 drawable.draw(canvas); // 实例2:将录制的内容显示在当前画布上(区域小于图形) drawable.setBounds(0, 0,250, mPicture.getHeight());
b. 绘制位图(drawBitmap)
位图,即平时我们使用的图片资源
要绘制Bitmap,就要先获取一个Bitmap对象,具体获取方式如下:
特别注意:绘制位图(Bitmap)是读取已有的图片转换为Bitmap,最后再绘制到Canvas。
所以:
具体请自行了解关于Drawble的内容
通过BitmapFactory获取Bitmap (从不同位置获取):
// 共3个位置:资源文件、内存卡、网络 // 位置1:资源文件(drawable/mipmap/raw) Bitmap bitmap = BitmapFactory.decodeResource(mContext.getResources(),R.raw.bitmap); // 位置2:资源文件(assets) Bitmap bitmap=null; try { InputStream is = mContext.getAssets().open("bitmap.png"); bitmap = BitmapFactory.decodeStream(is); is.close(); } catch (IOException e) { e.printStackTrace(); } // 位置3:内存卡文件 Bitmap bitmap = BitmapFactory.decodeFile("/sdcard/bitmap.png"); // 位置4:网络文件: // 省略了获取网络输入流的代码 Bitmap bitmap = BitmapFactory.decodeStream(is); is.close();
绘制Bitmap
绘制Bitmap共有四种方法:
// 方法1
public void drawBitmap (Bitmap bitmap, Matrix matrix, Paint paint)
// 方法2
public void drawBitmap (Bitmap bitmap, float left, float top, Paint paint)
// 方法3
public void drawBitmap (Bitmap bitmap, Rect src, Rect dst, Paint paint)
// 方法4
public void drawBitmap (Bitmap bitmap, Rect src, RectF dst, Paint paint)
// 下面详细说
方法1
public void drawBitmap (Bitmap bitmap, Matrix matrix, Paint paint)
// 后两个参数matrix, paint是在绘制时对图片进行一些改变
// 后面会专门说matrix
// 如果只是将图片内容绘制出来只需将传入新建的matrix, paint对象即可:
canvas.drawBitmap(bitmap,new Matrix(),new Paint());
// 记得选取一种获取Bitmap的方式
// 注:图片左上角位置默认为坐标原点。
方法2
// 参数 left、top指定了图片左上角的坐标(距离坐标原点的距离):
public void drawBitmap (Bitmap bitmap, float left, float top, Paint paint)
canvas.drawBitmap(bitmap,300,400,new Paint());
方法3
public void drawBitmap (Bitmap bitmap, Rect src, Rect dst, Paint paint) // 参数(src,dst) = 两个矩形区域 // Rect src:指定需要绘制图片的区域(即要绘制图片的哪一部分) // Rect dst 或RectF dst:指定图片在屏幕上显示(绘制)的区域 // 下面我将用实例来说明 // 实例 // 指定图片绘制区域 // 仅绘制图片的二分之一 Rect src = new Rect(0,0,bitmap.getWidth()/2,bitmap.getHeight()); // 指定图片在屏幕上显示的区域 Rect dst = new Rect(100,100,250,250); // 绘制图片 canvas.drawBitmap(bitmap,src,dst,null); // 下面我们一步步分析:
特别注意的是:如果src规定绘制图片的区域大于dst指定显示的区域的话,那么图片的大小会被缩放。
方法3的应用场景:
// 通过传入具体路径Path对象 & 画笔
canvas.drawPath(mPath, mPaint)
改变之后,任何的后续操作都会受到影响
A. 画布变换
a. 平移(translate)
// 将画布原点向右移200px,向下移100px
canvas.translate(200, 100)
// 注:位移是基于当前位置移动,而不是每次都是基于屏幕左上角的(0,0)点移动
b. 缩放(scale)
// 共有两个方法
// 方法1
// 以(px,py)为中心,在x方向缩放sx倍,在y方向缩放sy倍
// 缩放中心默认为(0,0)
public final void scale(float sx, float sy)
// 方法2
// 比方法1多了两个参数(px,py),用于控制缩放中心位置
// 缩放中心为(px,py)
public final void scale (float sx, float sy, float px, float py)
我将用下面的例子说明缩放的使用和缩放中心的意义。
// 实例:画两个对比图 // 相同:都有两个矩形,第1个= 正常大小,第2个 = 放大1.5倍 // 不同点:第1个缩放中心在(0,0),第2个在(px,py) // 第一个图 // 设置矩形大小 RectF rect = new RectF(0,-200,200,0); // 绘制矩形(蓝色) canvas.drawRect(rect, mPaint1); // 将画布放大到1.5倍 // 不移动缩放中心,即缩放中心默认为(0,0) canvas.scale(1.5f, 1.5f); // 绘制放大1.5倍后的蓝色矩形(红色) canvas.drawRect(rect,mPaint2); // 第二个图 // 设置矩形大小 RectF rect = new RectF(0,-200,200,0); // 绘制矩形(蓝色) canvas.drawRect(rect, mPaint1); // 将画布放大到1.5倍,并将缩放中心移动到(100,0) canvas.scale(1.5f, 1.5f, 100,0); // 绘制放大1.5倍后的蓝色矩形(红色) canvas.drawRect(rect,mPaint2); // 缩放的本质是:把形状先画到画布,然后再缩小/放大。所以当放大倍数很大时,会有明显锯齿
当缩放倍数为负数时,会先进行缩放,然后根据不同情况进行图形翻转:
(设缩放倍数为(a,b),旋转中心为(px,py)):
1.a<0,b>0:以px为轴翻转
2.a>0,b<0:以py为轴翻转
3.a<0,b<0:以旋转中心翻转
具体如下图:(缩放倍数为1.5,旋转中心为(0,0)为例)
c. 旋转(rotate)
注意:角度增加方向为顺时针(区别于数学坐标系)
// 方法1
// 以原点(0,0)为中心旋转 degrees 度
public final void rotate(float degrees)
// 以原点(0,0)为中心旋转 90 度
canvas.rotate(90);
// 方法2
// 以(px,py)点为中心旋转degrees度
public final void rotate(float degrees, float px, float py)
// 以(30,50)为中心旋转 90 度
canvas.rotate(90,30,50);
d. 错切(skew)
// 参数 sx = tan a ,sx>0时表示向X正方向倾斜(即向左) // 参数 sy = tan b ,sy>0时表示向Y正方向倾斜(即向下) public void skew(float sx, float sy) // 实例 // 为了方便观察,我将坐标系移到屏幕中央 canvas.translate(300, 500); // 初始矩形 canvas.drawRect(20, 20, 400, 200, mPaint2); // 向X正方向倾斜45度 canvas.skew(1f, 0); canvas.drawRect(20, 20, 400, 200, mPaint1); //向X负方向倾斜45度 canvas.skew(-1f, 0); canvas.drawRect(20, 20, 400, 200, mPaint1); // 向Y正方向倾斜45度 canvas.skew(0, 1f); canvas.drawRect(20, 20, 400, 200, mPaint1); // 向Y负方向倾斜45度 canvas.skew(0, -1f); canvas.drawRect(20, 20, 400, 200, mPaint1);
B. 画布裁剪
特别注意:其余的区域只是不能编辑,但是并没有消失,如下图
裁剪共分为:裁剪路径、裁剪矩形、裁剪区域 // 裁剪路径 // 方法1 public boolean clipPath(@NonNull Path path) // 方法2 public boolean clipPath(@NonNull Path path, @NonNull Region.Op op) // 裁剪矩形 // 方法1 public boolean clipRect(int left, int top, int right, int bottom) // 方法2 public boolean clipRect(float left, float top, float right, float bottom) // 方法3 public boolean clipRect(float left, float top, float right, float bottom, @NonNull Region.Op op) // 裁剪区域 // 方法1 public boolean clipRegion(@NonNull Region region) // 方法2 public boolean clipRegion(@NonNull Region region, @NonNull Region.Op op)
这里特别说明一下参数Region.Op op
作用:在剪下多个区域下来的情况,当这些区域有重叠的时候,这个参数决定重叠部分该如何处理,多次裁剪之后究竟获得了哪个区域,有以下几种参数:
以三个参数为例讲解:
Region.Op.DIFFERENCE:显示第一次裁剪与第二次裁剪不重叠的区域
// 为了方便观察,我将坐标系移到屏幕中央 canvas.translate(300, 500); //原来画布设置为灰色 canvas.drawColor(Color.GRAY); //第一次裁剪 canvas.clipRect(0, 0, 600, 600); //将第一次裁剪后的区域设置为红色 canvas.drawColor(Color.RED); //第二次裁剪,并显示第一次裁剪与第二次裁剪不重叠的区域 canvas.clipRect(0, 200, 600, 400, Region.Op.DIFFERENCE); //将第一次裁剪与第二次裁剪不重叠的区域设置为黑色 canvas.drawColor(Color.BLACK);
Region.Op.REPLACE:显示第二次裁剪的区域
//原来画布设置为灰色)
canvas.drawColor(Color.GRAY);
//第一次裁剪
canvas.clipRect(0, 0, 600, 600);
//将第一次裁剪后的区域设置为红色
canvas.drawColor(Color.RED);
//第二次裁剪,并显示第二次裁剪的区域
canvas.clipRect(0, 200, 600, 400, Region.Op.REPLACE);
//将第二次裁剪的区域设置为蓝色
canvas.drawColor(Color.BLUE);
Region.Op.INTERSECT:显示第二次与第一次的重叠区域
//原来画布设置为灰色)
canvas.drawColor(Color.GRAY);
//第一次裁剪
canvas.clipRect(0, 0, 600, 600);
//将第一次裁剪后的区域设置为红色
canvas.drawColor(Color.RED);
//第二次裁剪,并显示第一次裁剪与第二次裁剪重叠的区域
canvas.clipRect(-100, 200, 600, 400, Region.Op.INTERSECT);
//将第一次裁剪与第二次裁剪重叠的区域设置为黑色
canvas.drawColor(Color.BLACK);
C. 画布快照
这里先理清几个概念
画布状态:当前画布经过的一系列操作
状态栈:存放画布状态和图层的栈(后进先出)
1.在画布上操作 = 在图层上操作
2.如无设置,绘制操作和画布操作是默认在默认图层上进行
3.在通常情况下,使用默认图层就可满足需求;若需要绘制复杂的内容(如地图),则需使用更多的图层
4.最终显示的结果 = 所有图层叠在一起的效果
a. 保存当前画布状态(save)
// 方法1: // 保存全部状态 public int save () // 方法2: // 根据saveFlags参数保存一部分状态 // 使用该参数可以只保存一部分状态,更加灵活 public int save (int saveFlags) // saveFlags参数说明: // 1.ALL_SAVE_FLAG(默认):保存全部状态 // 2. CLIP_SAVE_FLAG:保存剪辑区 // 3. CLIP_TO_LAYER_SAVE_FLAG:剪裁区作为图层保存 // 4. FULL_COLOR_LAYER_SAVE_FLAG:保存图层的全部色彩通道 // 5. HAS_ALPHA_LAYER_SAVE_FLAG:保存图层的alpha(不透明度)通道 // 6. MATRIX_SAVE_FLAG:保存Matrix信息(translate, rotate, scale, skew) // 每调用一次save(),都会在栈顶添加一条状态信息(入栈)
b. 保存某个图层状态(saveLayer)
使用起来非常复杂,因为图层之间叠加会导致计算量成倍增长,应尽量避免使用。
// 无图层alpha(不透明度)通道
public int saveLayer (RectF bounds, Paint paint)
public int saveLayer (RectF bounds, Paint paint, int saveFlags)
public int saveLayer (float left, float top, float right, float bottom, Paint paint)
public int saveLayer (float left, float top, float right, float bottom, Paint paint, int saveFlags)
// 有图层alpha(不透明度)通道
public int saveLayerAlpha (RectF bounds, int alpha)
public int saveLayerAlpha (RectF bounds, int alpha, int saveFlags)
public int saveLayerAlpha (float left, float top, float right, float bottom, int alpha)
public int saveLayerAlpha (float left, float top, float right, float bottom, int alpha, int saveFlags)
c. 回滚上一次保存的状态(restore)
// 采取状态栈的形式。即从栈顶取出一个状态进行恢复。
canvas.restore();
d. 回滚指定保存的状态(restoreToCount)
canvas.restoreToCount(3) ;
// 弹出 3、4、5的状态,并恢复第3次保存的画布状态
e. 获取保存的次数(getSaveCount)
即获取状态栈中保存状态的数量
canvas.getSaveCount();
// 以上面栈为例,则返回5
// 注:即使弹出所有的状态,返回值依旧为1,代表默认状态。(返回值最小为1)
总结
对于画布状态的保存和回滚的套路,一般如下:
// 步骤1:保存当前状态
// 把Canvas的当前状态信息入栈
save();
// 步骤2:对画布进行各种操作(旋转、平移Blabla)
...
// 步骤3:回滚到之前的画布状态
// 把栈里面的信息出栈,取代当前的Canvas信息
restore();
步骤1:实现Measure、Layout、Draw流程
步骤2:自定义属性
自定义View一共分为两大类,具体如下图:
对于自定义View的类型介绍及使用场景如下图:
在使用自定义View时有很多注意点(坑),希望大家要非常留意:
具体原因请看文章:为什么你的自定义View wrap_content不起作用?
1.对于继承View的控件,padding是在draw()中处理
2.对于继承ViewGroup的控件,padding和margin会直接影响measure和layout过程
View的内部本身提供了post系列的方法,完全可以替代Handler的作用,使用起来更加方便、直接。
主要针对View中含有线程或动画的情况:当View退出或不可见时,记得及时停止该View包含的线程和动画,否则会造成内存泄露问题。
启动或停止线程/ 动画的方式:
- 启动线程/ 动画:使用view.onAttachedToWindow(),因为该方法调用的时机是当包含View的Activity启动的时刻
- 停止线程/ 动画:使用view.onDetachedFromWindow(),因为该方法调用的时机是当包含View的Activity退出或当前View被remove的时刻
当View带有滑动嵌套情况时,必须要处理好滑动冲突,否则会严重影响View的显示效果。
在下面的例子中,我将讲解:
下面我将逐个步骤进行说明:
步骤1:创建自定义View类(继承View类)
/** * CircleView.java * 作用:绘制自定义View的具体内容 * 需复写方法:onDraw() * 复写逻辑:具体绘制逻辑 */ public class CircleView extends View { // 设置画笔变量 Paint mPaint1; // 自定义View有四个构造函数 // 如果View是在Java代码里面new的,则调用第一个构造函数 public CircleView(Context context){ super(context); // 在构造函数里初始化画笔的操作 init(); } // 如果View是在.xml里声明的,则调用第二个构造函数 // 自定义属性是从AttributeSet参数传进来的 public CircleView(Context context,AttributeSet attrs){ super(context, attrs); init(); } // 不会自动调用 // 一般是在第二个构造函数里主动调用 // 如View有style属性时 public CircleView(Context context,AttributeSet attrs,int defStyleAttr ){ super(context, attrs,defStyleAttr); init(); } // API21之后才使用 // 不会自动调用 // 一般是在第二个构造函数里主动调用 // 如View有style属性时 public CircleView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } // 画笔初始化 private void init() { // 创建画笔 mPaint1 = new Paint (); // 设置画笔颜色为蓝色 mPaint1.setColor(Color.BLUE); // 设置画笔宽度为10px mPaint1.setStrokeWidth(5f); //设置画笔模式为填充 mPaint1.setStyle(Paint.Style.FILL); } // 关键点:复写onDraw()进行绘制 @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 获取控件的高度和宽度 int width = getWidth(); int height = getHeight(); // 设置圆的半径 = 宽,高最小值的2分之1 int r = Math.min(width, height)/2; // 画出圆(蓝色) // 圆心 = 控件的中央,半径 = 宽,高最小值的2分之1 canvas.drawCircle(width/2,height/2,r,mPaint1); } }
步骤2:在布局文件中添加自定义View类的组件及显示
/** * 1. 在布局文件中添加自定义View类的组件 * activity_main.xml */ <?xml version="1.0" encoding="utf-8"?> <RelativeLayout 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" tools:context="scut.carson_ho.diy_view.MainActivity"> <!-- 注意添加自定义View组件的标签名:包名 + 自定义View类名--> <!-- 控件背景设置为黑色--> <scut.carson_ho.diy_view.CircleView android:layout_width="match_parent" android:layout_height="150dp" android:background="#000000" </RelativeLayout> /** * 2. 设置显示 * MainActivity.java */ public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); } }
至此,一个基本的自定义View已经实现了,运行效果如下图。
步骤3:注意点设置(支持wrap_content & padding属性自定义属性等等)
接下来继续看自定义View关于属性自定义的问题:
- 如何手动支持wrap_content属性
- 如何手动支持padding属性
- 如何为自定义View提供自定义属性(如颜色等等)
a. 手动支持wrap_content属性
先来看wrap_content & match_parent属性的区别
// 视图的宽和高被设定成刚好适应视图内容的最小尺寸
android:layout_width="wrap_content"
// 视图的宽和高延伸至充满整个父布局
android:layout_width="match_parent"
// 在Android API 8之前叫作"fill_parent"
如果不手动设置支持wrap_content属性,那么wrap_content属性是不会生效(显示效果同match_parent)
b. 支持padding属性
padding属性:用于设置控件内容相对控件边缘的边距;
区别与margin属性(同样称为:边距):控件边缘相对父控件的边距(父控件控制),具体区别如下:
如果不手动设置支持padding属性,那么padding属性在自定义View中是不会生效的。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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"
tools:context="scut.carson_ho.diy_view.MainActivity">
<scut.carson_ho.diy_view.CircleView
android:layout_width="match_parent"
android:layout_height="match_parent"
/** 添加Padding属性,但不会生效 **/
android:padding="20dp"
/>
</RelativeLayout>
解决方案
绘制时考虑传入的padding属性值(四个方向)。
在自定义View类的复写onDraw()进行设置
/** * 复写的onDraw() */ @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 获取传入的padding值 final int paddingLeft = getPaddingLeft(); final int paddingRight = getPaddingRight(); final int paddingTop = getPaddingTop(); final int paddingBottom = getPaddingBottom(); // 获取绘制内容的高度和宽度(考虑了四个方向的padding值) int width = getWidth() - paddingLeft - paddingRight ; int height = getHeight() - paddingTop - paddingBottom ; // 设置圆的半径 = 宽,高最小值的2分之1 int r = Math.min(width, height)/2; // 画出圆(蓝色) // 圆心 = 控件的中央,半径 = 宽,高最小值的2分之1 canvas.drawCircle(paddingLeft+width/2,paddingTop+height/2,r,mPaint1); }
c. 提供自定义属性
除了常见的以android:开头的系统属性(如下所示),很多场景下自定义View还需要系统所没有的属性,即自定义属性。
// 基本是以android开头
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000"
android:padding="30dp"
实现自定义属性的步骤如下:
1.在values目录下创建自定义属性的xml文件
2.在自定义View的构造方法中解析自定义属性的值
3.在布局文件中使用自定义属性
下面我将对每个步骤进行具体介绍:
步骤1:在values目录下创建自定义属性的xml文件
/** * attrs_circle_view.xml */ <?xml version="1.0" encoding="utf-8"?> <resources> // 自定义属性集合:CircleView // 在该集合下,设置不同的自定义属性 <declare-styleable name="CircleView"> // 在attr标签下设置需要的自定义属性 // 此处定义了一个设置图形的颜色:circle_color属性,格式是color,代表颜色 // 格式有很多种,如资源id(reference)等等 <attr name="circle_color" format="color"/> </declare-styleable> </resources>
对于自定义属性类型 & 格式如下:
<-- 1. reference:使用某一资源ID --> <declare-styleable name="名称"> <attr name="background" format="reference" /> </declare-styleable> // 使用格式 // 1. Java代码 private int ResID; private Drawable ResDraw; ResID = typedArray.getResourceId(R.styleable.SuperEditText_background, R.drawable.background); // 获得资源ID ResDraw = getResources().getDrawable(ResID); // 获得Drawble对象 // 2. xml代码 <ImageView android:layout_width="42dip" android:layout_height="42dip" app:background="@drawable/图片ID" /> <-- 2. color:颜色值 --> <declare-styleable name="名称"> <attr name="textColor" format="color" /> </declare-styleable> // 格式使用 <TextView android:layout_width="42dip" android:layout_height="42dip" android:textColor="#00FF00" /> <-- 3. boolean:布尔值 --> <declare-styleable name="名称"> <attr name="focusable" format="boolean" /> </declare-styleable> // 格式使用 <Button android:layout_width="42dip" android:layout_height="42dip" android:focusable="true" /> <-- 4. dimension:尺寸值 --> <declare-styleable name="名称"> <attr name="layout_width" format="dimension" /> </declare-styleable> // 格式使用: <Button android:layout_width="42dip" android:layout_height="42dip" /> <-- 5. float:浮点值 --> <declare-styleable name="AlphaAnimation"> <attr name="fromAlpha" format="float" /> <attr name="toAlpha" format="float" /> </declare-styleable> // 格式使用 <alpha android:fromAlpha="1.0" android:toAlpha="0.7" /> <-- 6. integer:整型值 --> <declare-styleable name="AnimatedRotateDrawable"> <attr name="frameDuration" format="integer" /> <attr name="framesCount" format="integer" /> </declare-styleable> // 格式使用 <animated-rotate xmlns:android="http://schemas.android.com/apk/res/android" android:frameDuration="100" android:framesCount="12" /> <-- 7. string:字符串 --> <declare-styleable name="MapView"> <attr name="apiKey" format="string" /> </declare-styleable> // 格式使用 <com.google.android.maps.MapView android:apiKey="0jOkQ80oD1JL9C6HAja99uGXCRiS2CGjKO_bc_g" /> <-- 8. fraction:百分数 --> <declare-styleable name="RotateDrawable"> <attr name="pivotX" format="fraction" /> <attr name="pivotY" format="fraction" /> </declare-styleable> // 格式使用 <rotate xmlns:android="http://schemas.android.com/apk/res/android" android:pivotX="200%" android:pivotY="300%" /> <-- 9. enum:枚举值 --> <declare-styleable name="名称"> <attr name="orientation"> <enum name="horizontal" value="0" /> <enum name="vertical" value="1" /> </attr> </declare-styleable> // 格式使用 <LinearLayout android:layout_width="fill_parent" android:layout_height="fill_parent" /> <-- 10. flag:位或运算 --> <declare-styleable name="名称"> <attr name="windowSoftInputMode"> <flag name="stateUnspecified" value="0" /> <flag name="stateUnchanged" value="1" /> <flag name="stateHidden" value="2" /> <flag name="stateAlwaysHidden" value="3" /> <flag name="stateVisible" value="4" /> <flag name="stateAlwaysVisible" value="5" /> <flag name="adjustUnspecified" value="0x00" /> <flag name="adjustResize" value="0x10" /> <flag name="adjustPan" value="0x20" /> <flag name="adjustNothing" value="0x30" /> </attr> </declare-styleable>、 // 使用 <activity android:name=".StyleAndThemeActivity" android:label="@string/app_name" android:windowSoftInputMode="stateUnspecified | stateUnchanged | stateHidden" > <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <-- 特别注意:属性定义时可以指定多种类型值 --> <declare-styleable name="名称"> <attr name="background" format="reference|color" /> </declare-styleable> // 使用 <ImageView android:layout_width="42dip" android:layout_height="42dip" android:background="@drawable/图片ID|#00FF00" />
步骤2:在自定义View的构造方法中解析自定义属性的值
/** * 此处是需要解析circle_color属性的值 * 该构造函数需要重写 */ public CircleView(Context context, AttributeSet attrs) { this(context, attrs,0); // 原来是:super(context,attrs); init(); public CircleView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); // 加载自定义属性集合CircleView TypedArray a = context.obtainStyledAttributes(attrs,R.styleable.CircleView); // 解析集合中的属性circle_color属性 // 该属性的id为:R.styleable.CircleView_circle_color // 将解析的属性传入到画圆的画笔颜色变量当中(本质上是自定义画圆画笔的颜色) // 第二个参数是默认设置颜色(即无指定circle_color情况下使用) mColor = a.getColor(R.styleable.CircleView_circle_color,Color.RED); // 解析后释放资源 a.recycle(); init(); }
步骤3:在布局文件中使用自定义属性
/** * activity_main.xml */ <?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" <!--必须添加schemas声明才能使用自定义属性--> xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context="scut.carson_ho.diy_view.MainActivity" > <!-- 注意添加自定义View组件的标签名:包名 + 自定义View类名--> <!-- 控件背景设置为黑色--> <scut.carson_ho.diy_view.CircleView android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="#000000" android:padding="30dp" <!--设置自定义颜色--> app:circle_color="#FF4081" /> </RelativeLayout>
转载至:
作者:AngelDevil
链接:理解View的构造函数
来源:cnblog
作者:泡在网上的日子
链接:深入理解View的构造函数
来源:泡网
作者:星晴_371a
链接:Android主题的一些总结
来源:简书
作者:Carson带你学安卓
链接:自定义View基础必知必会!
链接:自定义View工作流程
链接:ViewRoot、DecorView & Window的简介
链接:自定义View测量过程
链接:测量规格MeasureSpec
链接:自定义View布局过程
链接:自定义View绘制过程
链接:自定义View Canvas类全面解析
链接:自定义View Path类全面解析
链接:自定义View实例讲解
来源:简书
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。