Android自定义组件-以饼状图并实现点击事件为例

概述

在开发中,当现有控件不能满足需求时,可能就需要自定义控件来实现。
自定义控件,一般就是继承View或者View的子类,或者组合方式(即自定义控件中包含已有控件)

先看下效果,然后详细说明下,最后附上相关完整的代码
这是个自定义的饼状图(2020第一季度珠三角九市GDP),并且当点击相应区域会显示出相关信息。
pie_chart_view

详细说明

创建自己的组件,一般需要完成下面步骤:

  1. 创建自己的组件类,继承View类或View的子类。
  2. 重写父类的一些方法,如onDraw(),onMeasure(),onKeyDown()等。
  3. 上述实现就可以使用了。

接下来详细介绍下上面的步骤及注意和优化

创建组件

创建组件类

组件类,继承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添加自定义属性或者样式等,需要的操作:

  1. 添加资源定义,attrs.xml中定义了一个boolean类型的showText。
    <declare-styleable name="MyPieChartView">
        <attr name="showText" format="boolean"/>
    </declare-styleable>
  1. 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 对象是共享资源,必须在使用后回收。

  1. 自定义属性也能通过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的性能,应该注意一下几点:

  1. onDraw()会频繁调用,因此绘制对象(Paint)在初始化时创建。这样避免onDraw时创建占用资源导致卡顿。
  2. onDraw()频繁绘制调用,因此应尽量剔除不必要的代码及绘制操作。
  3. invalidate()后就会调用onDraw()重绘,避免不必要的invalidate()调用。
  4. 尽量保证浅的视图层次结构。视图遍历代价很大,每当视图调用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>
原文地址:https://www.cnblogs.com/fanglongxiang/p/13266852.html