前言

实习期间被分配到的第一个任务,完成大概如图这样一个界面。乍一看,整个界面的布局还是十分清晰的,即使是新手也能轻易完成。唯一的难题应该就是这个红色的进度条了,我一开始考虑使用TextView的drawableLeft来实现,但又感觉不如自定义控件来得灵活,遂决定使用自定义控件的方式实现。然而,我高估了自己的水平😅,过程中遇到了不少坑,花了几天才误打误撞地完成这个“简易”进度条,也正因如此,才有了这篇文章来记录一下思考过程、遇到的问题以及解决方案。


Android开发自定义控件 android自定义控件 进度条_ci

设计分析

实现这样一个控件,要考虑的方面有:绘制(Draw)、测量(Measure)以及属性(Attribute)。

  • 绘制。显而易见,绘制一条直线和几个圆形即可。
  • 测量。我选择以圆形的直径为控件的宽(width),父容器的高度为控件的高(height)。
  • 属性。比较自由,如直线的粗细、圆形的半径、圆环的位置等。为简便起见,只考虑几个比较关键的属性。

实现

根据上述分析,代码的逻辑也基本理清了,实现起来应该是水到渠成。

创建类

新建MyProgressView类,继承自View类,并实现相关构造方法,使代码入口一致。

public class MyProgressView extends View {

    private static final String TAG = "MyProgressView";

    public MyProgressView(Context context) {
        this(context, null);
    }

    public MyProgressView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public MyProgressView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

设置属性

为方便使用,部分属性希望能在布局文件中直接修改,将其添加至attrs.xml文件中,并在控件的构造方法中完成初始化。另一部分属性,可能不方便于布局文件中修改或其他原因,为其设置get/set方法。

<declare-styleable name="MyProgressView">
        <attr name="circleRadius" format="dimension"/>
        <attr name="lineWidth" format="dimension"/>
        <attr name="circlePosition1" format="float"/>
        <attr name="circlePosition2" format="float"/>
        <attr name="circlePosition3" format="float"/>
    </declare-styleable>
public class MyProgressView extends View {

    private static final String TAG = "MyProgressView";
	
	//控件的高度
    private int mHeight;
	
    //绘制的起始点
    private float mStartY = DEFAULT_START_Y;
    public static final float DEFAULT_START_Y = 0;

    //实线的宽度
    private float mLineWidth;

    //圆环的半径
    private float mRadius;

    //圆环的位置(圆心)
    private float[] mCirclePositions = new float[3];

    //当前进程
    private int mProgress = -1;

    public MyProgressView(Context context) {
        this(context, null);
    }

    public MyProgressView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public MyProgressView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //初始化属性
        initAttrs(context, attrs);
    }

    private void initAttrs(Context context, AttributeSet attrs) {
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyProgressView);
        //圆环的半径
        int defaultRadius = SizeUtils.dip2px(5);
        mRadius = a.getDimension(R.styleable.MyProgressView_circleRadius, defaultRadius);
        //实线的宽度
        int defaultWidth = SizeUtils.dip2px(2);
        mLineWidth = a.getDimension(R.styleable.MyProgressView_lineWidth, defaultWidth);
        /*
         * 三个圆环的位置(y轴)
         * 推荐使用setCirclePositions方法设置
         */
        mCirclePositions[0] = a.getFloat(R.styleable.MyProgressView_circlePosition1, -1);
        mCirclePositions[1] = a.getFloat(R.styleable.MyProgressView_circlePosition2, -1);
        mCirclePositions[2] = a.getFloat(R.styleable.MyProgressView_circlePosition3, -1);
        //回收
        a.recycle();
    }

	/*
     * 省略部分属性的get/set方法
     */
}

绘制

绘制直线和圆形所需要的的相关属性已经定义好了,除此之外还需要实例化对应的画笔。

private void initPaints() {
        //初始化实线画笔
        mLinePaint = new Paint();
        mLinePaint.setColor(Color.parseColor("#FF0000"));
        mLinePaint.setStrokeWidth(mLineWidth);
        //初始化圆形画笔
        innerCirclePaint = new Paint();
        outerCirclePaint = new Paint();
        innerCirclePaint.setColor(Color.parseColor("#FFFFFF"));
        outerCirclePaint.setColor(Color.parseColor("#D0021B"));
    }

紧接着就是绘制过程。圆环利用两个重合的大小圆形实现,根据mProgress的值决定是否只绘制一个圆形,以表示当前流程。

@Override
    protected void onDraw(Canvas canvas) {
        Log.i(TAG,"-- onDraw --");
        super.onDraw(canvas);
        //绘制实线
        canvas.drawLine(mRadius, mStartY, mRadius, mHeight, mLinePaint);
        //绘制圆环
        if (mCirclePositions != null && mCirclePositions.length > 0) {
            for (int i = 1; i <= 3; i ++) {
                float f = mCirclePositions[i - 1];
                if (f == -1) continue;  //未设值,不绘制
                canvas.drawCircle(mRadius, f, mRadius, outerCirclePaint);
                if (i != mProgress) {
                    canvas.drawCircle(mRadius, f, mRadius / 2, innerCirclePaint);
                }
            }
        }
    }

测量

根据之前的分析,测量过程十分简单,控件的宽(width)和高(height)都写死,分别为圆形的直径和父容器的剩余高度。

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        Log.i(TAG, "heightSize --> " + heightSize);
        Log.i(TAG, "widthSize --> "+ widthSize);
        Log.i(TAG, "heightMode --> " + heightMode);
        Log.i(TAG, "widthMode --> " + widthMode);
        Log.i(TAG, "----------------------");
        mHeight = heightSize;
        setMeasuredDimension((int) mRadius * 2, heightSize);
    }

使用测试

至此,我认为自定义进度条已经完成,迫不及待地将其加入布局。
注意:由于在onMeasure方法中已经将测量宽度确定(精确值),因此控件的layout_width属性可以为任意值,不会影响显示结果。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 
	android:id="@+id/container"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="10dp"
    tools:context=".MainActivity"
    xmlns:app="http://schemas.android.com/apk/res-auto">
	
	<!--layout_width可以为任意值,不会影响结果-->
    <com.mone.customview.progressView.MyProgressView
        android:id="@+id/visit_progress_view"
        android:layout_width="10dp"
        android:layout_height="wrap_content"
        app:circleRadius="6dp"
        android:layout_alignParentStart="true" />

    <TextView
        android:id="@+id/title1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        style="@style/text_title_style"
        android:textColor="#000000"
        android:text="流程1"/>

    <TextView
        android:id="@+id/content1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/title1"
        style="@style/text_content_style"
        android:textColor="#aaaaaa"
        android:text="内容内容很多内容内容内容很多内容内容内容很多内容内容内容很多内容内容内容很多内容很多内容很多内容很多内容很多内容"/>

    <TextView
        android:id="@+id/title2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/content1"
        style="@style/text_title_style"
        android:textColor="#000000"
        android:text="流程2"/>

    <TextView
        android:id="@+id/content2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/title2"
        style="@style/text_content_style"
        android:textColor="#aaaaaa"
        android:text="内容内容很多内容内容内容很多内容内容内容很多内容内容内容很多内容内容内容很多内容很多内容很多内容很多内容很多内容很多内容很多内容很多内容"/>

    <TextView
        android:id="@+id/title3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/content2"
        style="@style/text_title_style"
        android:textColor="#000000"
        android:text="流程3"/>

    <TextView
        android:id="@+id/content3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/title3"
        style="@style/text_content_style"
        android:textColor="#aaaaaa"
        android:text="内容内容很多内容内容内容很多内容内容内容很多内容内容内容很多内容内容内容很多内容很多内容很多内容很多内容很多内容很多内容"/>

</RelativeLayout>

并设置好相应的属性。
注意:获取TextView位置属性时别忘记考虑容器的padding值。

@Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        //初始化组件
        container = findViewById(R.id.container);
        myProgressView = findViewById(R.id.visit_progress_view);
        title1 = findViewById(R.id.title1);
        title2 = findViewById(R.id.title2);
        title3 = findViewById(R.id.title3);
        //设置进度条
        container.post(new Runnable() {
            @Override
            public void run() {
                int padding = container.getPaddingTop();
                //获取三个标题TextView的中心高度
                float y1 = (title1.getTop() + title1.getBottom() - 2 * padding) >> 1;
                float y2 = (title2.getTop() + title2.getBottom() - 2 * padding) >> 1;
                float y3 = (title3.getTop() + title3.getBottom() - 2 * padding) >> 1;
                myProgressView.setStartY(y1);
                myProgressView.setCirclePositions(new float[]{y1, y2, y3});
                myProgressView.invalidate();
            }
        });
        myProgressView.setProgress(2);
    }

启动测试,查看结果:

Android开发自定义控件 android自定义控件 进度条_android_02


出现这样的结果是正常的,但这不符合需求,我希望进度条的高度根据右侧的内容变化,而不是像这样占满了屏幕。这也是我实现这个自定义控件过程中最头疼的一件事。为此,我特意去学习研究了onMeasure方法,也就是我的上一篇文章:自定义控件之onMeasure方法的研究整理。最终解决了这个问题,根源就在于处理不同情况下的测量过程。

EXACTLY

AT_MOST

UNSPECIFIED

dp/px

EXACTLY

childSize

EXACTLY

childSize

EXACTLY

childSize

match_parent

EXACTLY

parentSize

AT_MOST

parentSize

UNSPECIFIED

0

wrap_content

AT_MOST

parentSize

AT_MOST

parentSize

UNSPECIFIED

0

依旧搬出这张表,横轴对应父容器的测量模式,纵轴对应子View的layoutParams,值为onMeasure方法中的对应参数。
刚才的布局中,RelativeLayout的layout_height属性是wrap_content,对应AT_MOST模式,myProgressView的layout_height属性是wrap_content。那么,从参数heightMeasureSpec中取出的值和模式就分别是parentSize和AT_MOST,也就出现了那样的结果。

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    	//省略部分代码
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        setMeasuredDimension((int) mRadius * 2, heightSize);
    }

解决问题

既然如此,那么要解决这个问题的思路就很明确了。当返回的模式为AT_MOST时,做不一样的处理即可,我的解决方案如下:
首先修改onMeasure方法,当测试模式为AT_MOST时,让控件的测量高为0。这样的话,父容器的高度就只由其他子控件决定,不会再出现占满屏幕的情况。

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        Log.i(TAG, "heightSize --> " + heightSize);
        Log.i(TAG, "widthSize --> "+ widthSize);
        Log.i(TAG, "heightMode --> " + heightMode);
        Log.i(TAG, "widthMode --> " + widthMode);
        Log.i(TAG, "----------------------");
        mHeight = heightSize;
        //处理不同模式下的测量值
        if (heightMode == MeasureSpec.EXACTLY) {
            setMeasuredDimension((int) mRadius * 2, heightSize);
        } else if (heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension((int) mRadius * 2, 0);
        }
    }

然后调整初始化进度条的方法,在那里才为进度条设置高度值就能实现进度条控件与内容同高的需求了。

/**
     * 初始化进度条组件
     */
    private void setupProgressView() {
        container = findViewById(R.id.container);
        container.post(new Runnable() {
            @Override
            public void run() {
                int height = container.getMeasuredHeight();
                Log.i(TAG, "容器的最终高度 --> " + height);
                ViewGroup.LayoutParams layoutParams = myProgressView.getLayoutParams();
                layoutParams.height = height;
                myProgressView.setLayoutParams(layoutParams);
                myProgressView.setHeight(height);
                //设置进度条圆点位置
                int padding = container.getPaddingTop();
                float y1 = (title1.getTop() + title1.getBottom() - 2 * padding) >> 1;
                float y2 = (title2.getTop() + title2.getBottom() - 2 * padding) >> 1;
                float y3 = (title3.getTop() + title3.getBottom() - 2 * padding) >> 1;
                myProgressView.setStartY(y1);
                myProgressView.setCirclePositions(new float[]{y1, y2, y3});
            }
        });
        //设置进度
        myProgressView.setProgress(2);
    }

这样修改以后,最终的结果也符合需求:

Android开发自定义控件 android自定义控件 进度条_移动开发_03

第三种模式

上一轮修改onMeasure方法时,只考虑了EXACTLY和AT_MOST模式,却忽略了第三种模式:UNSPECIFIED模式。虽然大部分文章告诉我,这种模式很少用到,一般无需考虑。但我直接遇到了这种情况:当需要用滑动布局嵌套时,就需要考虑这种模式。

<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <RelativeLayout
        android:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="10dp">

        <com.mone.customview.progressView.MyProgressView
            android:id="@+id/visit_progress_view"
            android:layout_width="10dp"
            android:layout_height="wrap_content"
            android:layout_alignParentStart="true" />

		<!-- 省略部分代码 -->
		
	</RelativeLayout>

</androidx.core.widget.NestedScrollView>

可以看一下输出的Log。其中heightMode = 0,即表示UNSPECIFIED模式。

Android开发自定义控件 android自定义控件 进度条_android_04


不过解决的方式也很简单,与AT_MOST模式类似,在测量过程中设置测量值为0(返回值就是0,说明滑动布局有意这样设计),在初始化的过程中再设置高度即可。

//处理不同模式下的测量值
        if (heightMode == MeasureSpec.EXACTLY) {
            setMeasuredDimension((int) mRadius * 2, heightSize);
        } else {
            setMeasuredDimension((int) mRadius * 2, 0);
        }

完整代码

流程进度条

/**
 * 进度指示器
 */
public class MyProgressView extends View {

    private static final String TAG = "MyProgressView";

    //控件的高度
    private int mHeight;

    //绘制的起始点
    private float mStartY = DEFAULT_START_Y;
    public static final float DEFAULT_START_Y = 0;

    //实线的宽度
    private float mLineWidth;

    //圆环的半径
    private float mRadius;

    //圆环的位置(圆心)
    private float[] mCirclePositions = new float[3];

    //当前流程
    private int mProgress = -1;

    //画笔
    private Paint mLinePaint;
    private Paint innerCirclePaint;
    private Paint outerCirclePaint;

    public MyProgressView(Context context) {
        this(context, null);
    }

    public MyProgressView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public MyProgressView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //初始化属性
        initAttrs(context, attrs);
        //初始化画笔
        initPaints();
    }

    private void initAttrs(Context context, AttributeSet attrs) {
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyProgressView);
        //圆环的半径
        int defaultRadius = SizeUtils.dip2px(5);
        mRadius = a.getDimension(R.styleable.MyProgressView_circleRadius, defaultRadius);
        //实线的宽度
        int defaultWidth = SizeUtils.dip2px(2);
        mLineWidth = a.getDimension(R.styleable.MyProgressView_lineWidth, defaultWidth);
        /*
         * 三个圆环的位置(y轴)
         * 推荐使用setCirclePositions方法设置
         */
        mCirclePositions[0] = a.getFloat(R.styleable.MyProgressView_circlePosition1, -1);
        mCirclePositions[1] = a.getFloat(R.styleable.MyProgressView_circlePosition2, -1);
        mCirclePositions[2] = a.getFloat(R.styleable.MyProgressView_circlePosition3, -1);
        //回收
        a.recycle();
    }


    /**
     * 初始化画笔
     */
    private void initPaints() {
        //初始化实线画笔
        mLinePaint = new Paint();
        mLinePaint.setColor(Color.parseColor("#FF0000"));
        mLinePaint.setStrokeWidth(mLineWidth);
        //初始化圆形画笔
        innerCirclePaint = new Paint();
        outerCirclePaint = new Paint();
        innerCirclePaint.setColor(Color.parseColor("#FFFFFF"));
        outerCirclePaint.setColor(Color.parseColor("#D0021B"));
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        Log.i(TAG, "heightSize --> " + heightSize);
        Log.i(TAG, "widthSize --> "+ widthSize);
        Log.i(TAG, "heightMode --> " + heightMode);
        Log.i(TAG, "widthMode --> " + widthMode);
        Log.i(TAG, "----------------------");
        mHeight = heightSize;
        if (heightMode == MeasureSpec.EXACTLY) {
            setMeasuredDimension((int) mRadius * 2, heightSize);
        } else {
            setMeasuredDimension((int) mRadius * 2, 0);
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        Log.i(TAG,"-- onDraw --");
        super.onDraw(canvas);
        //绘制实线
        canvas.drawLine(mRadius, mStartY, mRadius, mHeight, mLinePaint);
        //绘制圆环
        if (mCirclePositions != null && mCirclePositions.length > 0) {
            for (int i = 1; i <= 3; i ++) {
                float f = mCirclePositions[i - 1];
                if (f == -1) continue;  //未设值,不绘制
                canvas.drawCircle(mRadius, f, mRadius, outerCirclePaint);
                if (i != mProgress) {
                    canvas.drawCircle(mRadius, f, mRadius / 2, innerCirclePaint);
                }
            }
        }
    }

    /**
     * 设置当前进度
     * @param num
     */
    public void setProgress(int num) {
        if (num < 1 || num > 3) return;
        mProgress = num;
        invalidate();
    }

    /**
     * 设置View的高度
     * @param height
     */
    public void setHeight(int height) {
        mHeight = height;
    }

    /**
     * 设置绘制的Y轴起始点
     * @param y
     */
    public void setStartY(float y) {
        mStartY = y;
    }

    /**
     * 设置圆环的半径
     * @param radius
     */
    public void setRadius(float radius) {
        mRadius = radius;
    }

    public float getRadius() {
        return mRadius;
    }

    /**
     * 设置实线的宽度
     * @param width
     */
    public void setLineWidth(float width) {
        mLineWidth = width;
        mLinePaint.setStrokeWidth(mLineWidth);
    }

    /**
     * 设置圆环的位置
     * @param floats
     */
    public void setCirclePositions(float[] floats) {
        this.mCirclePositions = floats;
    }
}

使用方式

public class MainActivity extends AppCompatActivity {

    private static final String TAG = "MainActivity";

    private RelativeLayout container;
    private MyProgressView myProgressView;
    private TextView title1, title2, title3;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        //初始化组件
        container = findViewById(R.id.container);
        myProgressView = findViewById(R.id.visit_progress_view);
        title1 = findViewById(R.id.title1);
        title2 = findViewById(R.id.title2);
        title3 = findViewById(R.id.title3);
        //设置进度条
        setupProgressView();
    }
     
    /**
     * 初始化进度条组件
     */
    private void setupProgressView() {
        container.post(new Runnable() {
            @Override
            public void run() {
                int height = container.getMeasuredHeight();
                Log.i(TAG, "容器的最终高度 --> " + height);
                ViewGroup.LayoutParams layoutParams = myProgressView.getLayoutParams();
                layoutParams.height = height;
                myProgressView.setLayoutParams(layoutParams);
                myProgressView.setHeight(height);
                //设置进度条圆点位置
                int padding = container.getPaddingTop();
                float y1 = (title1.getTop() + title1.getBottom() - 2 * padding) >> 1;
                float y2 = (title2.getTop() + title2.getBottom() - 2 * padding) >> 1;
                float y3 = (title3.getTop() + title3.getBottom() - 2 * padding) >> 1;
                myProgressView.setStartY(y1);
                myProgressView.setCirclePositions(new float[]{y1, y2, y3});
            }
        });
        //设置进度
        myProgressView.setProgress(2);
    }
}

结尾碎碎念

能有这篇文章真的是歪打正着,实际上踩过的坑比描述的还要多,有一些碍于篇幅就没有谈到。而且实际上的开发过程完全是…歪曲且有趣?实际情况是这样的:首先接到这个任务,编写完第一版进度条,也就是对应内容标题使用测试为止的版本。此时恰好由于UI占满了剩余空间,导致我没有发现问题,误以为轻松完成了任务(笑)。之后被要求在外层添加滑动布局,此时才发现问题。于是才有了之后的研究,也就有了这篇文章。