OCNYang の 博客

Android 开发者,亦涉猎 Flutter

0%

自定义 View:用贝塞尔曲线绘制酷炫轮廓背景

ContourView

在闲逛一个图片社区时看到这张图片,个人对炫酷的东西比较敏感(视觉肤浅),本来想下载一下这个 App 看一下实际效果,可是没找到。心有不甘,于是分析了一下,感觉实现起来不会太难,自己也花点时间实现了效果,发布了一个库。

Github地址:https://github.com/OCNYang/ContourView

今天就借助这个开源控件,来为大家梳理一下自定义 View 的整个流程:

  1. 分析需求、功能,确定实现方法;
  2. 总结所需的参数属性以满足可定制性,较明确的属性归纳为自定义属性,不适合自定义属性的(比如传入数据,对象等)提供方法来设置;
  3. 有时自定义 View 会提供一种或几种默认及内置的样式,(这时可以根据内置的样式种类补充到自定义属性中),同时分析,使用内置样式或用户定制拓展时的流程;
  4. 开始根据分析,按流程依次重写: 构造函数(获取自定义属性,设置画笔等) –> onMeasure()(测量大小) –> onSizeChanged()(确定大小,一般我们在这里获取大小) –> (onLayout()自定义View,因为没有子控件,这一步是不需要的) –> onDraw()(按照需求和根据属性绘制实际内容) –> 其他
  5. 如果有事件的需求,添加事件相关逻辑。

那么现在我们就根据上面这个流程一步步来实现 ContourView。

分析

分析图

根据上面的分析,实现的思路大概都有了。那么我们就开始寻找具体实现方法。
首先,我们选用三阶贝塞尔曲线,我们都知道三阶曲线的计算公式是:

path.moveTo(start.x, start.y);
path.cubicTo(control1.x, control1.y, control2.x,control2.y, end.x, end.y);

三阶贝塞尔曲线

也就是说绘制一段曲线,我们需要知道两个锚点的坐标以及两个控制点的坐标,为了保证曲线的弯曲度能够达到理想的状态,控制点的坐标也不能是随意取的,这就要求我们必须通过一种计算方法合理的得出控制点的坐标。Google 了一下,发现先驱们已经找到了很多种方法供我们选择。

最终经过对比我们选用了这样一种方法:

控制点计算方法

这种方法大概的形式如上图,利用锚点集合,连续的4个锚点坐标Pi-1、Pi、Pi+1、Pi+2,通过具体公式来计算出中间两个锚点之间曲线的两个控制点坐标。

详细的计算方法介绍请看 ContourView 的 WiKi:
Bézier-求贝塞尔曲线控制点

归纳自定义属性

通过上面的分析,其实我们大概能总结出需要自定义的属性有哪些了。这里不着急,我们先总结一下自定义属性相关的内容和步骤?

1. 创建自定义属性文件
在 res/values/ 下新建 attrs.xml 文件(默认新建项目没有这个文件)。文件内容类似如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <attr name="custom_color" format="color"/>

    <declare-styleable name="ContourView">
        <attr name="shader_color" format="color"/>
        <attr name="smoothness" format="float"/>
    </declare-styleable>
</resources>

其中 attr 和 declare-styleable 节点分别代表的意思如下:

attr: 定义了一个属性,属性名为 custom_color 这个是可以随意起的,但是要注意不要和其他控件所冲突, format 所定义的是属性的格式,其中格式又分为好多种,下面会细说,这里定义的是颜色 color。

declare-styleable:定义了一个属性组,在里面我们可以单独写 attr 属性,也可以引用直接在 resources 下定义的 attr,其中的区别就是引用的不用写 format。

需要注意的是,attr 并不依赖与 declare-styleable,declare-styleable 只是方便了 attr 的使用,使属性的使用更加明确。两者在代码中的获取方式并不相同,下面会细说。

在实际开发中,我们一般是采用 declare-styleable 方式,直接定义一组自己所编写的自定义控件需要用到的属性。

2. 自定义属性的可以设置哪些属性

我们根据需要可以设置的自定义属性的格式一共有一下几种:

format=”格式” 说明 app:myattr=”使用值”
reference 参考某一资源ID “@drawable/图片ID”
color 颜色值 “#FFFFFFFF” or “@color/颜色ID”
boolean 布尔值 “true” or “false”
dimension 尺寸值 “0dp”
float 浮点型 “1.2”
integer 整型值 “10”
fraction 百分数值 “50%”
string 字符串 “OCN.Yang”
enum 枚举值(详见下) “自定义类型名称”
flag 位或运算 “center | bottom”

附:
enum 枚举型定义:

<attr name="handsomeBoy">
    <enum name="OCNYang" value="0x01"/>
    <enum name="TFBOYS" value="0x10"/>
</attr>

enum 使用:

app:handsomeBoy="OCNYang"

flag 定义:

<attr name="gravity">
    <flag name="top" value="0"/>
    <flag name="center" value="1"/>
    <flag name="bottom" value="2"/>
</attr>

flag 使用:

app:gravity="center|bottom"

混搭使用

<attr name="background" format="reference|color"/>

这样,你传入资源ID或颜色值都是可以的了。

3. 获取自定义属性

那怎么获取这些自定义的属性呢,只需要在自定义 View 的构造方法(两个参数或两个以上的参数)里通过一下方式就能获取到了:

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

    TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.ContourView);
    //注意:获取时自定义的属性名有变动,例如:定义名:contour_style -> 获取名:ContourView_contour_style(即:自定义属性组名_属性名)
    mStyle = typedArray.getInt(R.styleable.ContourView_contour_style, STYLE_SAND);
}

当然获取时,不同格式的属性需要通过 TypedArray 对应的不同的方法获取,那 TypedArray 都有哪些获取方法呢?如下图:

TypedArray 的方法有哪些

通过方法名称,相信你能很轻易的知道,需要哪个对应方法获取了。

如果你想更详细的了解每个方法的详细介绍,可以点击下面链接查看:
https://developer.android.com/reference/android/content/res/TypedArray.html
另外,比较特殊的 enum 的获取方法:
由于 enum 的 value 值只能设置 int 型,所以,获取enum的方式是 getInt()

好了,关于自定义属性的介绍大概就是这么多内容了,那么回到原题,我们的 ContourView 需要哪几种 自定义属性呢?其实通过分析模块中我们就基本知道我们需要的属性有哪些了:

  • 内置轮廓样式: enum 类型,内置多少个 enum 就有多少类型;
  • 绘制颜色:纯色绘制时,我们需要一个颜色值,Color 属性
  • Shader 相关:
    1. 采用哪种 Shader,enum 类型,有RadialGradient、SweepGradient、LinearGradient;
    2. Shader 的颜色,Color 类型,需要两个一个startColor,一个endColor;
    3. Shader 填充的控制,enum 类型,我们提供几种填充的方向,比如左上角到右下角,从上到下,然后我们再通过这个方向和传入的秒点集来动态计算起点和终点的坐标

具体如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ContourView">
        <attr name="shader_mode">
            <enum name="RadialGradient" value="0x01"/>
            <enum name="SweepGradient" value="0x02"/>
            <enum name="LinearGradient" value="0x03"/>
        </attr>
        <attr name="shader_startcolor" format="color"/>
        <attr name="shader_endcolor" format="color"/>
        <attr name="shader_style">
            <enum name="LeftToBottom" value="0x00"/>
            <enum name="RightToBottom" value="0x11"/>
            <enum name="TopToBottom" value="0x12"/>
            <enum name="Center" value="0x13"/>
        </attr>
        <attr name="contour_style">
            <enum name="Beach" value="0x23"/>
            <enum name="Ripples" value="0x22"/>
            <enum name="Clouds" value="0x21"/>
            <enum name="Sand" value="0x00"/>
            <enum name="Shell" value="0x25"/>
        </attr>
        <attr name="shader_color" format="color"/>
        <!--弯曲系数,在通过贝塞尔曲线绘制曲线时,来控制弯曲度-->
        <attr name="smoothness" format="float"/>
    </declare-styleable>
</resources>

内置样式

既然自定义 View,那我们一定会为它提供一种或几种内置好的样式呀。这样别人在偷懒不想自己定制样式时,可以也有不错的显示效果呀!
通过上面知道,ContourView 的轮廓样式主要是通过给出的锚点集控制的,所有的锚点围成的闭合曲线就是轮廓的大概样式了。
所以,这里我们想内置几种样式,就等于内置几个锚点集就行了,这里的我们内置的锚点坐标为了使得不同大小显示效果相同,我们先在 onSizeChanged() 获得了 View 的宽高,然后根据宽高按照百分比来设置坐标。

设置的内置轮廓有以下几种(丑爆了),只是轮廓,颜色是自己设置的:

样式(contuor_style) 效果
Sand(默认) sand
Clouds clouds
Beach beach
Ripples ripples
Shell shell

重写各方法

关于自定义 View 重写各方法的介绍,网上已经有太多太多,这里就不再啰嗦了。

这里推荐一个关于自定义 View 尤其关于绘制方面讲解特别详细的系列博客:
https://github.com/GcsSloop/AndroidNote
另外厚脸皮的放上一篇自己的关于讲解“自定义组合控件”的博客地址:
http://www.jianshu.com/p/4bbc967214c9

我们知道,在自定义 View 时,必须要有构造函数的,对于4个构造函数,有时可能大家不确定到底该重写哪个,也不知道每个构造函数有什么区别,这里对常用的做法做下说明。

//在代码中直接 new 一个 Custom View 实例时,会调用第一个构造函数.这个没有任何争议.
public View(Context context);  
//在 xml 布局文件中使用自定义 View 时,会调用第二个构造函数.这个也没有争议.
public View(Context context, AttributeSet attrs);  
//关于这个构造函数的调用,网上真是众说纷纭,我也不说哪种说法正确,下面提供详解
public View(Context context, AttributeSet attrs, int defStyle);
//4个参数的构造函数这里不做考虑

关于内部这4个构造函数是怎么调用的,这里直接放源码图片,自己一目了然:

View 源码

大家在自定义 View 时,如果没有特别的需求,只要重写前两个构造函数就可以了,我习惯性的写成下面的形式:

public class MyView extends View {

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

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

    public MyView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //初始化画笔,做一些属性的默认赋值等;
        //获取自定义的属性等;
    }
}

那,说了这么多还是没有提第3个参数到底是干什么的有什么用呀,这里我就不再为大家详细讲解了,这里找到了一片文章,讲解了第3个参数在什么时候怎么使用,大家可以看一下:

http://www.cnblogs.com/angeldevil/p/3479431.html

回归到 ContourView,其实 ContourView 内部很简单,只对 onDraw() 进行了重写,毕竟 ContourView 的主要部分就是绘制。绘制的逻辑,就是遍历锚点集,然后利用上面 WiKi 里提到的公式求出各段曲线的控制点,然后用三阶贝塞尔曲线画出路径。当遍历完锚点集时,闭合曲线的轮廓基本上就得到了,然后就用Shader对路径进行绘制就行。

好了,本次的梳理内容就到这了,感兴趣的可以查看 ContourView 的源码进行分析,同时 ContourView 的这种背景效果还是不错的,需要的时候大家真的可以用到呢!

ContourView GitHub:https://github.com/OCNYang/ContourView

如果大家想看一些高级的自定义 View 的例子可以查看上次开源的 App 的天气模块,其中的天气页面以及天气折线图等等控件都是通过自定义 ViewGroup 或自定义 View 实现的。地址是:
Qbox Github:https://github.com/OCNYang/QBox