Android框架为我们提供了大量的视图类来帮助我们做好展示信息以及同用户进行交互的工作。然后有时候,我们的app或许需要一些在Android内建视图之外特殊的视图,那么此时我们就需要自定义视图。下面我们来看看如何构建一个具有鲁棒性和可重用的视图。本文主要结合谷歌官方文档和API介绍自定义视图。
第一步:建立一个视图类
1.1 继承自View作为View的一个子类
一个设计良好的自定义视图类应该和其他设计良好的类一样:封装了丰富的功能、为用户提供了易用的接口、高效的使用CPU和内存等。此外,自定义视图应该:符合Android规范、利用XML提供自定义样式属性、发送访问事件已经兼容不同的Android平台。
在Android框架中,所有的视图类都继承自View类,所有我们的自定义视图类也应该继承自View类或者某些View的子类(比如:Button)。
为了便于ADT和我们的自定义视图进行交互,我们应该提供给一个至少包含Context和AttributeSet作为参数的构造函数。这样的一个构造函数也是布局编辑器可以创建和实例化我们的自定义视图。
1 class PieChart extends View { 2 public PieChart(Context context, AttributeSet attrs) { 3 super(context, attrs); 4 } 5 }
1.2 自定义属性
当我们添加一个内建的视图时,我们使用XML中的元指定它的外观和行为。设计良好的自定义视图也应该可以通过XML进行添加以及设定样式。为此,我们应该:在一个<declare-styleable>资源中为我们的视图 定义自定义属性、在XML中为属性指定数值、运行时抽取属性值、将抽取的属性值应用于自定义视图。例如,添加一个res/values/attrs.xml
1 <resources> 2 <declare-styleable name="PieChart"> 3 <attr name="showText" format="boolean" /> 4 <attr name="labelPosition" format="enum"> 5 <enum name="left" value="0"/> 6 <enum name="right" value="1"/> 7 </attr> 8 </declare-styleable> 9 </resources>
这段代码定义了两个用户属性:showText和labelPosition,它们都属于一个叫做“PieChart”的styleable实体。按照常理,styleable实体的名字应该和自定义视图的名字相同。
当定义好自定义属性时,我们就可以像使用内建属性一样在布局XML文件中使用它们。唯一不同的是,自定义视图改变了命名空间,它不在属于
http://schemas.android.com/apk/res/android,而是属于http://schemas.android.com/apk/res/[your package name]。
1 <?xml version="1.0" encoding="utf-8"?> 2 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 3 xmlns:custom="http://schemas.android.com/apk/res/com.example.customviews"> 4 <com.example.customviews.charting.PieChart 5 custom:showText="true" 6 custom:labelPosition="left" /> 7 </LinearLayout>
注意:在XML标签中应该使用全名。尤其是当自定义视图是内部类时,必须用外部类来指明。例如:PieChart类有一个内部类叫做PieView,那么我们就应该使用<com.example.customviews.charting.PieChart$PieView>标签。
1.3 应用自定义属性
当根据XML布局穿件一个View时,所有XML标签中的属性都会被从资源bundle中读取并作为AttributeSet参数传递给构造函数。尽管可以直接从AttributeSet中读取各个属性的值,但是这么做有两个缺点:属性值中引用的资源不会被解析、样式不会被应用。
推荐的做法是:将AttributeSet传递给obtainStyledAttributes()方法。这个方法会返回一个包含已经解析和定义好样式的TypeArray数组。
1 public PieChart(Context context, AttributeSet attrs) { 2 super(context, attrs); 3 TypedArray a = context.getTheme().obtainStyledAttributes( 4 attrs, 5 R.styleable.PieChart, 6 0, 0); 7 try { 8 mShowText = a.getBoolean(R.styleable.PieChart_showText, false); 9 mTextPos = a.getInteger(R.styleable.PieChart_labelPosition, 0); 10 } finally { 11 a.recycle(); 12 } 13 }
TypeArray的作用就是取出每一个对应位置的属性值。我们需要在最后调用recycle()函数,一边以在下一次调用时重用。所有涉及TypeArray的操作都应该在recycle()被调用之前。
1.4 添加属性和事件
Attribute可以方便我们定义视图的外观,但是它们只会在初始化时被读取。为了提供动态行为,我们可以为每个自定义属性提供一个getter和setter方法。下面的代码说明了如何提供一个叫做“showText”的属性。
1 public boolean isShowText() { 2 return mShowText; 3 } 4 public void setShowText(boolean showText) { 5 mShowText = showText; 6 invalidate(); 7 requestLayout(); 8 }
注意:需要在进行了可能会改变外观的操作之后调用invalidate()函数和requestLayout()函数,来使得系统重新绘制view,确定大小和形状。这也确保了视图行为的可靠性和可用性。
1.5 无障碍
Android提供了良好的机制来帮助有视觉、听觉以及其他身体限制的用户使用Android设设备。文字语音转换功能、触控反馈、轨迹球以及D-pad等都为用户提供了良好的辅助性功能。 有关详细内容请阅读:https://developer.android.com/guide/topics/ui/accessibility/index.html
第二步:自定义绘图
2.1 重载onDraw()方法
自定义视图最重要的部分便是它的外观。我们通过重载onDraw()方法来实现自定义的外观。传递给onDraw()方法的参数是Canvas对象,Canvas类定义了绘制文字、线、位图以及其他基本图形的方法。
在调用所有的绘制方法之前,我们需要创建一个Paint对象。
2.2 创建Drawing对象
我们可以简单的做个类比:Canvas好比是一个画布(实际是一个bitmap, 我们自定义的视图都是在这个Bitmap上进行绘制的),而Paint是一个画笔(确定了颜色和样式等信息)。Canvas提供了绘制一条线的方法,而Piant提供了定义这条线颜色的方法。Canvas提供了绘制一个矩形的方法,而Paint定义了这个矩形是否需要填充。总结起来,Canvas定义了绘制在屏幕上的形状,而Paint定义了颜色、字体、样式等。
1 private void init() { 2 mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 3 mTextPaint.setColor(mTextColor); 4 if (mTextHeight == 0) { 5 mTextHeight = mTextPaint.getTextSize(); 6 } else { 7 mTextPaint.setTextSize(mTextHeight); 8 } 9 mPiePaint = new Paint(Paint.ANTI_ALIAS_FLAG); 10 mPiePaint.setStyle(Paint.Style.FILL); 11 mPiePaint.setTextSize(mTextHeight); 12 mShadowPaint = new Paint(0); 13 mShadowPaint.setColor(0xff101010); 14 mShadowPaint.setMaskFilter(new BlurMaskFilter(8, BlurMaskFilter.Blur.NORMAL)); 15 ...
提前创建视图是一个很重要的优化的方法,View会被频繁的重新绘制,这会消耗昂贵的资源。如果在onDraw()方法中创建绘图相关的对象将会大大降低性能,导致UI卡顿。上面的代码在构造函数中被调用,从而避免了这个问题。
2.3 处理布局事件
为了绘制自定义视图,我们必须知道视图的大小。负责的自定义视图常常需要根据它们显示在屏幕上的大小和形状进行多个布局相关的计算。尽管View提供了处理尺寸的方法,但是其中大部分我们都不需要进行重载。如果我们不需要进行特殊的大小设置,我们只需要重载onSizeChanged()方法。onSizeChanged()会在View第一次被分配大小以及大小改变时被调用。计算位置,分隔符以及其他和View大小相关的操作都在onSizeChanged()中进行。在为View分配大小时,布局管理器默认包含了padding的大小。
1 // Account for padding 2 float xpad = (float)(getPaddingLeft() + getPaddingRight()); 3 float ypad = (float)(getPaddingTop() + getPaddingBottom()); 4 // Account for the label 5 if (mShowText) xpad += mTextWidth; 6 float ww = (float)w - xpad; 7 float hh = (float)h - ypad; 8 // Figure out how big we can make the pie. 9 float diameter = Math.min(ww, hh);
如果我们需要更好的控制视图的布局,就需要重载onMeasure()方法。这个方法的参数View.MeasureSpec会告诉父视图希望我们的视图的大小
以及是硬性最大值还是建议值。MeasureSpec代表了父类给子类的布局要求的封装。每一个MeasureSpec代表一个宽度和高度的要求,它是大小和模式(共三种模式:1.UNSPECIFIED:父类对子类的大小没有限制,子类可以获得期望的大小。 2.EXACTLY:父类为子类确定了一个确切的大小。3. AT_MOST: 只要不超出指定大小,子类想要多大就多大)的组合。
例:PieChart试图是的自己足够大来来使得饼图和标签大小一样。
1 @Override 2 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 3 // Try for a width based on our minimum 4 int minw = getPaddingLeft() + getPaddingRight() + getSuggestedMinimumWidth(); 5 int w = resolveSizeAndState(minw, widthMeasureSpec, 1); 6 // Whatever the width ends up being, ask for a height that would let the pie 7 // get as big as it can 8 int minh = MeasureSpec.getSize(w) - (int)mTextWidth + getPaddingBottom() + getPaddingTop(); 9 int h = resolveSizeAndState(MeasureSpec.getSize(w) - (int)mTextWidth, heightMeasureSpec, 0); 10 setMeasuredDimension(w, h); 11 }
注意:
1.正如前面提及的,这些计算中需要考虑padding。2.resolveSizeAndState()方法用来确认最后个的宽度和高度。它会通过比较期望的大小和传递进onMeasure()方法的spec值来返回一个合适的View.MeasureSpec。除非有引入一个不同大小的限制,否则它会去期望的数值。3.onMeasure()方法没有返回值。它会在通过调用setMeasuredDimension()方法来传递结果。setMeasuredDimension()是强制需要的,如果没有调用它,会抛出运行时异常。
2.4 绘制
在创建了对象,并且定义的measure代码之后,我们可以开始实现onDraw()方法。每一个View的onDraw()方法都不相同,但是却大部分是有一些共同的操作的:
1. 使用drawText()来绘制Text,使用setTypeface设置字体,使用setColor设置字体颜色。
2. 使用drawRectangular()、drawOval()和drawArc()来绘制基本图形,通过setStyle()方法可以设置填空、边框等样式。
3. 使用Path类来绘制复杂图形。通过调用drawPath()为Path添加直线和曲线来绘制图形。同样,可以通过setStyle()方法可以设置填空、边框等样式。
4. 创建LinearGradient来定义渐变,调用setSharder()来使用LinearGradient构建渐变填充。
5. 使用drawBitmap()来绘制bitmap。
1 protected void onDraw(Canvas canvas) { 2 super.onDraw(canvas); 3 // Draw the shadow 4 canvas.drawOval( 5 mShadowBounds, 6 mShadowPaint 7 ); 8 // Draw the label text 9 canvas.drawText(mData.get(mCurrentItem).mLabel, mTextX, mTextY, mTextPaint); 10 // Draw the pie slices 11 for (int i = 0; i < mData.size(); ++i) { 12 Item it = mData.get(i); 13 mPiePaint.setShader(it.mShader); 14 canvas.drawArc(mBounds, 15 360 - it.mEndAngle, 16 it.mEndAngle - it.mStartAngle, 17 true, mPiePaint); 18 } 19 // Draw the pointer 20 canvas.drawLine(mTextX, mPointerY, mPointerX, mPointerY, mTextPaint); 21 canvas.drawCircle(mPointerX, mPointerY, mPointerSize, mTextPaint); 22 }
其中slice的角度计算如下
1 for (Item it : mData) { 2 it.mStartAngle = currentAngle; 3 it.mEndAngle = (int) ((float) currentAngle + it.mValue * 360.0f / mTotal); 4 currentAngle = it.mEndAngle; 5 ... 6 }
参考:http://developer.android.com/training/custom-views/index.html