自定义View实例(二)----一步一步教你实现QQ健康界面

简介: 本文已授权微信公众号:鸿洋(hongyangAndroid)在微信公众号平台原创首发。最近一直在学习自定义View相关的知识,今天给大家带来的是QQ健康界面的实现。先看效果图: 可以设置数字颜色,字体颜色,运动步数,运动排名,运动平均步数,虚线下方的蓝色指示条的长度会随着平均步数改变而进行变化。

本文已授权微信公众号:鸿洋(hongyangAndroid)在微信公众号平台原创首发。

最近一直在学习自定义View相关的知识,今天给大家带来的是QQ健康界面的实现。先看效果图:
这里写图片描述

可以设置数字颜色,字体颜色,运动步数,运动排名,运动平均步数,虚线下方的蓝色指示条的长度会随着平均步数改变而进行变化。整体效果还是和QQ运动健康界面很像的。

自定义View四部曲,一起来看看怎么实现的。

1.自定义view的属性:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    //自定义属性名,定义公共属性
    <attr name="titleSize" format="dimension"></attr>
    <attr name="titleText" format="string"></attr>
    <attr name="titleColor" format="color"></attr>
    <attr name="outCircleColor" format="color"></attr>
    <attr name="inCircleColor" format="color"></attr>
    <attr name="lineColor" format="color"></attr>
    //自定义View的属性
    <declare-styleable name="MyQQHealthView">
        <attr name="titleColor"></attr>
        <attr name="lineColor"></attr>
    </declare-styleable>
</resources>

依次定义了字体颜色,线的颜色2个属性,format是该属性的取值类型。
然后就是在布局文件中申明我们的自定义view:

  <com.example.tangyangkai.myview.MyQQHealthView
                android:id="@+id/myQQView"
                android:layout_width="match_parent"
                android:layout_height="530dp"
                android:layout_margin="15dp"
                myQQ:lineColor="@color/font_tips"
                myQQ:titleColor="@color/textcolor"
                myQQ:titleSize="50dp" />

自定义view的属性我们可以自己进行设置,记得最后要引入我们的命名空间,
xmlns:app=”http://schemas.Android.com/apk/res-auto

2.获取自定义view的属性:

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

    public MyQQHealthView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public MyQQHealthView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        //获取我们自定义的样式属性
        TypedArray array = context.getTheme().obtainStyledAttributes(attrs, R.styleable.MyQQHealthView, defStyleAttr, 0);
        int n = array.getIndexCount();
        for (int i = 0; i < n; i++) {
            int attr = array.getIndex(i);
            switch (attr) {
                case R.styleable.MyQQHealthView_titleColor:
                    // 默认颜色设置为黑色
                    textColor = array.getColor(attr, Color.BLACK);
                    break;
                case R.styleable.MyQQHealthView_lineColor:
                    lineColor = array.getColor(attr, Color.BLACK);
                    break;
            }

        }
        array.recycle();
        init();
    }

自定义View一般需要实现一下三个构造方法,这三个构造方法是一层调用一层的,属于递进关系。因此,我们只需要在最后一个构造方法中来获得View的属性了。

第一步通过theme.obtainStyledAttributes()方法获得自定义控件的主题样式数组;
第二步就是遍历每个属性来获得对应属性的值,也就是我们在xml布局文件中写的属性值;
第三步就是在循环结束之后记得调用array.recycle()来回收资源;
第四步就是进行一下必要的初始化,不建议在onDraw的过程中去实例化对象,因为这是一个频繁重复执行的过程,new是需要分配内存空间的,如果在一个频繁重复的过程中去大量地new对象会造成内存浪费的情况。

3.重写onMesure方法确定view大小:
当你没有重写onMeasure方法时候,系统调用默认的onMeasure方法:

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

这个方法的作用是:测量控件的大小。其实Android系统在加载布局的时候是由系统测量各子View的大小来告诉父View我需要占多大空间,然后父View会根据自己的大小来决定分配多大空间给子View。MeasureSpec的specMode模式一共有三种:

MeasureSpec.EXACTLY:父视图希望子视图的大小是specSize中指定的大小;一般是设置了明确的值或者是MATCH_PARENT
MeasureSpec.AT_MOST:子视图的大小最多是specSize中的大小;表示子布局限制在一个最大值内,一般为WARP_CONTENT
MeasureSpec.UNSPECIFIED:父视图不对子视图施加任何限制,子视图可以得到任意想要的大小;表示子布局想要多大就多大,很少使用。

想要设置WARP_CONTENT,只要重写onMeasure方法:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int width;
        int height;
        //如果布局里面设置的是固定值,这里取布局里面的固定值;如果设置的是match_parent,则取父布局的大小
        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        } else {

            //如果布局里面没有设置固定值,这里取布局的宽度的1/2
            width = widthSize * 1 / 2;
        }

        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        } else {
            //如果布局里面没有设置固定值,这里取布局的高度的3/4
            height = heightSize * 3 / 4;
        }
        widthBg = width;
        heightBg = height;
        setMeasuredDimension(width, height);
        startAnim();
    }

我这里为了不让布局显得过小,所以WARP_CONTENT分别取宽的1/2,高的3/4,具体情况因人而异。

4.重写onDraw方法进行绘画:
界面比较复杂,我们从上到下,一步一步来:

        //绘制最底层的背景
        radiusBg = widthBg / 20;
        pathBg.moveTo(0, heightBg);
        pathBg.lineTo(0, radiusBg);
        pathBg.quadTo(0, 0, radiusBg, 0);
        pathBg.lineTo(widthBg - radiusBg, 0);
        pathBg.quadTo(widthBg, 0, widthBg, radiusBg);
        pathBg.lineTo(widthBg, heightBg);
        pathBg.lineTo(0, heightBg);
        backgroundPaint.setColor(Color.WHITE);
        canvas.drawPath(pathBg, backgroundPaint);

整个自定义View的最底层是一个左上,右上有弧度,左下,右下为直角的白色背景。使用canvas.drawRoundRect实现的矩形是四个角都有弧度,达不到预期。但是一阶贝塞尔曲线加上二阶贝塞尔曲线就能很好的实现这种特殊的情况。
moveTo(x,y):不会进行绘制,只用于移动移动画笔,确定起点坐标,与其他方法配合使用;
lineTo(x,y):一阶贝塞尔曲线,坐标为终点坐标,配合moveTo方法用于进行直线绘制;
quadTo (x1,y1,x2,y2):二阶贝塞尔曲线,(x1,y1) 为控制点,(x2,y2)为结束点,用于绘制圆滑的曲线;
最后调用canvas.drawPath(path,paint)即可达到这种效果

        //绘制圆弧
        arcPaint.setStrokeWidth(widthBg / 20);
        //设置空心
        arcPaint.setStyle(Paint.Style.STROKE);
        //防抖动
        arcPaint.setDither(true);
        //连接处为圆弧
        arcPaint.setStrokeJoin(Paint.Join.ROUND);
        //画笔的笔触为圆角
        arcPaint.setStrokeCap(Paint.Cap.ROUND);
        arcPaint.setColor(lineColor);
        //圆弧范围
        arcRect = new RectF(widthBg * 1 / 4, widthBg * 1 / 4, widthBg * 3 / 4, widthBg * 3 / 4);
        //绘制背景大圆弧
        canvas.drawArc(arcRect, 120, 300, false, arcPaint);
        arcPaint.setColor(textColor);
        //绘制分数小圆弧
        canvas.drawArc(arcRect, 120, arcNum, false, arcPaint);

绘制圆弧先确定圆弧的范围,传入的四个参数就是圆弧所在圆的外接矩形的坐标。canvas.drawArc的五个参数依次是圆弧范围;开始的角度;圆弧的角度;第四个为True时,在绘制圆弧时会将圆心包括在内,通常用来绘制扇形,我们这里选false;圆弧的画笔

        //绘制圆圈内的数字
        textPaint.setColor(textColor);
        textPaint.setTextSize(widthBg / 10);
        canvas.drawText(String.valueOf(walkNum), widthBg * 3 / 8, widthBg * 1 / 2 + 20, textPaint);
        //绘制名次
        textPaint.setTextSize(widthBg / 15);
        canvas.drawText(String.valueOf(rankNum), widthBg * 1 / 2 - 15, widthBg * 3 / 4 + 10, textPaint);

        //绘制其他文字
        textPaint.setColor(lineColor);
        textPaint.setTextSize(widthBg / 25);
        canvas.drawText("截止13:45已走", widthBg * 3 / 8 - 10, widthBg * 5 / 12 - 10, textPaint);
        canvas.drawText("好友平均2781步", widthBg * 3 / 8 - 10, widthBg * 2 / 3 - 20, textPaint);
        canvas.drawText("第", widthBg * 1 / 2 - 50, widthBg * 3 / 4 + 10, textPaint);
        canvas.drawText("名", widthBg * 1 / 2 + 30, widthBg * 3 / 4 + 10, textPaint);

        //绘制圆圈外的文字
        canvas.drawText("最近7天", widthBg * 1 / 15, widthBg, textPaint);
        myaverageTxt = String.valueOf(averageSize);
        canvas.drawText("平均", widthBg * 10 / 15 - 15, widthBg, textPaint);
        canvas.drawText(myaverageTxt, widthBg * 11 / 15, widthBg, textPaint);
        canvas.drawText("步/天", widthBg * 12 / 15 + 20, widthBg, textPaint);

绘制文字就稍微简单点,这里计算好各自的位置即可

        //绘制虚线
        linePaint.setStyle(Paint.Style.STROKE);
        linePaint.setStrokeWidth(2);
        linePaint.setColor(lineColor);
        linePath.moveTo(widthBg * 1 / 15, widthBg + 80);
        linePath.lineTo(widthBg * 14 / 15, widthBg + 80);
        linePaint.setPathEffect(effects);
        canvas.drawPath(linePath, linePaint);

        rectSize = widthBg / 12;
        rectAgHeight = widthBg / 10;
        //绘制虚线上的圆角竖线
        for (int i = 0; i < FourActivity.sizes.size(); i++) {
            rectPaint.setStrokeWidth(widthBg / 25);
            rectPaint.setStyle(Paint.Style.STROKE);
            rectPaint.setStrokeJoin(Paint.Join.ROUND);
            rectPaint.setStrokeCap(Paint.Cap.ROUND);
            float startHeight = widthBg + 90 + rectAgHeight;
            rectPath.moveTo(rectSize, startHeight);
            double percentage = Double.valueOf(FourActivity.sizes.get(i)) / Double.valueOf(averageSize);
            double height = percentage * rectAgHeight;
            rectPath.lineTo(rectSize, (float) (startHeight - height));
            rectPaint.setColor(textColor);
            canvas.drawPath(rectPath, rectPaint);
            //绘制下方的文字
            textPaint.setColor(lineColor);
            canvas.drawText("0" + (i + 1) + "日", rectSize - 25, startHeight + 50, textPaint);
            rectSize += widthBg / 7;
        }

DashPathEffect的作用就是将Path的线段虚线化。构造函数为DashPathEffect(float[] intervals, float offset),其中intervals为虚线的ON和OFF数组,该数组的length必须大于等于2,float[0] ,float[1] 依次代表第一条实线与第一条虚线的长度,如果数组后面不再有数据则重复第一个数以此往复循环。offset为绘制时的偏移量。

DashPathEffect effects = new DashPathEffect(new float[]{5,5}, 1);

然后将这个实例作为linePaint.setPathEffect()的参数即可。绘制下方文字写的是一个简单的循环,然后竖线的长度是根据步数数组大小来进行计算的。示例图中改变平均步数以后,竖线的长度也进行了变化。

        //绘制底部波纹
        weavPaint.setColor(textColor);
        weavPath.reset();
        weavPath.moveTo(0, heightBg);
        weavPath.lineTo(0, heightBg * 10 / 12);
        weavPath.cubicTo(weavX, weavY, widthBg * 3 / 10, heightBg * 11 / 12, widthBg, heightBg * 10 / 12);
        weavPath.lineTo(widthBg, heightBg);
        weavPath.lineTo(0, heightBg);
        canvas.drawPath(weavPath, weavPaint);

        //绘制底部文字
        weavPaint.setColor(Color.WHITE);
        weavPaint.setTextSize(widthBg / 20);
        canvas.drawText("成绩不错,继续努力哟!", widthBg * 1 / 10 - 20, heightBg * 11 / 12 + 50, weavPaint);

底部水波纹的实现使用的是三阶贝塞尔曲线:
cubicTo(x1, y1, x2, y2, x3, y3):三阶贝塞尔曲线, (x1,y1) 为控制点,(x2,y2)为控制点,(x3,y3) 为结束点,用于绘制复杂的曲线。

关于重写onDraw方法,个人建议就是最好在本子上将需要完成的自定义view大致轮廓画下来,标注好坐标与位置,计算一下高宽。然后根据绘制的自定义View,从上到下,从外到内,一步一步进行绘制。真的很实用,这样逻辑清晰,层次分明,对你写代码很有帮助。

5.动画的实现:

    private void startAnim() {
        //步数动画的实现
        ValueAnimator walkAnimator = ValueAnimator.ofInt(0, mySize);
        walkAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                walkNum = (int) animation.getAnimatedValue();
                postInvalidate();
            }
        });
        //排名动画的实现
        ValueAnimator rankAnimator = ValueAnimator.ofInt(0, rank);
        rankAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                rankNum = (int) animation.getAnimatedValue();
                postInvalidate();
            }
        });

        double size = mySize;
        double avgSize = averageSize;
        if (size > avgSize) {
            size = avgSize;
        }
        //圆弧动画的实现
        ValueAnimator arcAnimator = ValueAnimator.ofFloat(0, (float) (size / avgSize * 300));
        arcAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                arcNum = (float) animation.getAnimatedValue();
                postInvalidate();
            }
        });
            //水波纹动画的实现
        ValueAnimator weavXAnimator = ValueAnimator.ofFloat(widthBg * 1 / 10, widthBg * 2/ 10);
        weavXAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                weavX = (float) animation.getAnimatedValue();
                postInvalidate();
            }
        });
        ValueAnimator weavYAnimator = ValueAnimator.ofFloat(heightBg*10/12, heightBg*11/12);
        weavYAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                weavY = (float) animation.getAnimatedValue();
                postInvalidate();
            }
        });

        animSet.setDuration(3000);
        animSet.playTogether(walkAnimator, rankAnimator, arcAnimator,weavXAnimator,weavYAnimator);
        animSet.start();
    }

这里就不得不提到属性动画的优越性了,不仅可以作用在view上,也可以作用于某个对象上。只需要设置好开始值与结束值,添加一个动画的监听,就能够得到变化的值,再使用postInvalidate()方法,从而调用onDraw方法来进行数值的改变。最后设置一个组合动画—-AnimatorSet,使五个动画达到同步一致的效果。
然后就是使用动画了,刚开始我是将这个方法写在init()初始化函数里面,一直达不到预期的效果。后来才知道,自定义View进行初始化的时候,组合动画需要的一些值:步数,排名,平均步数等还没有传递过来,所以动画无法完成。最后我是将这个方法写在onMeasure()方法的后面才达到效果,这里很感谢同事的提醒。

6.设置步数大小以及在Activity中的使用:

    public void reSet(int mysize, int myrank, int myaverageSize) {
        walkNum = 0;
        arcNum = 0;
        rankNum = 0;
        mySize = mysize;
        rank = myrank;
        averageSize = myaverageSize;
        startAnim();
    }

将设置的值通过构造方法传递过来,最后调用开启动画的方法即可。对应的Activity的代码:

public class FourActivity extends AppCompatActivity {

    private MyQQHealthView view;
    public static List<Integer> sizes = new ArrayList<>();
    private Button btn;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_four);
        initview();
    }

    private void initview() {
        view = (MyQQHealthView) findViewById(R.id.myQQView);
        view.setMySize(2345);
        view.setRank(11);
        view.setAverageSize(5436);
        sizes.add(1234);
        sizes.add(2234);
        sizes.add(4234);
        sizes.add(6234);
        sizes.add(3834);
        sizes.add(7234);
        sizes.add(5436);
        btn = (Button) findViewById(R.id.set_btn);
        btn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                view.reSet(6534, 8, 4567);
            }
        });
    }
}

自己根据情况设置想要的值就可以了。

整个流程走下来,你会发现其实自定义View并没有想象之中的那么难。多加练习,熟能生巧,相信再复杂的界面也难不住我们的。

OK,下一篇自定义View再见~

源码地址:

https://github.com/18722527635/MyCustomView

欢迎start,fork,提issues,一起进步!

目录
相关文章
|
3月前
|
存储 安全 物联网
Android经典实战之跳转到系统设置页面或其他系统应用页面大全
本文首发于公众号“AntDream”,关注获取更多技巧。文章总结了Android开发中跳转至系统设置页面的方法,包括设备信息、Wi-Fi、显示与声音设置等,并涉及应用详情与电池优化页面。通过简单的Intent动作即可实现,需注意权限与版本兼容性。每日进步,尽在“AntDream”。
332 2
|
3月前
|
API Android开发 开发者
Android经典实战之用WindowInsetsControllerCompat方便的显示和隐藏状态栏和导航栏
本文介绍 `WindowInsetsControllerCompat` 类,它是 Android 提供的一种现代化工具,用于处理窗口插入如状态栏和导航栏的显示与隐藏。此类位于 `androidx.core.view` 包中,增强了跨不同 Android 版本的兼容性。主要功能包括控制状态栏与导航栏的显示、设置系统窗口行为及调整样式。通过 Kotlin 代码示例展示了如何初始化并使用此类,以及如何设置系统栏的颜色样式。
173 2
|
6月前
|
缓存 小程序 数据可视化
【社区每周】小程序授权弹层和菜单支持长辈版、无障碍版;AMPE情景智能新增widget卡片能力(2022年6月第一期)
【社区每周】小程序授权弹层和菜单支持长辈版、无障碍版;AMPE情景智能新增widget卡片能力(2022年6月第一期)
34 0
|
6月前
|
Java 定位技术 Android开发
【Android App】利用腾讯地图获取地点信息和规划导航线路讲解及实战(附源码和演示视频 超详细必看)
【Android App】利用腾讯地图获取地点信息和规划导航线路讲解及实战(附源码和演示视频 超详细必看)
365 1
|
移动开发 前端开发 JavaScript
【H5 音乐播放实例】第一节 音乐详情页制作(1)
【H5 音乐播放实例】第一节 音乐详情页制作(1)
127 0
|
Android开发
微信公众号点击菜单出现白屏问题探究
1. 小米魅族等安卓手机白屏,苹果手机正常 2. 首次进入白屏,再次进入正常 3. 点击链接正常,点击菜单白屏
449 0
微信公众号点击菜单出现白屏问题探究
智能切换微信群活码二维码创建教程
突破群二维码入群人数限制,每200人自动换群,群二维码随时更新、智能切换,所有人通过一个群活码快速进群。
302 0
智能切换微信群活码二维码创建教程
|
SQL 缓存 前端开发
从零开始实现放置游戏(十一)——实现战斗挂机(2)注册登陆和游戏主界面
 本章主要实现注册登陆功能和游戏的主界面。有了游戏的界面,大家能有更直观的认识。   本章我们主要开发的是idlewow-game模块,其实就是游戏的客户端展示层。因为是放置游戏,为了方便,主要使用spring-mvc来开发,整个游戏形式是类似web端的文字mud游戏,会稍带一些图形图片。当然,游戏的客户端可以是多种多样的,也可以使用U3D开发成移动端或者C++/flash/silver light,开发成PC端、网页端、微端等等形式,但需要更多的美术资源。
从零开始实现放置游戏(十一)——实现战斗挂机(2)注册登陆和游戏主界面
|
Android开发 数据格式 XML
界面无小事(五):自定义TextView
界面无小事(一): RecyclerView+CardView了解一下界面无小事(二): 让RecyclerView展示更多不同视图界面无小事(三):用RecyclerView + Toolbar做个文件选择器界面无小事(四):来写个滚动选择器吧!界面...
1112 0