自定义View

自定义控件总结和思考

2018-08-28  本文已影响40人  章子_zz

前言

Android开发中自定义控件是很重要的一块内容。 自定义控件有很多类型, 这里做下总结。目的是为了以后不管看到什么炫酷的自定义控件都能有大概的实现思路。

自定义控件的分类

一、 组合控件

组合控件其实严格意义上说其实不是自定义控件,但是这里还是有必要说明一下。 组合控件是利用原始控件,通过直接设置监听事件效果来实现一些交互。 如果能用原始控件解决的问题,我们就没有必要自定义控件了不是。 组合控件大多直接写在xml布局中,通过id得到控件,做操作。
下面看一经典组合控件截图:


image.png

早期的优酷app主页下面有这样一个控件,点击小房子按钮外两圈会旋转收缩,再点击就又会出现。点击三横杠最外圈收缩 再点击就可以出现。
实现: 其实这是三个图片叠加起来的,通过图片的选择来实现的;

image.png

可以看到红色三个矩形就是三个背景图;然后把小图标布置在指定位置 。
点击时候就让指定view做旋转操作。 当然这里有一些逻辑判断。
组合控件比较简单,注意布局和监听事件处理就可以了。

二、 自定义控件

1) 继承自View的控件

继承View的控件是单独使用的。 如果放在xml中是直接用的话使用</>, 不可以在里面再添加子控件。
一般是使用Canvas来画视图的,因为没有子控件,所以不需要考虑子控件的摆放。
如果要画动态的效果,其实就是不断地调用invalidate();--> onDraw() --> Canvas绘图。
onMeasure --> onDraw

2) 继承自ViewGroup的控件

继承ViewGroup在xml中可以</>或者<>xxx</> 这样写里面可以直接填入子控件。
一般是使用Canvas来画视图的,因为有子控件,所以需要考虑子控件的摆放。
onMeasure -->onLayout --> onDraw
通过findchidviewbyid 等方法获取子控件。

3) 继承已有的控件

比如说ListView是写列表的,但是一个带上下拉刷新功能的列表怎么实现? 其实就是可以继承ListView重写其中的一些方法,让其带上下拉刷新功能。

三、根据需求分析实现思路

1) 效果图拆解

要实现某一个自定义控件,先要开始分析效果。 不管多复杂的自定义控件,先分解为静态效果和动态效果。
先去实现静态的部分, 再实现动态的部分,最后是交互部分。

2) 静态部分

自定义控件的一帧, 静态的效果用draw()方法中的Canvas画布和Paint画笔来绘制,看看Canvas能什么事情。


image.png

Canvas 可以绘制文本,图片,矩形,轨迹线等。还可以通过paint画笔调整颜色锯齿等。
任何一个自定义控件都是先实现静态效果,再去实现动态效果。
当然在这之前需要先分析自定义控件是view还是viewGroup的,两者区别的关键是看这个自定义控件中是否有子控件。

3)动态部分

动态部分的实现可以考虑使用属性动画。 属性动画的变化中动态的改变一个变量,然后去反复调用onDraw方法。 不断的绘制就会有动画效果的产生。 引用网上一个自定义控件https://juejin.im/post/5b7a90bde51d4538a01ea519

自定义.gif

4)交互部分

自定义控件的交互就是一些触摸事件的处理。 重写自定义控件的onTouchEvent方法来实现。
onTouchEvent方法中去去处理 触摸包括按下去的位置,滑动距离,抬起来的坐标等等。

四、实践

一、 继承自View的控件 先看效果:

自定义bar.gif
我们看到的右侧导航栏目就是一个继承view的自定义控件。
1)先看静态效果 是一列字母和符号。 这个是用canvas.drawText(letter, x, y, paint) 来绘制的。 再就是指定字母的位置, 这个位置需要计算 paint.getTextBounds()可以获取字体区域,根据这些计算。
2)动态效果 点击的时 字母弹出效果, 其实是canvas.drawText(letter, x, y, paint) 中x的位置发生了变化。
3) 交互效果 就是触发事件。 点击事件的触发写在public boolean onTouchEvent(MotionEvent event) {}中 ; 一般在考虑这种动效的时候,先是实现点击效果,再是实现滑动效果。
具体代码可以参考: https://github.com/zmin666/QuickIndexBar

二、 继承自ViewGroup的控件 :

继承自ViewGroup的控件需要重写onLayout 通过这个方法来摆放控件

自定义控件中:

 private void init() {
        mChild1 = (Button) getChildAt(0);
        mChild2 = (TextView) getChildAt(1);
    }

    @Override
    protected void onLayout(boolean b, int i, int i1, int i2, int i3) {
        init();
        mChild1.layout(300, 100, 700, 200);
        mChild2.layout(300, 400, 700, 500);
        requestLayout();
    }

xml布局

    <com.example.zmin.demo.MineViewGroup
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <Button
            android:id="@+id/bt_1"
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:background="@color/colorPrimaryDark"/>

        <TextView
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:background="@color/colorAccent"
            android:gravity="center"
            android:text="文本显示"
            android:textSize="20sp"/>

    </com.example.zmin.demo.MineViewGroup>

获取了子控件了 其他的方法其实和继承view的自定义控件相同。
不同的是,可以不断通过requestLayout();
来调整子View的位置 大小等。

三、 继承已有的控件 先看效果:

视察特效 girl01.gif

这个效果的实现就是继承Listview来实现的。 因为其还是一个列表,如果重写ViewGroup肯定工作量太大。继承LsitView加以改造,可以快速显示这个效果。

在列表到最上方是时候,继续下拉 头部空间变长,放手后回弹回去。

public class ParallaxListView  extends ListView {

    private ImageView iv_header;
    private int origanaldHeight;
    private int drawableHeight;


    public ParallaxListView(Context context) {
        super(context);
    }

    public ParallaxListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

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

    @Override
    protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY, int scrollRangeX, int scrollRangeY, int maxOverScrollX, int maxOverScrollY, boolean isTouchEvent) {
        //deltaY 竖直方向滑动的瞬时变化量 顶部下拉为- 底部上拉为+
        //scrollY 竖直方向滑动超出的距离 顶部为- 底部为正
        //scrollRangeY 竖直方向滑动的距离
        //maxOverScrollY 竖直方向最大的滑动位置
        //isTouchEvent 是惯性 还是触摸
        System.out.println("deltaY: " + deltaY + " scrollY: " + scrollY
                + " scrollRangeY: " + scrollRangeY + " maxOverScrollY: " + maxOverScrollY
                + " isTouchEvent: " + isTouchEvent);
        if(deltaY < 0 && isTouchEvent){
            int newHeight = iv_header.getMeasuredHeight() + Math.abs(deltaY)/3;
            if(newHeight <= drawableHeight){
                System.out.println("newHeight: " + newHeight);
                //设置Layout值
                iv_header.getLayoutParams().height = newHeight;
                //请求重摆放
                iv_header.requestLayout();
            }
        }
        return super.overScrollBy(deltaX, deltaY, scrollX, scrollY, scrollRangeX, scrollRangeY, maxOverScrollX, maxOverScrollY, isTouchEvent);
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_UP:
                //直接恢复
//                iv_header.getLayoutParams().height = origanaldHeight;
//                iv_header.requestLayout();

                // 做成动画效果  --> 属性动画
                ValueAnimator valueAnimator = ValueAnimator.ofInt(iv_header.getHeight(), origanaldHeight);
                valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                    @Override
                    public void onAnimationUpdate(ValueAnimator animation) {
                        //获取动画的分度值
                        float animatedFraction = animation.getAnimatedFraction();
                        Integer animatedValue = (Integer) animation.getAnimatedValue();
                        iv_header.getLayoutParams().height = animatedValue;
                        iv_header.requestLayout();
                    }
                });
                valueAnimator.setInterpolator(new OvershootInterpolator(4));
                valueAnimator.setDuration(500);
                valueAnimator.start();



                break;
        }
        return super.onTouchEvent(ev);
    }

    public void setParallaxImage(ImageView iv_header) {
        this.iv_header = iv_header;
        // imageView的高度
        origanaldHeight = iv_header.getMeasuredHeight();
        // imageView中的图片的高度
        drawableHeight = iv_header.getDrawable().getIntrinsicHeight();
    }

}

使用:

 plv = (ParallaxListView) findViewById(R.id.plv);
        View activity_heard = View.inflate(this, R.layout.activity_heard, null);
        final ImageView iv_header = (ImageView) activity_heard.findViewById(R.id.iv_header);
        plv.addHeaderView(activity_heard);

        //为什么对自定义控件设置成员变量需要在这里设置, 这个方法是为了填充玩整个View树的监听.然后再设置.
        //这样设置能保证在自定义控件中拿到高度
        plv.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                plv.setParallaxImage(iv_header);
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                    plv.getViewTreeObserver().removeOnGlobalLayoutListener(this);
                }
            }
        });

        plv.setAdapter(new ArrayAdapter<String>(this,android.R.layout.simple_list_item_1,Cheeses.NAMES));

静态部分: 列表+头部图片 -> 想到listview和addHeaderView方法来实现
动态部分: 头部图片可以拉伸 想到设置头部 Layout.Height的值
交互:列表在最上方,下拉的时候才触发。 ---->考虑view.getViewTreeObserver().addOnGlobalLayoutListener 来监听
拉下图片后放手 图片回弹 ---> onTouchEvent -->MotionEvent.ACTION_UP中触发

五、自定义控件中需要必备知识

1) 获取自定义控件的宽高

自定义控件方法执行顺序 Constructor->onFinishInflate->onMeasure..->onSizeChanged->onLayout->addOnGlobalLayoutListener->onWindowFocusChanged->onMeasure->onLayout 其中 onMeasure和onLayout会被多次调用.

如果自定义控件使用过程中宽高不变推荐使用
onMeasure --》 可以通过 getMeasuredHeight()和getMeasuredWidth()来获取控件的高和宽
如果自定义控件使用过程中宽高改变的 而且一开始就需要宽高信息
View.getViewTreeObserver().addOnGlobalLayoutListener(OnGlobalLayoutListener listener)的方式,因为这种方式有getViewTreeObserver().removeOnGlobalLayoutListener(this);来避免回调函数因宽高信息的变化而多次调用,如果使用其他方式的话,就要借助额外的变量来保证获取到的宽高是View的初始高度.
当然还有其他的方法获取宽高 如; onSizeChanged onLayout onWindowFocusChanged等。

2) 获取滑动点击的临界值

int s = ViewConfiguration.get(getContext()).getScaledTouchSlop();
触摸轨迹超过这个距离就是滑动, 小于这个距离就算是点击。

3) 自定义控件的移动
  1. setTranslationX 改变了view的位置,但没有改变view的LayoutParams里的margin属性;

2.layout
view在视图中的位置. 这个位置的同时指定左上角和右下角的位置. 还可以使用这个改变View的大小.

3.LayoutParams
LayoutParams要使用view的父容器的LayoutParams 然后通过设置margin值来改变view的位置.
通过这个方法设置的位置是改变想对父容器的位置. 只能在父容器中移动;

  1. scrollBy scrollTo
    一般用于自定义控件本身的移动.

  2. 调整自定义布局控件的高度 直接变高
    getLayoutParams().height = mStarHight; requestLayout();

  3. 设置padding

  4. 绘制移动
    是通过 invalidate --> ondraw() --> canvans 画出动态效果.

4)自定义控件事件传递

a.传递——dispatchTouchEvent()函数
b.拦截——onInterceptTouchEvent()函数 (只有ViewGroup才有这个方法)
c.消费——onTouchEvent()函数和 OnTouchListener(返回true,事件消费)
(1) 事件从 Activity.dispatchTouchEvent()开始传递,只要没有被停止或拦截,从最上层的 View(ViewGroup)开始一直往下(子 View)传递。子 View 可以通过 onTouchEvent()对事件进行处理。
(2) 事件由父 View(ViewGroup)传递给子 View,ViewGroup 可以通过 onInterceptTouchEvent()对事件做拦截,停止其往下传递。
(3) 如果事件从上往下传递过程中一直没有被停止,且最底层子 View 没有消费事件,事件会反向往上传递,这时父 View(ViewGroup)可以进行消费,如果还是没有被消费的话,最后会到 Activity 的 onTouchEvent()函数,从下往上的消费次序。
(4) 如果 View 没有对 ACTION_DOWN 进行消费,之后的其他事件不会传递过来。
(5) OnTouchListener 优先于 onTouchEvent()对事件进行消费。

5)事件冲突处理

onInterceptTouchEvent 返回true 就是自己拦截事件自己处理
---> onTouchEvent 返回true 就把事件处理 了.
返回false就是拦截了事件,但是自己也不处理. (子控件得不到事件)

6)自定义控件自定义属性

1.创建属性文件(res/values/attrs.xml)

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ViewZ2View">
        <attr name="circle_color" format="color" />
        <attr name="radius" format="dimension" />
        <attr name="gap" format="dimension" />
    </declare-styleable>
</resources>

name是自定义属性的名字。format是自定义属性的类型,color是颜色属性,integer是基本数据类型,除此之外还有很多,可以阅读文档或直接使用as的代码提示。
2.布局文件中添加属性

xmlns:app="http://schemas.android.com/apk/res-auto"

<com.sdwfqin.sample.view.viewz2.ViewZ2View
    ... ...
    app:circle_color="#ffffff"
    app:gap="10dp"
    app:radius="10dp" />

3.在代码中读取属性

// 加载自定义属性集合
    TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.ViewZ2View);
    // Color.WHITE为默认颜色
    mColor = typedArray.getColor(R.styleable.ViewZ2View_circle_color, Color.WHITE);
    radius = typedArray.getDimensionPixelSize(R.styleable.ViewZ2View_radius, radius);
    gap = typedArray.getDimensionPixelSize(R.styleable.ViewZ2View_gap, gap);
    typedArray.recycle();

读取属性完毕后 一定要记得recycle();因为程序在运行时维护了一个 TypedArray的池,程序调用时,会向该池中请求一个实例,用完之后,调用 recycle() 方法来释放该实例,从而使其可被其他模块复用。

六、总结思考

其实自定义控件中细节很多,这里的知识总结,只是总结分析一个自定义控件的大概实现思路。 不管一个自定义控件多么炫酷复杂,最基础的分析其实是一样的。
我写的一些自定义控件,
环形倒计时: https://github.com/zmin666/LoopView
快速索引: https://github.com/zmin666/QuickIndexBar

如果需要学习更细致的知识点,推荐学习抛物线大神的一些自定义控件教程。
https://juejin.im/post/5962a3746fb9a06ba2687226

上一篇下一篇

猜你喜欢

热点阅读