效果如下,就是自定义了一个比较酷炫可以转动的扇形图,官方文档链接https://developer.android.com/training/custom-views/index.html
本文只会描述我认为实现的时候需要考虑的一些关键的点(实际要完整地写出来需要考虑的东西还是挺多的),有些基本的、细节的或机制的东西限于篇幅原因不太可能详细讲,可以根据给出的链接查看详细或者自行搜索
完整的demo代码在文末给出
1.关于view的onDraw(Canvas canvas)
用于绘制图像,其中Canvas(画布)可以当成具体画到的地方,由图形的具体形状确定绘画方法需要的传递的参数(如line需要传递startX、startY、endX、endY),Paint对象可以当成画笔,维护着color、size等属性,通过canvas相关方法就可以绘制不同的图像 ,如canvas.drawLine(startX, startY, endX, endY, Paint)
2. 关于画每一个扇形
demo中每一个扇形对应的model用Item表示,Item主要有label、value、color三个属性,还保存有三个计算出来的属性(startAngle、endAngle:起始角度和结束角度,画扇形的时候需要用到,highlight:使用color计算出来的高亮颜色),然后在onDraw方法中遍历items中的每一个item使用canvas.drawArc(RectF, startAngle, endAngle, useCenter, Paint)就可以画出扇形
3.关于图像的旋转
可以通过View提供setRotation(degree)方法来实现旋转,滑动手势的检测可以通过一个叫的GestureDetector的helper class来方便地实现,GestureDetector的构造器需要传递Context和一个叫OnGestureListener的回调接口,OnGestureListener提供了很多回调方法,比如onScroll、onLongPress、onFling(表示飞速滑动的意思),我们只需要在这些方法里写我们需要的应用逻辑就可以了,简便起见可以扩展SimpleOnGestureListener类,覆盖其他的一个或多个以及onDown方法并注意在onDown中返回true就可以了
@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;
}
这里我们覆盖了 boolean onScroll(MotionEvent e1, MotionEvent e2,floatdistanceX,floatdistanceY)方法,并根据e1、e2、distanceX、distanceY计算出滑动路线对应的圆周角(demo里面不是计算圆周角的,但是我觉得计算圆周角比较合适),再调用setRotation(degree)即可实现旋转
最后在onTouchEvent中将触摸事件转交给GestureDetector处理就可以了,关于onTouchEvent返回true和false的问题(view事件机制)请自行百度谷歌或查阅官方文档
@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;
}
4.关于fling(飞速滑动)
fling可以想象成惯性滑动,通过实现OnGestureListener回调接口的 boolean onFling(MotionEvent e1, MotionEvent e2, floatvelocityX,floatvelocityY) 方法可以实现相关的惯性滑动逻辑,这里我们使用到了Scroller和ValueAnimator(android3.0推出的一种动画形式)来实现圆顺地滑动
@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);
// Start the animator and tell it to animate for the expected duration of the fling.
if (Build.VERSION.SDK_INT >= 11) {
mScrollAnimator.setDuration(mScroller.getDuration());
mScrollAnimator.start();
}
return true;
}
Scroller类似于GestureDetector,也是一个Helper class,只是封装了滑动相关的动作需要实现的数学逻辑,本身并不执行view的滑动操作,因此需要Animator的配合:
View创建时对Aniamtor和Scroller的初始化
if (Build.VERSION.SDK_INT < 11) {
mScroller = new Scroller(getContext());
} else {
mScroller = new Scroller(getContext(), null, true);
}
mScrollAnimator = ValueAnimator.ofFloat(0, 1); //0、1随便取的,不用纠结取值问题
mScrollAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
public void onAnimationUpdate(ValueAnimator valueAnimator) {
tickScrollAnimation(); //每次value更新的时候都会回调这个方法
}
});
private void tickScrollAnimation() {
if (!mScroller.isFinished()) {
mScroller.computeScrollOffset(); //
setPieRotation(mScroller.getCurrY());
} else {
if (Build.VERSION.SDK_INT >= 11) {
mScrollAnimator.cancel();
}
onScrollFinished();
}
}
以及在onFling中开始滑动
mScrollAnimator.setDuration(mScroller.getDuration());
mScrollAnimator.start();
Scroller的另外一种常见使用方式是重写view的onComputeScroll方法,内容和tickScrollAnimation中差不多,具体不详细介绍
还有另一种Scroller叫OverScroller,额外提供了springBack(超过回弹),ScrollerView中也使用了OverScroller,在自定义View中使用的频率还是挺高的,具体不详细介绍
5.关于自动滑动到扇形中间
/**
* Kicks off an animation that will result in the pointer being centered in the
* pie slice of the currently selected item.
*/
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);
}
}
先是需要计算出centerItem需要旋转的targetAngle值,然后使用值动画mAutoCenterAnimator(android 3.0以上版本的时候)进行滑动或者是直接...作者直接注释掉了
mAutoCenterAnimator的初始化:
<span style="white-space:pre"> </span>if (Build.VERSION.SDK_INT >= 11) {
mAutoCenterAnimator = ObjectAnimator.ofInt(PieChart.this, "PieRotation", 0);
// Add a listener to hook the onAnimationEnd event so that we can do
// some cleanup when the pie stops moving.
mAutoCenterAnimator.addListener(new Animator.AnimatorListener() {
public void onAnimationStart(Animator animator) {
}
public void onAnimationEnd(Animator animator) {
mPieView.decelerate();
}
public void onAnimationCancel(Animator animator) {
}
public void onAnimationRepeat(Animator animator) {
}
});
}
实际使用值动画的时候为了兼容到API8一般的做法是使用nineoldandroids开源兼容动画框架http://nineoldandroids.com/,就不用像demo中需要写那么多检测API版本的烦人代码了
centerOnCurrentItem方法需要在滑动结束后调用(如下的onScrollFinished代码和上面tickScrollAnimation中在滑动结束后对onScrollFinished的)
/**
* Called when the user finishes a scroll action.
*/
private void onScrollFinished() {
if (mAutoCenterInSlice) {
centerOnCurrentItem();
} else {
mPieView.decelerate(); //关闭硬件加速,这一行可以忽略
}
}
但是setPieRotation(在每次检测到滑动动作的时候都会调用到,很频繁)也会间接调用到centerOnCurrentItem方法,具体为什么调我有点迷茫...
6.关于onSizeChanged方法
@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);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// Do nothing. Do not call the superclass method--that would start a layout pass
// on this view's children. PieChart lays out its children in onSizeChanged().
}
有点让我意外的是onLayout方法是空的,对子View进行布局的逻辑都移到了onSizeChanged里,后来一想,因为子元素都是固定的,和LinearLayout之类的布局不一样,而且通过log打印发现onSizeChanged只被调用了一次,onMeasure和onLayout方法被调用了好几次,所以这样还是挺合理的吧
@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;
}
mShadowBounds = new RectF(
mPieBounds.left + 10,
mPieBounds.bottom + 10,
mPieBounds.right - 10,
mPieBounds.bottom + 20);
// Lay out the child view that actually draws the pie.
mPieView.layout((int) mPieBounds.left,
(int) mPieBounds.top,
(int) mPieBounds.right,
(int) mPieBounds.bottom);
mPieView.setPivot(mPieBounds.width() / 2, mPieBounds.height() / 2);
mPointerView.layout(0, 0, w, h);
onDataChanged();
}
代码还是挺好理解的,不过有点长是真的...