Android 自定义View

概述:

Android framework包含大量的View类, 它们被广泛的用于跟用户交互和显示各种数据. 但是有时我们的app可能需要一些内置view无法满足的功能. 本文将展示如何创建自己的view, 使其健壮并可重用. 包括四个小节:

l  创建View类.

l  自定义Drawing.

l  使View可交互.

l  优化view.

创建一个View类:

一个优秀的自定义View就像优秀的其它类一样, 它应该可以通过简单易用的用户接口封装一系列功能, 高效的使用CPU和内存等. 此外, 要设计一个优秀的自定义view还应该:

l  符合Android标准

l  提供自定义的设置样式属性,并可以支持Android XML layout.

l  发送访问事件.

l  可以兼容多个Android版本.

Android提供了大量的基础类和XML tag来给我们创建自定义的view类, 以满足所有这些需求.

创建一个View的子类:

所有的Android framework中定义的View类都是继承自View. 我们的自定义view也可以直接继承自View, 或者也可以继承其子类以节省时间, 比如Button. 为了让Android Studio可以跟我们的View交互, 至少应该提供一个构造方法, 并可以接收Context和一个AttributeSet作为参数. 这个构造方法可以让layout编辑器能够创建和编辑view的实例.

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

定义一个自定义的属性(Attribute):

要添加一个内置的View到我们的用户接口, 只需要在XML元素中指定它, 并通过元素属性控制它的显示和行为. 优秀的自定义View也应该可以通过XML添加并使用主题. 想要实现这些, 必须:

1.      在<declare-styleable>资源标签中为View定义自定义属性.

2.      为在XML layout中的属性指定值.

3.      在运行时可以取得属性值.

4.      在view中可以应用得到的属性值.

本小节讨论如何定义自定义属性并指定它们的值. 下一小节则处理在运行时获取和应用值. 要定义自定义属性, 需要添加<declare-styleable>资源到工程中. 通常会将这些资源放在res/values/attrs.xml文件中. 这是一个attrs.xml文件的栗子:

<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. 尽管不严格要求, 但是通常XML中PieChart这个名字跟自定义view的名字是一样的. 一旦定义了自定义属性, 就可以在XML layout文件中使用它们了, 用法跟内置的属性无差. 唯一区别就是我们自定义的属性属于一个不同的名字空间. 而不是http://schemas.android.com/apk/res/android命名空间, 它们属于http://schemas.android.com/apk/res/[包名]. 栗如, 下面是一个如何使用PieChart属性的栗子:

<?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指令. 该指令指定了名字空间http://schemas.android.com/apk/res/com.example.customviews的别名为custom. 我们可以选择任何想要使用的别名. 注意这里将自定义view类添加到layout的XML tag的名字, 它必须是完整的自定义view类的名字. 如果view类是一个内部类. 比如, PieChart类拥有一个内部类名为PieView. 想要为使用该类的自定义属性, 那么需要使用这样的tag: com.example.customviews.charting.PieChart$PieView.

应用自定义属性(attribute):

当从XML layout中创建一个view的时候, 所有XML tag中的属性都从XML文件中读取并作为一个AttributeSet类型的参数传给view的构造方法. 尽管可以直接从AttributeSet中读取值, 但是这样做会有两个缺点:

l  属性值中的资源说明未解析.

l  主题未应用.

我们可以将AttributeSet传给obtainStyledAttribute()方法. 该方法会返回一个TypedArray数组, 数组中的值已经没有上述的问题了. 为了让调用obtainStyledAttribute()更加容易, Android资源编译器做了很多工作. 对于每个<declare-styleable>资源, 生成的R.java文件不但定义了属性ID的数组而且还定义了一组常量来指定每个数组中的属性的index. 我们可以使用预定义的常量来从TypedArray中读取属性. 下面的代码演示了如何从PieChart类中读取它的属性:

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对象是一个共享资源, 所以在用完之后必须回收.

添加属性(property)和事件:

Attribute是控制view显示和行为的有效方式, 但是只可以在view初始化的时候读取. 想要提供动态行为, 需要为每个自定义attribute提供getter和setter方法. 下面代码段演示了PieChart如何实现一个showText属性:

public boolean isShowText() {
   return mShowText;
}

public void setShowText(boolean showText) {
   mShowText = showText;
   invalidate();
   requestLayout();
}

注意这里的setShowText()方法调用了invalidate()和requestLayout(). 这些方法十分重要, 它们确保了view行为的可靠性. 我们必须在任何可能改变外观的属性改变之后刷新view, 这样系统才知道需要重新绘制view. 同样, 如果属性变化引起了屏幕尺寸或者形状的变化, 那么我们还需要请求一个新的layout. 如果忘记调用这些方法将会导致难以发现的bug.

自定义view还应该支持事件监听来响应重要事件. 比如, PieChat会实现一个自定义事件叫做OnCurrentItemChanged来提醒监听器用户已经旋转pie chart. 自定义的属性和事件很容易被忽略, 特别是当我们是自定义view的唯一用户的时候. 花一些时间来仔细的定义我们的view接口可以降低未来维护的成本. 一个好的规则是暴露(expose)任何可以影响自定义view外观或者行为的属性.

自定义view的绘制:

自定义view最重要的部分是实现它的外观. 自定义绘制根据需求可以简单也可以复杂. 在自定义绘制中最重要的步骤是重写onDraw()方法. onDraw()的参数是一个Canvas对象, view可以通过它来绘制自己. Canvas类定义了很多方法来绘制文本, 线, 位图和其它基本图形. 我们可以在onDraw()中使用这些方法来创建自己的UI. 在调用任何绘制方法之前, 很有必要创建一个Paint对象.

创建绘制对象:

Android.graphics framework将绘制分为两个部分:

l  绘制什么, 由Canvas处理.

l  如何绘制, 由Paint处理.

栗如, Canvas提供了一个方法来绘制一条线, Paint提供方法来定义这条线的颜色. Canvas有一个方法来绘制一个矩形, 那么Paint定义是否使用颜色填充这个矩形. 简单的说, Canvas定义我们要绘制到屏幕上的形状, Paint则定义颜色, 主题, 字体等. 所以在我们绘制任何东西之前, 我们应该创建一个或者更多的Paint对象. PieChart栗子中在init方法里实现了这个功能, 它在构造方法中被调用:

privatevoid init(){
   mTextPaint = newPaint(Paint.ANTI_ALIAS_FLAG);
   mTextPaint.setColor(mTextColor);
   if (mTextHeight== 0){
       mTextHeight = mTextPaint.getTextSize();
   } else {
       mTextPaint.setTextSize(mTextHeight);
   }

   mPiePaint = newPaint(Paint.ANTI_ALIAS_FLAG);
   mPiePaint.setStyle(Paint.Style.FILL);
   mPiePaint.setTextSize(mTextHeight);

   mShadowPaint = new Paint(0);
   mShadowPaint.setColor(0xff101010);
   mShadowPaint.setMaskFilter(newBlurMaskFilter(8,BlurMaskFilter.Blur.NORMAL));

   ...

提前创建对象是一种重要有效的优化. View的重绘制十分的频繁, 并且很多绘制对象都需要昂贵耗时的初始化. 在onDraw()方法中创建绘制对象会显著的降低性能, 并使UI在显示的时候变得很迟钝.

处理Layout事件:

为了正确的绘制自定义view, 我们需要知道它的尺寸. 复杂的自定义view经常需要根据它们的形状和尺寸处理多个layout计算. 我们永远都不应该对view在屏幕上的尺寸做出假设(make assumptions). 就算只有我们一个app使用该view, 这个app也应该处理不同的屏幕尺寸, 多屏幕密度, 还有各种宽高比(横竖屏两种情况).

尽管View拥有很多方法来处理测量(measurement), 多数的方法并不需要被重写. 如果自定义view不需要对尺寸有特别的控制, 只需要重写onSizeChanged()方法就可以了. onSizeChanged()方法在view首次指定尺寸被调用, 如果因为任何原因view的尺寸改变了, 那么它还会被调用. 在onSizeChanged()中计算位置, 密度和任何其它尺寸相关值, 而不是每次绘制都重新计算. 在PieChart栗子中, onSizeChanged()是PieChart计算pie chart边框, 文字相对位置和其它可见元素的地方. 当自定义view被分配了一个尺寸后, layout管理器会假设这个尺寸已经包含了所有view的填充. 我们必须在计算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);

如果我们需要更好的控制view的布局参数, 那么实现onMeasure()方法. 该方法的参数是View.MeasureSpec值, 它告诉我们view的parent希望view能有多大的尺寸, 以及改尺寸是否是一个强制的最大值, 还是仅仅是一个建议. 这些值被打包为一个整型, 我们需要使用静态方法View.MeasureSpec来解压缩每个整型数据中包含的信息. 下面是一个onMeasure()的实现. 在这段代码中, PieChart尝试是自己的尺寸足够大以使pie跟它的label一样大:

@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.      计算构成顾及了view的填充. 正如前面提到的, 这是view的责任.

2.      帮助方法resolveSizeAndState()被用来创建最终的宽度和高度值. 该方法通过对比view的设计尺寸和传入onMeasure()的spec返回一个适当的View.MeasureSpec值.

3.      onMeasure()没有返回值. 而是通过调用setMeasureDimension()来设置自己的结果. 调用该方法是强制性的, 如果不调用它, 那么view类将会抛出一个运行时异常.

绘制:

一旦view对象的创建和测量代码定义完成, 就可以实现onDraw()方法了. 每个view实现onDraw()方法都有所不同, 但是大多数的view都有些相似之处:

1.      绘制文本需要使用drawText()方法. 通过setTypeface()来指定字体, 并使用setColor()方法来指定文本颜色.

2.      绘制基本形状可以使用drawRect(), drawOval()和drawArc(). 想要指定它们是否被填充, 边框可以通过调用setStyle()来实现.

3.      绘制更复杂的形状需要使用Path类. 通过添加直线和曲线到Path对象来定义一个形状, 然后使用drawPath()来绘制该形状. 类似基本形状, 填充和边框可以通过setStyle()方法来指定.

4.      如果想要定义渐变的填充, 要创建LinearGradient对象. 调用setShader()方法来应用LinearGradient到要填充的形状.

5.      使用drawBitmap()来绘制位图.

栗如, 这是PieChart栗子中的绘制代码. 它使用了文本, 直线和形状:

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的一部分. 想要它正常工作还得让它可以相应用户的输入, 并尽量做到跟真实世界的操作拥有一致的体验. 对象应该总是保持跟真实对象一样的行为. 比如, 图片不应该直接弹出并出现在另一个地方, 因为真实世界的对象不是这样的, 而是应该从一个地点移动到另一个地点.(不能瞬移) 还有就是开始移动的时候应该感到”困难”, 停止移动放下的时候应该感觉”轻松”.

处理输入手势:

就像其它很多UI framework一样, Android支持输入事件模型. 用户的动作会触发事件回调, 我们可以重写这些回调来自定义APP如何响应用户的操作. 最常见的输入事件是触摸(touch), 它会触发onTouchEvent(android.view.MotionEvent). 重写该方法来处理点击事件:

@Override
   public boolean onTouchEvent(MotionEvent event) {
    return super.onTouchEvent(event);
   }

触摸事件本身并没有什么特别的用途. 就手势而言有很多更加先进的实现, 比如: 点击, 拉, 推, 抛和缩放等. 为了将原始的触摸事件转化成手势, Android提供了GestureDetector. 我们可以通过传给构造方法一个GestureDetector.OnGestureListener参数来得到GestureDetector对象. 如果只是想处理一个新的手势,那么可以扩展GestureDetector.SimpleOnGestureListener代替GestureDetector.OnGestureListener接口. 栗如, 下面这段代码创建了一个继承自GestureDetector.SimpleOnGestureListener的类并重写了onDown(MotionEvent)方法:

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()消息开始. 如果返回false, 则系统会认为我们想要忽略剩下的手势, GestureDetector.OnGestureListener中的其余方法则不会再被调用. 唯一返回false的情况就是我们真的希望忽略掉整个手势的时候. 一旦实现了GestureDetector.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. 然后我们就可以运行自定义的手势处理代码了.

创建物理模拟运动:

手势是触屏设备中强大的控制方式, 但是它们有些时候可能会违反直觉和难以记住的, 除非它们符合物理模拟结果. 一个好的栗子是抛手势, 用户快速移动手指划过屏幕然后举起手指. 然而模拟这样的手势并不容易, 需要很多的物理和数学计算才能让它正确的工作. 幸运的是, Android为我们提供了帮助类来模拟它以及其它行为. Scroller类是一个处理flywheel-style fling手势的基类. 要启动一个滑动手势, 用一个启动速度以及最大最小x,y值来调用fling(). 对于速度值, 我们可以使用GestureDetector计算出来的值.

@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计算出来的速度是物理加速度, 但是很多开发者觉得使用这个值使得动画速度太快了. 通常会将其除以一个4~8的参数.

Fling()方法为滑动手势设置了一个物理模型. 之后我们需要定期通过Scroller.computeScrollOffset()来更新Scroller.ComputeScrollOffset()通过读取当前的时间配合物理模型来计算当下x和y的值进而更新Scroller对象的内部状态. 调用getCurrX()和getCurrY()可以获取这些值. 大多数view直接将Scroller对象的x,y直接传给scrollTo(). PieChart栗子有一些小不同: 它使用当前y位置来设置chart的旋转角度:

if (!mScroller.isFinished()) {
    mScroller.computeScrollOffset();
    setPieRotation(mScroller.getCurrY());
}

Scroller类会为我们计算滚动位置, 但是它不会自动应用这些位置到view. 确保获取到值并用足够的频率应用新的坐标以让滚动看起来足够平滑是我们的任务. 有两种方法可以实现这个功能:

l  调用fling()之后调用postInvalidate(),这样可以强制重绘. 这种方法需要我们在onDraw()中计算滚动偏移并在每次偏移值变化的时候调用postInvalidate().

l  设置一个ValueAnimator,在滑动过程中通过它来设置动画, 并用addUpdateListener()添加一个listener来处理动画更新.

PieChart栗子中使用第二种方法. 这种技术设置起来稍微复杂些,但是它工作更加贴近动画系统, 并且不需要潜在不必要的刷新view. 缺陷则是ValueAnimator不能支持API level 11之前的版本, 所以这种技术不能在Android 3.0以前的设备上运行.

提醒: ValueAnimator在API level 11之前不可用, 但是我们依然可以在运行时判断当前的版本, 如果版本号小于11, 就忽略对其的使用.

   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元素应该淡入淡出而不是突然的出现和消失. 动作开始和结束都应该平滑而不是突然启动和停止. Android 3.0引入的属性动画framework可以使动画更容易的变得流畅. 要使用动画系统, 无论何时只要有属性改变, 就会引起view的显示, 不要直接改变属性值. 而是使用ValueAnimator改变值. 在下面的栗子中, 修改当前选中的pie slice会导致整个chart旋转, 这样选中点就会在选中slice的中间. 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:

现在我们已经拥有了一个设计优秀的view, 并可以响应用户的手势和变换状态, 而且保证view可以运行的流畅. 想要避免UI的卡顿或者在播放的时候断断续续, 需要确保动画在运行的时候满足每秒60帧的速度.

减少操作, 降低频率:

要让view更快, 应该消除那些不必要的程序代码. 从onDraw()开始, 优化它将会得到最大的回报. 尤其是应该清除onDraw()中的申请内存操作, 因为内存分配会导致一次垃圾回收, 这会引发卡顿. 内存分配工作应该在初始化或者两个动画之间完成, 而不要在动画运行期间完成.

除了让onDraw()更加精简, 还应该努力使其调用的频率降到最低. 大多数的onDraw()都是由于invalidate()引发的, 所以降低调用invalidate()方法的频率可以起到优化效果.

另一个很耗性能的操作是遍历layout. 任何时候一个view调用requestLayout(), Android的UI系统都需要遍历整个view层来找出每个view是多大. 如果它发现有矛盾的测量值,那么还有可能会多次遍历view层. UI设计者有时候会创建深层次嵌套的viewGroup对象来使得UI达到合适的样子. 这些深层的view会导致性能问题. 所以应该尽可能的使我们的view层保持在较浅的层次数.

如果我们必须使用较为复杂的UI, 那么可以考虑写一个自定义的ViewGroup来实现它的layout. 不像内置的view, 自定义的view可以在指定的app中对它的子view做出假设, 并因此避免遍历它的子view来计算测量值. PieChart栗子中演示了如何继承一个ViewGroup来作为自定义view的一部分. PieChart拥有子view, 但是它从来不会测量它们. 而是根据自己的自定义layout的算法直接设置它们的尺寸.

 

总结:

要实现一个自定义的view:

1.      创建View类.

2.      自定义属性, attrs.xml; 定义操作属性的方法, getter/setter.

3.      实现onDraw()来绘制view(可能需要实现onMeasure()).

4.      实现view的事件.

 

参考: https://developer.android.com/training/custom-views/index.html

栗子: https://developer.android.com/shareables/training/CustomView.zip

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值