Android自定义组件-以饼状图并实现点击事件为例
概述
在开发中,当现有控件不能满足需求时,可能就需要自定义控件来实现。
自定义控件,一般就是继承View或者View的子类,或者组合方式(即自定义控件中包含已有控件)。
先看下效果,然后详细说明下,最后附上相关完整的代码
这是个自定义的饼状图(2020第一季度珠三角九市GDP),并且当点击相应区域会显示出相关信息。
详细说明
创建自己的组件,一般需要完成下面步骤:
- 创建自己的组件类,继承View类或View的子类。
- 重写父类的一些方法,如onDraw(),onMeasure(),onKeyDown()等。
- 上述实现就可以使用了。
接下来详细介绍下上面的步骤及注意和优化
创建组件
创建组件类
组件类,继承View类或者View的子类。至少提供一种构造方法,这里也就提供了一种。这里获取了自定义的属性showText。
public MyPieChartView(Context context, @Nullable AttributeSet attrs) {
super( context, attrs );
TypedArray a = context.getTheme().obtainStyledAttributes( attrs, R.styleable.MyPieChartView,0, 0 );
//TypedArray对象是共享资源,必须在使用后回收。
try {
mIsShowText = a.getBoolean( R.styleable.MyPieChartView_showText, false );
} finally {
a.recycle();
}
init();
}
自定义属性或样式
通过XML添加自定义属性或者样式等,需要的操作:
- 添加
资源定义,attrs.xml中定义了一个boolean类型的showText。
<declare-styleable name="MyPieChartView">
<attr name="showText" format="boolean"/>
</declare-styleable>
- XML中使用了showText,设置为true。
<com.flx.flxblogtests.customComponent.piechart.MyPieChartView
android:id="@+id/piechart"
android:layout_centerInParent="true"
android:layout_width="200dp"
android:layout_height="200dp"
custom:showText="true" />
注意命名空间问题,这个是自定义的,不属于android。所以要添加下面的代码。
xmlns:custom="http://schemas.android.com/apk/res-auto"
这样定义,http://schemas.android.com/apk/res/[your package name]
。但是 In Gradle projects, always use http://schemas.android.com/apk/res-auto for custom attributes
,所以需要写成上面的样子。
3. 自定义View类里检索到,并使用。(上面创建中构造方法里有获取到showText)
TypedArray a = context.getTheme().obtainStyledAttributes( attrs, R.styleable.MyPieChartView,0, 0 );
//TypedArray对象是共享资源,必须在使用后回收。
try {
mIsShowText = a.getBoolean( R.styleable.MyPieChartView_showText, false );
} finally {
a.recycle();
}
请注意,TypedArray 对象是共享资源,必须在使用后回收。
- 自定义属性也能通过setter和getter方法 设置到View中或从自定义View中获取。
重写方法
自定义View,有几个方法是很重要的。
onDraw()
这个是必须重写的。它只有一个参数Canvas,View通过它进行绘制。
在绘制之前,需要创建Paint对象。
Canvas处理需要绘制什么,如画矩形、圆形、弧形、直线等等。而Paint决定如何绘制,绘制的样式、颜色等等。因此,一般都会有多个Paint对象。
绘制会频繁调用,因此绘制对象(Paint)在初始化时创建好。这样避免onDraw时创建占用资源导致卡顿;另外,因为频繁绘制调用,因此应尽量提出不必要的代码及绘制操作。
private void init() {
mTextPaint = new Paint( Paint.ANTI_ALIAS_FLAG );//ANTI_ALIAS_FLAG 消除锯齿。
mTextPaint.setColor( Color.BLACK );
mTextPaint.setTextAlign( Paint.Align.LEFT );
mTextPaint.setFakeBoldText( true );
mTextPaint.setTextSize( 50 );
mPiePaint = new Paint( Paint.ANTI_ALIAS_FLAG );
mPiePaint.setStyle( Paint.Style.FILL );
mShowTitle = "";
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw( canvas );
Log.d( TAG, "onDraw: width=" + getWidth() + ";height=" + getHeight() );
RectF bounds = new RectF( 0, 0, getWidth(), getHeight() );
//绘制pie
float startAngle = 0;
for (int i = 0; i < mData.length; ++i) {
float itemAngle = (float) (mData[i]/mSumData*360);
mPiePaint.setColor( COLOR_ARR[i%COLOR_ARR.length]);
canvas.drawArc(bounds, startAngle, itemAngle,true, mPiePaint);
startAngle = startAngle + itemAngle;
}
//绘制字符显示,当点击选中某块区域后才会显示
if(!"".equals( mShowTitle ) && mIsShowText) {
float txtWidth = mTextPaint.measureText( mShowTitle );
Paint.FontMetricsInt fm = mTextPaint.getFontMetricsInt();
float height = fm.bottom - fm.top;
canvas.drawText( mShowTitle,getWidth()/2 - txtWidth/2, getHeight() - height, mTextPaint );
mShowTitle = "";
}
}
题外话:博客园页面右边的时钟也是通过H5 canvas绘制的,我觉得还不错。
onMeasure()
onMeasure()是组件与父级容器之间的关键部分,它提供准确的测量后的宽高。一旦计算出宽度、高度,必须调用setMeasuredDimension(int width, int height)存储测量后的宽高。
onTouchEvent()
事件处理。下面是计算触摸点是落在哪个扇形区域的。
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!mIsShowText)
return super.onTouchEvent( event );
Log.d( TAG, "onTouchEvent: x=" + event.getX() + "y=" + event.getY() );
float radius = getWidth()/2;
float touchX = event.getX();
float touchY = event.getY();
float distanceX = Math.abs( touchX - getWidth()/2 );
float distanceY = Math.abs( touchY - getHeight()/2 );
//是否在圆内
if ((distanceX*distanceX + distanceY*distanceY) > radius*radius) {
Log.d( TAG, "onTouchEvent: touch point out of circule!" );
return super.onTouchEvent( event );
}
//计算角度,因为View左上角为(0,0),转换后圆心作为(0,0)计算角度
float tmpTheta = (float) Math.atan2( getHeight()/2 - touchY, touchX - getWidth()/2 );
Log.d( TAG, "onTouchEvent: theta=" + tmpTheta*180/Math.PI );
tmpTheta = (float) (tmpTheta*180/Math.PI);
float realTheta = 0;
if (tmpTheta >= 0) {
realTheta = 360 - tmpTheta;
} else {
realTheta = Math.abs( tmpTheta );
}
//判断所选点在哪个区域
float touchSum = (float) mSumData*(realTheta/360);
float tmpSum = 0;
for (int i = 0; i < mData.length; ++i) {
tmpSum += mData[i];
if (tmpSum > touchSum) {
mShowTitle = mDataTitle[i] + ":" + mData[i];
break;
}
}
//重绘
invalidate();
return super.onTouchEvent( event );
}
下面是官网的表格,作为参考:
分类 | 方法 | 描述 |
---|---|---|
创建 | Constructors | 从代码创建自定义View 和 从布局文件中inflate到View都会调用到 |
创建 | onFinishInflate() | 在视图及其所有子级都已从 XML布局文件中 扩充之后调用 |
布局 | onMeasure(int, int) | 调用以确定此视图及其所有子级的大小要求。 |
布局 | onLayout(boolean, int, int, int, int) | 在此视图应为其所有子级分配大小和位置时调用。 |
布局 | onSizeChanged(int, int, int, int) | 在此视图的大小发生变化时调用。 |
绘制 | onDraw(Canvas) | 在视图应渲染其内容时调用。 |
事件处理 | onKeyDown(int, KeyEvent) | 在发生新的按键事件时调用。 |
事件处理 | onKeyUp(int, KeyEvent) | 在发生松开按键事件时调用。 |
事件处理 | onTrackballEvent(MotionEvent) | 在发生轨迹球动作事件时调用。 |
事件处理 | onTouchEvent(MotionEvent) | 在发生触屏动作事件时调用。 |
焦点 | onFocusChanged(boolean, int, Rect) | 在视图获得或失去焦点时调用。 |
焦点 | onWindowFocusChanged(boolean) | 在包含视图的窗口获得或失去焦点时调用。 |
附加 | onAttachedToWindow() | 在视图附加到窗口时调用。 |
附加 | onDetachedFromWindow() | 在视图与其窗口分离时调用。 |
附加 | onWindowVisibilityChanged(int) | 在包含视图的窗口的可见性发生变化时调用。 |
组件使用
通过布局文件直接添加或者通过代码添加。这里通过布局文件直接添加。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:custom="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.flx.flxblogtests.customComponent.piechart.MyPieChartView
android:id="@+id/piechart"
android:layout_centerInParent="true"
android:layout_width="200dp"
android:layout_height="200dp"
custom:showText="true" />
</RelativeLayout>
优化
为保证自定义View的性能,应该注意一下几点:
- onDraw()会频繁调用,因此绘制对象(Paint)在初始化时创建。这样避免onDraw时创建占用资源导致卡顿。
- onDraw()频繁绘制调用,因此应尽量剔除不必要的代码及绘制操作。
- invalidate()后就会调用onDraw()重绘,避免不必要的invalidate()调用。
- 尽量保证浅的视图层次结构。视图遍历代价很大,每当视图调用requestLayout()时,Android都需要遍历整个视图层次结构,以确定每个视图所需的尺寸。如果发现有冲突的尺寸,可能需要多次遍历该层次结构。
完整代码
自定义View类,MyPieChartView
public class MyPieChartView extends View {
private final String TAG = "MyPieChartView";
private final int[] COLOR_ARR = {Color.RED, 0xFFFFA500, Color.YELLOW, Color.GREEN, 0xFF00FFFF, Color.BLUE, 0xFF800080};
private boolean mIsShowText;
private String mShowTitle;
private Paint mTextPaint;
private double[] mData;
private double mSumData = 0;
private String[] mDataTitle;
private Paint mPiePaint;
public MyPieChartView(Context context, @Nullable AttributeSet attrs) {
super( context, attrs );
TypedArray a = context.getTheme().obtainStyledAttributes( attrs, R.styleable.MyPieChartView,0, 0 );
//TypedArray对象是共享资源,必须在使用后回收。
try {
mIsShowText = a.getBoolean( R.styleable.MyPieChartView_showText, false );
} finally {
a.recycle();
}
init();
}
private void init() {
mTextPaint = new Paint( Paint.ANTI_ALIAS_FLAG );//ANTI_ALIAS_FLAG 消除锯齿。
mTextPaint.setColor( Color.BLACK );
mTextPaint.setTextAlign( Paint.Align.LEFT );
mTextPaint.setFakeBoldText( true );
mTextPaint.setTextSize( 50 );
mPiePaint = new Paint( Paint.ANTI_ALIAS_FLAG );
mPiePaint.setStyle( Paint.Style.FILL );
mShowTitle = "";
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure( widthMeasureSpec, heightMeasureSpec );
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw( canvas );
Log.d( TAG, "onDraw: width=" + getWidth() + ";height=" + getHeight() );
RectF bounds = new RectF( 0, 0, getWidth(), getHeight() );
//绘制pie
float startAngle = 0;
for (int i = 0; i < mData.length; ++i) {
float itemAngle = (float) (mData[i]/mSumData*360);
mPiePaint.setColor( COLOR_ARR[i%COLOR_ARR.length]);
canvas.drawArc(bounds, startAngle, itemAngle,true, mPiePaint);
startAngle = startAngle + itemAngle;
}
//绘制字符显示,当点击选中某块区域后才会显示
if(!"".equals( mShowTitle ) && mIsShowText) {
float txtWidth = mTextPaint.measureText( mShowTitle );
Paint.FontMetricsInt fm = mTextPaint.getFontMetricsInt();
float height = fm.bottom - fm.top;
canvas.drawText( mShowTitle,getWidth()/2 - txtWidth/2, getHeight() - height, mTextPaint );
mShowTitle = "";
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!mIsShowText)
return super.onTouchEvent( event );
Log.d( TAG, "onTouchEvent: x=" + event.getX() + "y=" + event.getY() );
float radius = getWidth()/2;
float touchX = event.getX();
float touchY = event.getY();
float distanceX = Math.abs( touchX - getWidth()/2 );
float distanceY = Math.abs( touchY - getHeight()/2 );
//是否在圆内
if ((distanceX*distanceX + distanceY*distanceY) > radius*radius) {
Log.d( TAG, "onTouchEvent: touch point out of circule!" );
return super.onTouchEvent( event );
}
//计算角度,因为View左上角为(0,0),转换后圆心作为(0,0)计算角度
float tmpTheta = (float) Math.atan2( getHeight()/2 - touchY, touchX - getWidth()/2 );
Log.d( TAG, "onTouchEvent: theta=" + tmpTheta*180/Math.PI );
tmpTheta = (float) (tmpTheta*180/Math.PI);
float realTheta = 0;
if (tmpTheta >= 0) {
realTheta = 360 - tmpTheta;
} else {
realTheta = Math.abs( tmpTheta );
}
//判断所选点在哪个区域
float touchSum = (float) mSumData*(realTheta/360);
float tmpSum = 0;
for (int i = 0; i < mData.length; ++i) {
tmpSum += mData[i];
if (tmpSum > touchSum) {
mShowTitle = mDataTitle[i] + ":" + mData[i];
break;
}
}
//重绘
invalidate();
return super.onTouchEvent( event );
}
public double[] getData() {
return mData;
}
public void setData(double[] mData) {
this.mData = mData;
mSumData = 0;
//计算数据和,后续计算各个部分所占百分比
for (int i = 0;i < mData.length; i++) {
mSumData += mData[i];
}
}
public String[] getDataTitle() {
return mDataTitle;
}
public void setDataTitle(String[] mDataTitle) {
this.mDataTitle = mDataTitle;
}
}
Activity调用,PieChartActivity
public class PieChartActivity extends Activity {
private double[] mData = {5785.6,5228.8,2154.0,1923.7,879.1,703.5,631.9,597.2,424.5};
private String[] mDataTitle = {"深圳", "广州", "佛山", "东莞", "惠州", "珠海", "江门", "中山", "肇庆"};
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate( savedInstanceState );
setContentView( R.layout.custom_component_piechart_act );
MyPieChartView pieChartView = findViewById( R.id.piechart );
pieChartView.setData( mData );
pieChartView.setDataTitle( mDataTitle );
}
}
布局文件:custom_component_piechart_act.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:custom="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.flx.flxblogtests.customComponent.piechart.MyPieChartView
android:id="@+id/piechart"
android:layout_centerInParent="true"
android:layout_width="200dp"
android:layout_height="200dp"
custom:showText="true" />
</RelativeLayout>
资源文件:attrs.xml
<resources>
<declare-styleable name="MyPieChartView">
<attr name="showText" format="boolean"/>
</declare-styleable>
</resources>