前言
今天看了Google上关于View的教程,看的真是云里雾里。虽然篇幅很长,看完一遍实在不想再看第二遍,不过自己还是坚持看完了第二遍,第三遍,第四遍。。。后面几遍越看越熟练,我看着Google示例APP里面的代码,然后参考着Google教程里面的讲解,慢慢的和教程里面的一个个点对上号。到写这篇博客为止,我还是没有很好的把其中的原理融会贯通,但是还是想先记录一下目前从代码里学习到的知识,以后看到好的View代码再多练习。
比如自定义XML属性我都不是很熟悉,在这里只是熟悉它的大概流程,里真正学会还差很远。
正文
首先我们来看看效果图(动态图不会弄。。见谅):
我们可以用手指拖动这个圆盘开始旋转,然后过一段时间它会慢慢停下来,并且旋转过程中左边对应的饼图部分的文字一直在变化。
下面我就按照如何自己写出一个自定义View的过程来记录学到的东西:
定义一个View子类
自定义当然离不开自己创建一个类继承View,不过我们可以选择继承基础类view,也可以是基础类之上的类(如Button),如果UI复杂想在自定义View里继续嵌套子元素,还可以继承ViewGroup。比如在PieChart里面,是这么写的:
public class PieChart extends ViewGroup {
定义自定义样式
每一个View都有自己的样式,比如说我们在XML里面经常使用的 android:layout_width = “match_parent”,如果我们自己定制View,我们还可以自己定义自己的样式,习惯上来讲,我们把自定义view的自定义属性放在res/values/attrs.xml文件里面。我们需要在< resource >元素下面定义一个子元素 < declare-styleable >,然后写上这个样式的名字,一般和自定义View类的名字一样。在这个< declare-styleable >里面来定义有需求的属性,在pieChart里面是这样的:
<declare-styleable name="PieChart">
<attr name="autoCenterPointerInSlice" format="boolean"/>
<attr name="highlightStrength" format="float"/>
<attr name="labelColor" format="color"/>
<attr name="labelHeight" format="dimension"/>
<attr name="labelPosition" format="enum">
<enum name="left" value="0"/>
<enum name="right" value="1"/>
</attr>
<attr name="labelWidth" format="dimension"/>
<attr name="labelY" format="dimension"/>
<attr name="pieRotation" format="integer"/>
<attr name="pointerRadius" format="dimension"/>
<attr name="showText" format="boolean"/>
</declare-styleable>
然后我们应该在XML文件里面应用这个样式,不过我们应该重新加入一个新的命名空间,比如之前我们用android:layout_weight,android:centerInParent之类的属性,其实都是因为先引入了xmlns:android=”http://schemas.android.com/apk/res/android”,”http://schemas.android.com/apk/res/android“是具体的URI,用指令xmlns可以指定一个别名还代替这一长串的URI,我们这里引入xmlns:custom=”http://schemas.android.com/apk/res/com.example.custom_view”,这样,我们就能用custom:XX=YY这样的自定义属性了。
注意:引入自己的命名空间,规则是在res后面加入APP的具体包名。这里的包名是com.example.custom_view。
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:custom="http://schemas.android.com/apk/res/com.example.custom_view"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.example.custom_view.PieChart
android:id="@+id/Pie"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp"
android:layout_weight="100"
custom:showText="true"
custom:labelHeight="20dp"
custom:labelWidth="110dp"
custom:labelY="85dp"
custom:labelPosition="left"
custom:highlightStrength="1.12"
android:background="@android:color/white"
custom:pieRotation="0"
custom:labelColor="@android:color/black"
custom:autoCenterPointerInSlice="true"
custom:pointerRadius="4dp"
/>
<Button
android:id="@+id/Reset"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/reset_button"
/>
</LinearLayout>
应用自定义样式
应用自定义样式要在自定义View的构造函数里面取出来,然后应用给View里面的成员变量,如果是代码里面直接新建View,一般都调用的是:
/**
* Class constructor taking only a context. Use this constructor to create
* {@link PieChart} objects from your own code.
*
* @param context
*/
public PieChart(Context context) {
super(context);
init();
}
只要传递一个上下文context进入就行了。
如果我们是在XML文件里面指定好了,然后我们就要调用另一构造函数,因为如果使用上一个构造函数的话,它单单是建立了一个含有默认样式的View,而我们在XML文件里面定制好的属性样式都完全没有应用给它。这个时候我们应该调用这个构造函数:
/**
* Class constructor taking a context and an attribute set. This constructor
* is used by the layout engine to construct a {@link PieChart} from a set of
* XML attributes.
*
* @param context
* @param attrs An attribute set which can contain attributes from
* {@link com.example.android.customviews.R.styleable.PieChart} as well as attributes inherited
* from {@link android.view.View}.
*/
public PieChart(Context context, AttributeSet attrs) {
super(context, attrs);
// attrs contains the raw values for the XML attributes
// that were specified in the layout, which don't include
// attributes set by styles or themes, and which may have
// unresolved references. Call obtainStyledAttributes()
// to get the final values for each attribute.
//
// This call uses R.styleable.PieChart, which is an array of
// the custom attributes that were declared in attrs.xml.
TypedArray a = context.getTheme().obtainStyledAttributes(
attrs,
R.styleable.PieChart,
0, 0
);
try {
// Retrieve the values from the TypedArray and store into
// fields of this class.
//
// The R.styleable.PieChart_* constants represent the index for
// each custom attribute in the R.styleable.PieChart array.
mShowText = a.getBoolean(R.styleable.PieChart_showText, false);
mTextY = a.getDimension(R.styleable.PieChart_labelY, 0.0f);
mTextWidth = a.getDimension(R.styleable.PieChart_labelWidth, 0.0f);
mTextHeight = a.getDimension(R.styleable.PieChart_labelHeight, 0.0f);
mTextPos = a.getInteger(R.styleable.PieChart_labelPosition, 0);
mTextColor = a.getColor(R.styleable.PieChart_labelColor, 0xff000000);
mHighlightStrength = a.getFloat(R.styleable.PieChart_highlightStrength, 1.0f);
mPieRotation = a.getInt(R.styleable.PieChart_pieRotation, 0);
mPointerRadius = a.getDimension(R.styleable.PieChart_pointerRadius, 2.0f);
mAutoCenterInSlice = a.getBoolean(R.styleable.PieChart_autoCenterPointerInSlice, false);
} finally {
// release the TypedArray so that it can be reused.
a.recycle();
}
init();
}
我们通过obtainStyledAttrubutes()把属性读取到TypeArray里面,需要传入构造函数的参数attrs,我们自定义的属性R.styleable.PieChart,两个0(不知道什么用)。然后这样我们就把XML里面定制好的属性读取到TypedArray数组里了。
TypedArray a = context.getTheme().obtainStyledAttributes(
attrs,
R.styleable.PieChart,
0, 0
);
接下来我们就要把需要读取的属性从获取到的TypedArray对象里获取出来,就像下面这样,好像取key_value对一样,其中R.styleable.PieChart_*这些是系统自动帮我们生成的R.styleable.PieChart对应属性的索引。如果取到了就是XML文件里面定制好的属性,如果没有取到我们就用默认的属性。
mShowText = a.getBoolean(R.styleable.PieChart_showText, false);
mTextY = a.getDimension(R.styleable.PieChart_labelY, 0.0f);
还有,最后一定要回收TypedArray资源,因为这是系统的一个共享资源。
a.recycle();
添加属性和事件
为了提供动态的行为,我们应该暴露出那些会影响VIew外观和行为的属性。比如说我们把showText改成false,那么我们就需要重新计算最好的尺寸和布局,我们就要调用 invalidate() 和 requestLayout()。忘记这些方法的调用我们就很难发现BUG。
当我们开发的时候,很容易忘记这一点。那我们应该遵循一个好的规则,总是把那些会影响View外观和行为的属性暴露出来,并调用invalidate()或是requestLayout()来让View变得可靠。
public float getPointerRadius() {
return mPointerRadius;
}
public void setPointerRadius(float pointerRadius) {
mPointerRadius = pointerRadius;
invalidate();
}
创建绘图对象
在画图之前我们要先准备好画笔,在PieChart里面,我们要预先准备好画饼图的画笔,画字的画笔,画阴影的画笔。而且我们应该在构造函数里面完成这一操作,因为放在onDraw()方法里面会导致每次重绘都要重新初始化画笔,会让View变的拖沓。
private void init() {
// Force the background to software rendering because otherwise the Blur
// filter won't work.
setLayerToSW(this);
// Set up the paint for the label text
mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setColor(mTextColor);
if (mTextHeight == 0) {
mTextHeight = mTextPaint.getTextSize();
} else {
mTextPaint.setTextSize(mTextHeight);
}
// Set up the paint for the pie slices
mPiePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPiePaint.setStyle(Paint.Style.FILL);
mPiePaint.setTextSize(mTextHeight);
// Set up the paint for the shadow
mShadowPaint = new Paint(0);
mShadowPaint.setColor(0xff101010);
mShadowPaint.setMaskFilter(new BlurMaskFilter(8, BlurMaskFilter.Blur.NORMAL));
... ...
处理布局事件
在初始化完画笔之后,画图之前我们还有一项工作,就是确定好布局大小。一般在View第一次被指定大小的时候,系统会调用onSizeChanged()方法,在这个方法里面我们要确定好View里面每一个子元素的大小,确定好每一个子元素和子元素之间的位置关系。
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
//
// Set dimensions for text, pie chart, etc
//
// 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);
mPieBounds = new RectF(
0.0f,
0.0f,
diameter,
diameter);
mPieBounds.offsetTo(getPaddingLeft(), getPaddingTop());
mPointerY = mTextY - (mTextHeight / 2.0f);
float pointerOffset = mPieBounds.centerY() - mPointerY;
// Make adjustments based on text position
if (mTextPos == TEXTPOS_LEFT) {
mTextPaint.setTextAlign(Paint.Align.RIGHT);
if (mShowText) mPieBounds.offset(mTextWidth, 0.0f);
mTextX = mPieBounds.left;
if (pointerOffset < 0) {
pointerOffset = -pointerOffset;
mCurrentItemAngle = 225;
} else {
mCurrentItemAngle = 135;
}
mPointerX = mPieBounds.centerX() - pointerOffset;
} else {
mTextPaint.setTextAlign(Paint.Align.LEFT);
mTextX = mPieBounds.right;
if (pointerOffset < 0) {
pointerOffset = -pointerOffset;
mCurrentItemAngle = 315;
} else {
mCurrentItemAngle = 45;
}
mPointerX = mPieBounds.centerX() + pointerOffset;
}
如果我们想要更加精细的去控制View的大小,我们要实现onMeasure()方法。覆写这个方法会有两个参数,这两个参数分别是系统想要这个View的宽度和高度。我们在确定这个View的大小的时候,我们应该注意一点,确定这个View的Padding是View的责任,千万不要忘记。
resolveSizeAndState()方法是通过比较View想要的值和传入的参数来返回一个 View.MeasureSpec 参数,传入的参数是两个int类型的参数。onMeasure()方法没有返回值,我们是通过setMeasuredDimension()把结果地交给系统,传入的两个View.MeasureSpec类型的值,分别是宽和高。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// Try for a width based on our minimum
int minw = getPaddingLeft() + getPaddingRight() + getSuggestedMinimumWidth();
int w = Math.max(minw, MeasureSpec.getSize(widthMeasureSpec));
// Whatever the width ends up being, ask for a height that would let the pie
// get as big as it can
int minh = (w - (int) mTextWidth) + getPaddingBottom() + getPaddingTop();
int h = Math.min(MeasureSpec.getSize(heightMeasureSpec), minh);
setMeasuredDimension(w, h);
}
开始绘图
终于开始最重要的一步了,这个步骤在onDraw()里面完成。就是调用系统的一些方法,比如drawText(),drawRect(), drawOval(),drawPath(),drawBitmap()画出一些基本的图形,然后用setStyle(),等一些方法来确定样式。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// Draw the shadow
canvas.drawOval(mShadowBounds, mShadowPaint);
// Draw the label text
if (getShowText()) {
canvas.drawText(mData.get(mCurrentItem).mLabel, mTextX, mTextY, mTextPaint);
}
处理输入手势
当然,绘画当然是自定义View里面最重要的一步,但是能让View响应用户的操作也很重要,要不然和图片有什么区别。
我们知道用户的手势无非是这么几种,轻点,推,拉,快速移动,缩放,为了把这些原始的接触事件转化为手势,Android提供了 GestureDetector 。我们先要构造一个 GestureDetector ,然后通过这个 GestureDetector 来处理一些手势,如果处理不了,我们再自己自定义手势的处理方案。
首先要构造一个 GestureDetector ,我们要让一个类去继承 GestureDetector.OnGestureListener ,如果只想处理几个简单的事件我们可以继承GestureDetector.SimpleOnGestureListener,然后在这个类里面定义好处理各种手势的代码。
private class GestureListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
// Set the pie rotation directly.
float scrollTheta = vectorToScalarScroll(
distanceX,
distanceY,
e2.getX() - mPieBounds.centerX(),
e2.getY() - mPieBounds.centerY());
setPieRotation(getPieRotation() - (int) scrollTheta / FLING_VELOCITY_DOWNSCALE);
return true;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
// Set up the Scroller for a fling
float scrollTheta = vectorToScalarScroll(
velocityX,
velocityY,
e2.getX() - mPieBounds.centerX(),
e2.getY() - mPieBounds.centerY());
mScroller.fling(
0,
(int) getPieRotation(),
0,
(int) scrollTheta / FLING_VELOCITY_DOWNSCALE,
0,
0,
Integer.MIN_VALUE,
Integer.MAX_VALUE);
......
定义好这个类之后,我们就可以把这个类作为参数传递给GestureDetector:
mDetector = new GestureDetector(PieChart.this.getContext(), new GestureListener());
然后我们在onTouchEvent()方法里面,先把手势传递给GestureDetector,看它能不能解决,如果它能解决,会返回true,如果不能解决就会返回false,如果返回false,我们还需要自定义处理的代码。
@Override
public boolean onTouchEvent(MotionEvent event) {
// Let the GestureDetector interpret this event
boolean result = mDetector.onTouchEvent(event);
// If the GestureDetector doesn't want this event, do some custom processing.
// This code just tries to detect when the user is done scrolling by looking
// for ACTION_UP events.
if (!result) {
if (event.getAction() == MotionEvent.ACTION_UP) {
// User is done scrolling, it's now safe to do things like autocenter
stopScrolling();
result = true;
}
}
return result;
}
创建直观正确的运动
我们在创建动画,不应该只满足于创建出来动画,而是让动画尽可能的和真实世界里一样,这样用户才不会觉得讶异。比如所快速推一个物体,它应该是先慢慢加速,匀速,然后再慢慢停下来。玩转盘的时候,它也是先快,然后慢慢停下来。
要想自己实现这样的效果能难,不过Android已经帮我们集成好了一些相关的方法,只要传入参数,就能帮我们计算好各个运动时刻的参数值。比如在GestureListener里面的onFling()方法里,我们将一些参数传入Scroller类里的fling()方法,就可以根据当前的速度,位置等信息,自动生成各个时刻的信息。
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
// Set up the Scroller for a fling
mScroller.fling(
0,
(int) getPieRotation(),
0,
(int) scrollTheta / FLING_VELOCITY_DOWNSCALE,
0,
0,
Integer.MIN_VALUE,
Integer.MAX_VALUE);
当然fling()方法只是为我们提供了一个值,它并不会把这些时刻变化的位置信息应用到我们的View上,所以我们应该从Scroller里面得到这些值,然后更新View就好。
要去更新有两种方式,一种就是调用postInvalidate()去强制调用onDraw()方法去重绘,不过这个方法需要我们在onDraw()方法里面计算偏移量,然后每次改变的时候重新调用postInvalidate()。
第二种方式是添加一个ValueAnimator去监听滑动,添加一个监听器去处理动画的更新。
if (Build.VERSION.SDK_INT >= 11) {
mScrollAnimator = ValueAnimator.ofFloat(0, 1);
mScrollAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
public void onAnimationUpdate(ValueAnimator valueAnimator) {
tickScrollAnimation();
}
});
}
第二种方式虽然实现比较复杂(不过我觉得第一种更麻烦),但是它和系统结合的更紧密,而且不会产生多余的View。它在Android3.0的时候被引入(API11)。如果考虑低版本,我们应该在运行的时候检查一下版本。
if (Build.VERSION.SDK_INT >= 11) {
mScrollAnimator.setDuration(mScroller.getDuration());
mScrollAnimator.start();
}
return true;
让你的过渡平滑
还有一个小小的知识点,就是在View的位置做任何改变的时候都不要突然性的改变位置信息,而是要通过动画的形式来改变。因为在真实世界里,东西是不会突然出现,消失的,不会突然停下,或是突然开始运动,都要一个过程。用户自己也会用真实世界里面的直觉来预判View的变化,所以我们应该动画的形式来完成,比如在PieChart里面,我们要实现停止之后,小圆点位于所在饼切片的中间,这时我们就要用动画的解决,而不是直接生硬的修改位置。
private void centerOnCurrentItem() {
Item current = mData.get(getCurrentItem());
int targetAngle = current.mStartAngle + (current.mEndAngle - current.mStartAngle) / 2;
targetAngle -= mCurrentItemAngle;
if (targetAngle < 90 && mPieRotation > 180) targetAngle += 360;
if (Build.VERSION.SDK_INT >= 11) {
// Fancy animated version
mAutoCenterAnimator.setIntValues(targetAngle);
mAutoCenterAnimator.setDuration(AUTOCENTER_ANIM_DURATION).start();
} else {
// Dull non-animated version
//mPieView.rotateTo(targetAngle);
}
}
优化View
简而言之:
- 让你的布局尽可能浅从而减少系统确定大小时的历遍次数
- 尽可能减少不必要的Invalidate()从未减少对onDraw()不必要的回调,