翻译自:http://developer.android.com/training/custom-views/index.html

一)创建view类

一个设计良好的自定义view与其他的类一样,它使用接口来封装一系列的功能,有效的使用CPU和内存等。除了这些,定制view还应该满足如下条件:

  • 符合Android标准
  • 与Android XML 布局文件配合,提供符合style风格的定制属性
  • 发送易接近性事件(accessibility events,针对听力或视觉有缺陷用户提供方便的事件)
  • 兼容不同的Android平台

Android框架提供了一系列的基类和XML标识以便用户可以很方便的创建满足上述条件的View。该节将讨论如何使用Android框架来创建view类的核心功能。

1)基于View创建子类

Android框架中的所有view类都继承自View类。自定义view也可以直接继承View类,或它的已经定义的子类,如Button类。

为了与Android studio交互,至少要定义一个以Context和AttributeSet对象为参数的构造函数,该构造函数运行布局编辑器创建和修改view的实例,如:

classPieChartextendsView{

  public PieChart(Context context, AttributeSet attrs) {    super(context, attrs);  }}

2)添加自定义属性

我们可以通过在布局文件中添加XML元素来将内置的View类添加到用户界面, 并且可以修改元素属性来控制它的样式和行为。设计良好的自定义view也应该可以通过XML元素来添加和修改style. 通过以下步骤来启动自定义view的上述行为:

  • <declare-styleable>资源属性中添加view的自定义属性
  • 在XML布局中指定属性的值
  • 运行时获取属性值
  • 将上述步骤中获取的属性值应用到view中

通常情况下,在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.

3)使自定义属性生效

当在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的定义:

publicTypedArrayobtainStyledAttributes(AttributeSetset, int[] attrs, int defStyleAttr, int defStyleRes)

Added in API level 1

Return a TypedArray holding the attribute values insetthat are listed inattrs. 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 callTypedArray.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:

  1. Any attribute values in the given AttributeSet.
  2. The style resource specified in the AttributeSet (named "style").
  3. The default style specified bydefStyleAttranddefStyleRes
  4. The base values in this theme.

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 willalwaysbe 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.

4)添加属性和事件

通过属性可以控制view的行为和样式,但确定是只在view初始化时被读取。提供每个自定义属性的getter和setter方法可以动态的修改行为,如:

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可见外观和行为的属性。

5)设计支持易接近性

可以通过以下方法为听力或视觉有缺陷用户提供方便:

  • 使用android:contentDescription属性给输入区域做标签
  • 在需要时通过调用sendAccessibilityEvent()发送易接近性事件
  • 支持触屏以外的其他控制器,如D-pad 和traceball

更多信息,见Android 开发文档:Making ApplicationsAccessible.


二)自定义绘画

1)重写onDraw()

onDraw()使用Canvas对象作为参数,Canvas类定义了绘制文字,线条,位图及其他简单图形的函数。可以在onDraw()中使用这些方法创建定制的用户界面。

2)创建绘画对象

框架将绘画分为两个部分:

  • 画什么,由Canvas类来负责
  • 怎么画,由Paint类来负责

例如, 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迟滞。

3)处理布局

在绘制定制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);}

上述代码有三点需要注意:

  1. 计算时需要考虑填充(padding)的问题
  2. 辅助函数resolveSizeAndState()被用来生成最终的宽度和高度值,该函数通过比较view请求的尺寸和onMeasure()方法传入的尺寸要求,返回一个合适的View.MeasureSpec
  3. onMeasure()没有返回值,它通过调setMeasuredDimension()方法将结果传送出去。如果忽略该方法的话,View类会抛出异常

4)开始绘制

创建绘画对象和处理好布局后,就可以实现onDraw()方法了。如下是一些常用的操作:

  • 使用drawText()绘制文字。通过setTypeface()指定字体(typeface),通过setColor()指定文字颜色
  • 使用drawRect(),drawOval(), 和drawArc()绘制基础形状,通过setStyle()设置是否被填充,是否显示轮廓。
  • 使用Path类来绘制负责的形状。 添加直线和曲线到Path对象定义形状,然后使用drawPath() 绘制该形状。也可以通过setStyle()设置paths是否被填充,是否显示轮廓
  • 通过创建LinearGradient对象来定义渐变填充, 调用setShader()将该LinearGradient对象填充到需要绘制的形状中。
  • 使用drawBitmap()绘制位图。

示例如下:

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);}

三)给view添加交互

绘制UI只是创建定制view的一部分,view还需要响应用户输入。设计交互时,需要注意模仿现实世界的操作。例如,图片不应该在一个地方立即退出然后又在另外一个地方重新出现,而应该从一个地方移动到另外一个地方。

这一节展示如何使用Android框架的特性来给定值view添加真实世界的行为。

1)处理输入手势

同其他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()中接收到的触摸事件。

@Overridepublic boolean onTouchEvent(MotionEvent event) { boolean result = mDetector.onTouchEvent(event); if (!result) {   if (event.getAction() == MotionEvent.ACTION_UP) {     stopScrolling();     result = true;   } } return result;}

当你给onTouchEvent()函数传递它不能识别为手势的触摸事件时,它会返回false。测试你可以运行自己定制的手势检测代码。

2)创建看似合理的移动

手势是控制触屏设备的强有力的工具, 但是它们有可能会违背直觉并难以被记住,如果它们不能产生看似合理的结果的话。如fling手势,该手势发生在用户在屏幕上快速的移动手指然后抬起它的时候。UI的看似合理的响应应该是:在fling的方向上快速移动,然后慢慢减速,仿佛用户推动转速轮使其开始转动一样。但是,产生转速轮的感觉不是那么容易,需要大量的物理和数学知识来使得转速轮模式正确的运行。 为此,Android提供了辅助类Scroller来处理fling手势。

开始fling操作前,调用fling()函数,该函数需要x,y方向上的启动速度,最小和最大值等作为参数。对于速度值,可以使用GestureDector计算的数值:

@Overridepublic 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中以保证滚动效果的流畅性。可以通过如下两种方法来做:

  • 在调用fling()后调用postInvalidate(),强制系统重绘。这需要在onDraw() 中计算滚动偏移值并在每次滚动偏移值发生改变时调用postInvalidate()
  • 在fling过程中设置ValueAnimator并通过调用addUpdateListener()添加监听器来处理动画。

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();       }     }   });

3)平滑地状态过渡

用户希望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();

四)对view进行优化

为避免UI让人感觉迟滞,需保证动画持续地每秒播放60帧。

要想到达上述目的,需要在经常被调用的函数中去除没必要的代码。首先检查onDraw()函数,需要去除该函数中的分配空间的操作,因为分配空间会导致垃圾收集操作,而后者会引起卡顿。可以在初始化或动画间隙分配对象,永远不要在动画过程中分配对象。

尽可能的减少onDraw()的使用频率。大部分的onDraw()调用都是invalidate()引起的,因此需要避免没必要的invalidate()调用。

另外一个花费时间的操作是遍历布局。每次view调用requestLayout(), Android的UI系统都会遍历整个view树去找出每个view的大小。如果发现冲突,可能会需要多次遍历view树。UI设计者有时会构造很深的嵌套ViewGroup树以使UI可以很好的使用。确保view树尽可能地浅。

如果UI很复杂的话,可以考虑写一个定制的ViewGroup来执行布局操作。不像内置的view类,自己写的定制view类可以对子控件给出具体的尺寸和形状,避免遍历子控件来计算尺寸。如在PieChart示例中,PieChart类包含子页面,但是从来不去计算他们的尺寸,而是根据定制布局算法直接设置它们的尺寸。


更多相关文章

  1. C语言函数的递归(上)
  2. [Android]笔记10-单选按钮和复选框的功能与用法
  3. android:windowSoftInputMode属性详解以及editText监听键盘回车
  4. Android知识巩固—四大组件能否开多进程
  5. Android在标准linux基础上对休眠唤醒的实现(二)
  6. android布局属性使用说明和一些开发经验
  7. Android下affinities和任务(task)
  8. Unity与Android之间的交互之AndroidManifest
  9. Android(安卓)5.1 SurfaceFlinger VSYNC详解

随机推荐

  1. android视频播放-饺子播放器
  2. android菜单的使用
  3. 解决style attribute ‘@android:attr/wi
  4. Android(安卓)如何在IDEA Eclipse 的UI E
  5. Android(安卓)FileObserver 用法
  6. IDA动态调试Android的DEX文件
  7. 调用Android系统设置中的Intent
  8. Android源码下载出现的问题
  9. android ramdisk.img解包,打包
  10. Android(安卓)ContentProvider 完全解析