翻译自:http://developer.android.com/training/custom-views/index.html
一个设计良好的自定义view与其他的类一样,它使用接口来封装一系列的功能,有效的使用CPU和内存等。除了这些,定制view还应该满足如下条件:
Android框架提供了一系列的基类和XML标识以便用户可以很方便的创建满足上述条件的View。该节将讨论如何使用Android框架来创建view类的核心功能。
Android框架中的所有view类都继承自View类。自定义view也可以直接继承View类,或它的已经定义的子类,如Button类。
为了与Android studio交互,至少要定义一个以Context和AttributeSet对象为参数的构造函数,该构造函数运行布局编辑器创建和修改view的实例,如:
classPieChartextendsView{
public PieChart(Context context, AttributeSet attrs) { super(context, attrs); } }
通常情况下,在res/values/attrs.xml文件中添加<declare-styleable>,如:
<resources> <declare-styleable name="PieChart"> <attr name="showText" format="boolean" /> <attr name="labelPosition" format="enum"> <enum name="left" value="0"/> <enum name="right" value="1"/> </attr> </declare-styleable> </resources>
声明了两个自定义属性:showText和labelPosition,归属于PieChart样式,按照惯例,样式名字一般都取与view类相同的名字,许多代码编辑器依赖这个来提供语句补充功能。
对上述自定义属性,我们可以像使用内置属性一样在布局文件中使用,唯一的区别是他们属于不同的命名空间。它们属于http://schemas.android.com/apk/res/[yourpackage name]
. 而内置属性属于http://schemas.android.com/apk/res/android
命名空间。它们的使用如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:custom="http://schemas.android.com/apk/res/com.example.customviews"> <com.example.customviews.charting.PieChart custom:showText="true" custom:labelPosition="left" /> </LinearLayout>
上例中使用xmlns命令来避免重复很长的命名空间URI,该命令给http://schemas.android.com/apk/res/com.example.customviews
指定了一个别名custom。另外,在布局文件中添加XML标示时,需要使用view类的全名。如果自定义的view类时一个内部类的话,必现包含它的外部类的名字。如要使用PieChart
类的内部类 PieView
的话,使用标识: com.example.customviews.charting.PieChart$PieView
.
当在XML文件中创建view时,XML标识上的所有属性被从资源bundle中读入并作为一个AttributeSet实例传输到view的构造函数中。尽管可以直接从AttributSet实例中读取属性值,因为如下原因不被推荐:
因此,需要使用 obtainStyledAttributes() 来处理AttributeSet,它会返回一个TypedArray,包含已经解引用和采用样式的属性数组。
Android 资源编译器做了很多工作以便我们可以方便的调用 obtainStyledAttributes() 。 对res文件夹下的每个 <declare-styleable>
资源, 生成的R.java文件定义了一组常量来指向每一个属性。可以使用这些常量从 TypedArray中读取属性值,如:
public PieChart(Context context, AttributeSet attrs) { super(context, attrs); TypedArray a = context.getTheme().obtainStyledAttributes( attrs, R.styleable.PieChart, 0, 0); try { mShowText = a.getBoolean(R.styleable.PieChart_showText, false); mTextPos = a.getInteger(R.styleable.PieChart_labelPosition, 0); } finally { a.recycle(); } }
需要注意的是,TypedArray 对象是一个共享的资源,使用后必须回收。
官网关于obtainStyledAttributes的定义:
Return a TypedArray holding the attribute values in set that are listed in attrs. In addition, if the given AttributeSet specifies a style class (through the "style" attribute), that style will be applied on top of the base attributes it defines.
Be sure to call TypedArray.recycle()
when you are done with the array.
When determining the final value of a particular attribute, there are four inputs that come into play:
Each of these inputs is considered in-order, with the first listed taking precedence over the following ones. In other words, if in the AttributeSet you have supplied <Button textColor="#ff000000">
, then the button's text will always be black, regardless of what is specified in any of the styles.
Parameters | |
---|---|
set |
AttributeSet : The base set of attribute values. May be null. |
attrs |
int : The desired attributes to be retrieved. |
defStyleAttr |
int : An attribute in the current theme that contains a reference to a style resource that supplies defaults values for the TypedArray. Can be 0 to not look for defaults. |
defStyleRes |
int : A resource identifier of a style resource that supplies default values for the TypedArray, used only if defStyleAttr is 0 or can not be found in the theme. Can be 0 to not look for defaults. |
publicboolean isShowText(){
return mShowText; } public void setShowText(boolean showText) { mShowText = showText; invalidate(); requestLayout(); }
注意setShowText
调用了 invalidate() 和 requestLayout(). 这些调用对view的行为很重要。修改任何可能影响view外观的属性时,必须使view失效,以便系统知道它需要重新绘制。同样的,修改任何可能会引起view尺寸大小的属性时,必须请求重新布局。遗漏了上述方法会引起很难定位的bug.
自定义view应该支持事件监听器以便于重要事件的交互。如, PieChart 提供了一个自定义事件
OnCurrentItemChanged
通知监听者用户对饼图做了修改。
很容易忘记将属性和事件公开以便使用者使用。一个很好的原则是:总是公开会影响定制view可见外观和行为的属性。
android:contentDescription
属性给输入区域做标签更多信息,见Android 开发文档:Making ApplicationsAccessible .
onDraw() 使用Canvas对象作为参数,Canvas 类定义了绘制文字,线条,位图及其他简单图形的函数。可以在 onDraw() 中使用这些方法创建定制的用户界面。
框架将绘画分为两个部分:
例如, Canvas类提供了绘制线条的方法,而Paint类提供了定义线条颜色的方法。 Canvas类有绘制长方形的方法, 而Paint类定义是否用颜色填充长方形或让它为空。总之,Canvas类定义可以在屏幕上绘制的形状,而Paint类为绘制的形状定义颜色,字体等。
因此,在绘制之前,需要创建一个或多个Paint对象。下面的PieChart类在构造函数中调用init方法,该方法用来创建Paint对象:
private void init() { mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mTextPaint.setColor(mTextColor); if (mTextHeight == 0) { mTextHeight = mTextPaint.getTextSize(); } else { mTextPaint.setTextSize(mTextHeight); } mPiePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mPiePaint.setStyle(Paint.Style.FILL); mPiePaint.setTextSize(mTextHeight); mShadowPaint = new Paint(0); mShadowPaint.setColor(0xff101010); mShadowPaint.setMaskFilter(new BlurMaskFilter(8, BlurMaskFilter.Blur.NORMAL)); ...
提前创建绘画对象是一个很重要的优化。页面经常会被重新绘制,会产生很多绘画对象,致使初始化成本变得很高。在onDraw()方法中创建绘画对象会很明显地影响性能,并且引起UI迟滞。
在绘制定制view前,需要知道它的尺寸。复杂的定制view需要根据屏幕上绘制区域的尺寸和形状执行多次布局计算。永远都不应该对view在屏幕上的尺寸做假设。即使只有一个app使用你的view,该app也需要处理不同的屏幕尺寸,不同的屏幕密度和不同的纵横屏幕比例。
尽管View 类有很多处理尺寸的方法, 大多数不需要被重写。如果定制页面需要对尺寸做特殊的处理,只需要重新一个方法:onSizeChanged().
onSizeChanged() 会在view页面首次被指定一个尺寸和view页面尺寸改变时被调用。在onSizeChanged()方法中计算位置,尺寸,而不是在每次绘制时重新计算。
PieChart
中,在onSizeChanged()
中计算包含饼图的正方形的尺寸及文字图标和其他可见元素的相对位置。
当view被指定一个尺寸后,布局管理器假设该尺寸已经包含view的填充(即padding)。因此,计算view尺寸时必须处理填充值。下面代码节选自PieChart.onSizeChanged(),显示如何来做:
// Account for padding float xpad = (float)(getPaddingLeft() + getPaddingRight()); float ypad = (float)(getPaddingTop() + getPaddingBottom()); // Account for the label if (mShowText) xpad += mTextWidth; float ww = (float)w - xpad; float hh = (float)h - ypad; // Figure out how big we can make the pie. float diameter = Math.min(ww, hh);
如果需要对布局参数做更精细的管理,需要实现onMeasure()函数,该函数的参数是 View.MeasureSpec,用来表明该view的父类期望的view的尺寸及这个尺寸是最大值还是建议的尺寸。该值做了优化,被打包存储在整数中,需要使用静态方法View.MeasureSpec 去解开存储在每个整数中的信息。
以下是onMeasure()的实现,PieChart试图使它的饼图尽可能的大:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // Try for a width based on our minimum int minw = getPaddingLeft() + getPaddingRight() + getSuggestedMinimumWidth(); int w = resolveSizeAndState(minw, widthMeasureSpec, 1); // Whatever the width ends up being, ask for a height that would let the pie // get as big as it can int minh = MeasureSpec.getSize(w) - (int)mTextWidth + getPaddingBottom() + getPaddingTop(); int h = resolveSizeAndState(MeasureSpec.getSize(w) - (int)mTextWidth, heightMeasureSpec, 0); setMeasuredDimension(w, h); }
上述代码有三点需要注意:
示例如下:
protected void onDraw(Canvas canvas) { super.onDraw(canvas); // Draw the shadow canvas.drawOval( mShadowBounds, mShadowPaint ); // Draw the label text canvas.drawText(mData.get(mCurrentItem).mLabel, mTextX, mTextY, mTextPaint); // Draw the pie slices for (int i = 0; i < mData.size(); ++i) { Item it = mData.get(i); mPiePaint.setShader(it.mShader); canvas.drawArc(mBounds, 360 - it.mEndAngle, it.mEndAngle - it.mStartAngle, true, mPiePaint); } // Draw the pointer canvas.drawLine(mTextX, mPointerY, mPointerX, mPointerY, mTextPaint); canvas.drawCircle(mPointerX, mPointerY, mPointerSize, mTextPaint); }
绘制UI只是创建定制view的一部分,view还需要响应用户输入。设计交互时,需要注意模仿现实世界的操作。例如,图片不应该在一个地方立即退出然后又在另外一个地方重新出现,而应该从一个地方移动到另外一个地方。
这一节展示如何使用Android框架的特性来给定值view添加真实世界的行为。
同其他UI框架一样,Android支持输入数据模型。用户操作被转换成引起回调的事件,这些回调可以被重写以便定制应用程序对用户操作的回应。Android系统中最常见的输入事件是触摸事件(touch), 它会引起onTouchEvent(android.view.MotionEvent).回调。重写该函数来处理事件:
@Override public boolean onTouchEvent(MotionEvent event) { return super.onTouchEvent(event); }
触摸事件本身不是特别有用。现代的UI系统会根据手势将其分类,如点击(tapping), 上拉或下拉(pulling), pushing,急冲(flinging),缩放(zooming)等。Android提供 GestureDetector类来将原始的触摸事件转换为手势。
以一个实现GestureDetector.OnGestureListener接口的类实例为参数来构造GestureDetector类。如果只需要处理简单的手势,可以继承 GestureDetector.SimpleOnGestureListener 类而不需要实现GestureDetector.OnGestureListener接口。如下例:
class mListener extends GestureDetector.SimpleOnGestureListener { @Override public boolean onDown(MotionEvent e) { return true; } } mDetector = new GestureDetector(PieChart.this.getContext(), new mListener());
无论是否使用GestureDetector.SimpleOnGestureListener类,必现实现onDown() 方法且该方法返回值为true, 因为所以的手势都是从onDown()消息开始的。如果onDown()函数返回false(GestureDetector.SimpleOnGestureListener的默认行为), 系统会认为你系那个忽略接下来的手势,因此GestureDetector.onGestureListener中的其他函数就永远不会被调用。 onDown()返回fasle的唯一用处是你确实想忽略掉整个手势。一旦你实现了GestureDectecor.OnGestureListener接口并且创建了一个GestureDetector实例,你可以使用该实例来处理你在onTouchEvent()中接收到的触摸事件。
@Override public boolean onTouchEvent(MotionEvent event) { boolean result = mDetector.onTouchEvent(event); if (!result) { if (event.getAction() == MotionEvent.ACTION_UP) { stopScrolling(); result = true; } } return result; }
当你给onTouchEvent()函数传递它不能识别为手势的触摸事件时,它会返回false。测试你可以运行自己定制的手势检测代码。
手势是控制触屏设备的强有力的工具, 但是它们有可能会违背直觉并难以被记住,如果它们不能产生看似合理的结果的话。如fling手势,该手势发生在用户在屏幕上快速的移动手指然后抬起它的时候。UI的看似合理的响应应该是:在fling的方向上快速移动,然后慢慢减速,仿佛用户推动转速轮使其开始转动一样。但是,产生转速轮的感觉不是那么容易,需要大量的物理和数学知识来使得转速轮模式正确的运行。 为此,Android提供了辅助类Scroller 来处理fling手势。
开始fling操作前,调用fling() 函数,该函数需要x,y方向上的启动速度,最小和最大值等作为参数。对于速度值,可以使用GestureDector计算的数值:
@Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { mScroller.fling(currentX, currentY, velocityX / SCALE, velocityY / SCALE, minX, minY, maxX, maxY); postInvalidate(); }
注:尽管GestureDetector计算的速度在物理上是准确的,很多开发者任务使用这个值使得fling动画太快。因此通常使用速度值除以4到8以后的数值。
fling()调用设置fling手势的物理模型。 之后, 可以每隔一个固定时间段,通过调用 Scroller.computeScrollOffset() 来更新Scroller类。 computeScrollOffset()读取当前时间,使用上述物理模型计算当前时间的x和y的值,然后更新Scroller对象的状态。 通过调用 getCurrX() 和getCurrY() 来获取这些值。
大多数view类对Scroller对象的x和y的位置信息的处理是直接传送给 scrollTo()函数。PieChart示例有一点不同:它使用y值来设置图表中的旋转角度:
if (!mScroller.isFinished()) { mScroller.computeScrollOffset(); setPieRotation(mScroller.getCurrY()); }
Scroller 类计算位置信息,但是不会自动将这些信息应用到view类中。开发者需要确保及时获取并应用新的坐标位置到view中以保证滚动效果的流畅性。可以通过如下两种方法来做:
PieChart使用第二种方法,该技术更复杂一些,但是利用了动画机制,避免了不必要的页面重绘。缺点是ValueAnimator类是在API 11开始使用的,因此不能在3.0以下的Android系统中运行。
mScroller = new Scroller(getContext(), null, true); mScrollAnimator = ValueAnimator.ofFloat(0,1); mScrollAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { if (!mScroller.isFinished()) { mScroller.computeScrollOffset(); setPieRotation(mScroller.getCurrY()); } else { mScrollAnimator.cancel(); onScrollFinished(); } } });
用户希望UI状态的过渡更平滑一些,UI元素淡入和淡出而不是出现和消失。移动平滑地开始和结束而不是突然开始和停止。Android3.0版本引入property animation framework, 以保证状态的平滑过渡。
当属性值的改变会改变view的外观时,使用 ValueAnimator 来改变属性值,亦即使用动画系统来改变属性值。下例中,修改PieChart的当前选中饼图块会导致整个饼图旋转以便选中的饼图块居中。ValueAnimator会每隔几百毫秒修改旋转角度,而不是立即设置新的旋转角度值。
mAutoCenterAnimator = ObjectAnimator.ofInt(PieChart.this, "PieRotation", 0); mAutoCenterAnimator.setIntValues(targetAngle); mAutoCenterAnimator.setDuration(AUTOCENTER_ANIM_DURATION); mAutoCenterAnimator.start();
如果修改的属性是 View 的属性时,动画会更容易一些,因为View有一个内置的ViewPropertyAnimator 类,该类已被优化来处理多个属性值同时改变的动画。如:
animate().rotation(targetAngle).setDuration(ANIM_DURATION).start();
为避免UI让人感觉迟滞,需保证动画持续地每秒播放60帧。
要想到达上述目的,需要在经常被调用的函数中去除没必要的代码。首先检查onDraw()函数,需要去除该函数中的分配空间的操作,因为分配空间会导致垃圾收集操作,而后者会引起卡顿。可以在初始化或动画间隙分配对象,永远不要在动画过程中分配对象。
尽可能的减少onDraw()的使用频率。大部分的onDraw()调用都是invalidate()引起的,因此需要避免没必要的invalidate()调用。
另外一个花费时间的操作是遍历布局。每次view调用 requestLayout(), Android的UI系统都会遍历整个view树去找出每个view的大小。如果发现冲突,可能会需要多次遍历view树。UI设计者有时会构造很深的嵌套ViewGroup树以使UI可以很好的使用。确保view树尽可能地浅。
如果UI很复杂的话,可以考虑写一个定制的 ViewGroup 来执行布局操作。不像内置的view类,自己写的定制view类可以对子控件给出具体的尺寸和形状,避免遍历子控件来计算尺寸。如在PieChart示例中,PieChart类包含子页面,但是从来不去计算他们的尺寸,而是根据定制布局算法直接设置它们的尺寸。