自定义View

作者: 四月一号 | 来源:发表于2016-06-14 23:45 被阅读137次

    View的继承关系

    • Android中所有控件,都是View或View的子类,比如开发中最常写的代码 findViewById() , 返回值就是 View ,然后我们会将它强转为 TextView , ImageView ...

    一张图描述 View 的继承关系:


    Paste_Image.png

    View的绘制流程

    • Android界面布局是以一棵树的结构形式展现的,而绘制出整个界面肯定是要遍历整个View树,从根节点开始,对这棵树的所有节点分别进行测量,布局和绘制.类似我们写的布局文件,一个控件下面会有一个或多个子控件,子控件下可能又有子控件...


      Paste_Image.png
    • 之前分析过View的生命周期,知道它的生命周期所对应的方法有很多,并且根据显示状态的不同,也会有所区别.实对于自定义控件,通常我们只需要关注一下三个方法:
      onMeasure()、onLayout()、onDraw() , 可以联想一下画画的步骤:

    画多大 --> 考虑怎么画 --> 开始动手画


    自定义控件的分类

    • 先说下为什么要自定义控件.
      原生控件满足不了产品的需求, 比如圆角图片,加密文本之类.
    • 自定义控件的分类(个人的分法)
      * 按实现方式分:
      1.扩展控件(继承某个具体的控件,增强它的功能,比如圆角图片)
      2.组合控件(将多个控件组合在一起,作为整体使用,提高复用性)
      3.完全自定义控件(直接继承View 或ViewGroup 自己定义规则)
      * 按类型分:
      1.自定义View(单个控件,没有子控件)
      2.自定义ViewGroup(布局容器)

    自定义控件的具体实现步骤

    控件的初始化

    • View的构造方法有以上几种重载形式
      public View(Context context) {}
      public View(Context context, AttributeSet attrs) {}
      public View(Context context, AttributeSet attrs, int defStyleAttr) {}
      还有一个带4个参数的构造方法,是API21新增的,没什么用(其实还没研究过),简单说下上面三个方法的调用时机:

      • 一个参数

        代码中初始化 比如: ImageView img = new ImageView(context);

      • 两个参数

        加载布局文件中的控件(不设置样式)

      • 三个参数

      加载布局文件中的控件(设置了样式---style)

    • 列举view的构造方法的调用时机,是因为我们需要在正确的方法中,初始化一些数据,比如获取 自定义属性 的初始值.

      • 在资源文件 res -> values -> xxxattrs.xml 中定义自定义属性
        <declare-styleable name="Myview">

        <attr name="textSize" format="dimension"></attr>

        <attr name="abc" format="reference"></attr>

        <attr name="textColor" format="color"></attr>
        </declare-styleable>
        自定义属性还有其它的类型,不一一列举

      • 在构造方法中获取自定义属性的值
        // 根据字面意思理解(类型的数组),就是获取含有对应自定义属性的对象
        TypedArray t = context.obtainStyledAttributes(attrs,R.styleable.Myview);
        int textColor = a.getColor(R.styleable.Myview_textColor, 0xFFFFFFFF);
        ...
        // 获取完所有属性后,必须调用一次,释放资源
        t.recycle();

      • 简单分析下上面获取自定义属性的方法
        public TypedArray obtainStyledAttributes(AttributeSet set,
        int[] attrs, int defStyleAttr, int defStyleRes) {}

        • defStyleRes : 字面意思理解 --- 默认的样式资源,当我们在style.xml里定义一个样式,并设置一些属性的值,如果在获取属性的时候,传入了这个样式,那么对于缺省的值,会从该样式中去取;
        • defStyleAttr : (下面这段话比较绕口... 99%没什么用,写在这里凑字数)
          1.自定义属性里指定一条引用类型的属性
          2.给application的主题添加该引用,指定某个样式
          3.如果传入了这个引用,那么,指定的样式会作为缺省值
        • 优先级 : defStyleAttr > defStyleRes

    View的测量 -measure

    • 可以想象,view没有子控件,所以只管测量自己,而viewgroup还需要考虑子控件.面也说过,view是所有控件的基类,所以,考虑这个问题最好的切入点就是 view 的测量方法:
      public final void measure(int widthMeasureSpec, int heightMeasureSpec)
      public final,该方法不能被重写,可以外部调用,简单的追踪下源码,发现是在 viewgroup 中被调用 :
      protected void measureChild(View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec) {
      final LayoutParams lp = child.getLayoutParams();
      final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, mPaddingLeft + mPaddingRight, lp.width);
      final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec, mPaddingTop + mPaddingBottom, lp.height);
      child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
      }

         ...
      
         protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,  int parentHeightMeasureSpec, int heightUsed) {    
              final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();   
              final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin + widthUsed, lp.width);    
              final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec, mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin+ heightUsed, lp.height);    
              child.measure(childWidthMeasureSpec, childHeightMeasureSpec);}
      

      两个方法的作用一样,都是用于测量子控件,一个考虑子控件的外边距,一个没有.从其中一个着手简单分析.
      view 的 measure 所需的参数通过 getChildMeasureSpec 获得
      /**
      *
      * 目标是将父控件的测量规格和child view的布局参数LayoutParams相结合,得到一个
      * 最可能符合条件的child view的测量规格。

       * @param spec 父控件的测量规格
       * @param padding 父控件里已经占用的大小
       * @param childDimension child view布局LayoutParams里的尺寸
       * @return child view 的测量规格
       */
      public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
        int specMode = MeasureSpec.getMode(spec); //父控件的测量模式
        int specSize = MeasureSpec.getSize(spec); //父控件的测量大小
      
        int size = Math.max(0, specSize - padding);
      
        int resultSize = 0;
        int resultMode = 0;
      
        switch (specMode) {
        // 当父控件的测量模式 是 精确模式,也就是有精确的尺寸了
        case MeasureSpec.EXACTLY:
            //如果child的布局参数有固定值,比如"layout_width" = "100dp"
            //那么显然child的测量规格也可以确定下来了,测量大小就是100dp,测量模式也是EXACTLY
            if (childDimension >= 0) {
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } 
      
            //如果child的布局参数是"match_parent",也就是想要占满父控件
            //而此时父控件是精确模式,也就是能确定自己的尺寸了,那child也能确定自己大小了
            else if (childDimension == LayoutParams.MATCH_PARENT) {//-1
                resultSize = size; // 父控件尺寸减去内边距
                resultMode = MeasureSpec.EXACTLY;
            }
            //如果child的布局参数是"wrap_content",也就是想要根据自己的逻辑决定自己大小,
            //比如TextView根据设置的字符串大小来决定自己的大小
            //那就自己决定呗,不过你的大小肯定不能大于父控件的大小嘛
            //所以测量模式就是AT_MOST,测量大小就是父控件的size
            else if (childDimension == LayoutParams.WRAP_CONTENT) {//-2
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;
      
        // 当父控件的测量模式 是 最大模式,也就是说父控件自己还不知道自己的尺寸,但是大小不能超过size
        case MeasureSpec.AT_MOST:
            //同样的,既然child能确定自己大小,尽管父控件自己还不知道自己大小,也优先满足孩子的需求
            if (childDimension >= 0) {
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } 
            //child想要和父控件一样大,但父控件自己也不确定自己大小,所以child也无法确定自己大小
            //但同样的,child的尺寸上限也是父控件的尺寸上限size
            else if (childDimension == LayoutParams.MATCH_PARENT) {
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            //child想要根据自己逻辑决定大小,那就自己决定呗
            else if (childDimension == LayoutParams.WRAP_CONTENT) {
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;
      
        // Parent asked to see how big we want to be
        case MeasureSpec.UNSPECIFIED:
            if (childDimension >= 0) {
                // Child wants a specific size... let him have it
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size... find out how big it should
                // be
                resultSize = 0;
                resultMode = MeasureSpec.UNSPECIFIED;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size.... find out how
                // big it should be
                resultSize = 0;
                resultMode = MeasureSpec.UNSPECIFIED;
            }
            break;
        }
        return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
      }
      
      • 通过前面的源码分析,可以知道 viewgroup在调用 view的 measure方法时,传入的参数不仅仅是单纯的宽度和高度,而是 柔和了尺寸和模式的综合值.
    • MeasureSpec通常翻译为”测量规格”,它是一个32位的int数据.
      其中高2位代表SpecMode即某种测量模式,低30位为SpecSize代表在该模式下的规格大小

    • 可以通过如下方式分别获取这两个值:
      获取SpecSize
      int specSize = MeasureSpec.getSize(measureSpec)
      获取specMode
      int specMode = MeasureSpec.getMode(measureSpec)

    • 三种测量模式
      1.AT_MOST ---> wrap_content
      2.EXACTLY ---> 具体的值/math_parent
      3.UNSPECIFIED ---> listview ,scrollview 等控件才可能,不需要关心

    前面说了那么多,其实值说明了一个问题:View的测量规格是由父控件的测量规格和自身的LayoutParams共同决定的

    Paste_Image.png
    • 回归主题 onMeasure
      // 设置测量的宽高
      protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
      setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
      getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
      }
      ...
      // 获取最小宽度
      protected int getSuggestedMinimumWidth() {
      return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
      }
      ...
      // 计算尺寸
      public static int getDefaultSize(int size, int measureSpec) {
      int result = size;
      int specMode = MeasureSpec.getMode(measureSpec);
      int specSize = MeasureSpec.getSize(measureSpec);

        switch (specMode) {
        case MeasureSpec.UNSPECIFIED:
            result = size;//这里的size就是上面getSuggestedMinimumWidth/height的返回值
            break;
        case MeasureSpec.AT_MOST:
        case MeasureSpec.EXACTLY:
            result = specSize;//测量规格里的尺寸
            break;
        }
        return result;
      }
      

    在计算子控件的尺寸时,不管父View的specMode是MeasureSpec.AT_MOST还是MeasureSpec.EXACTLY对于子View而言系统给它设置的specMode都是MeasureSpec.AT_MOST,并且其大小都是parentLeftSize即父View目前剩余的可用空间。这时wrap_content就失去了原本的意义,变成了match_parent一样了.
    所以自定义控件默认不支持 wrap_content
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec , heightMeasureSpec);
    int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSpceSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSpecMode=MeasureSpec.getMode(heightMeasureSpec);
    int heightSpceSize=MeasureSpec.getSize(heightMeasureSpec);
    int wrapWidth,wrapHight;//根据逻辑计算自己的尺寸
    if(widthSpecMode==MeasureSpec.AT_MOST&&heightSpecMode==MeasureSpec.AT_MOST){
    setMeasuredDimension(mWidth, mHeight);
    }else if(widthSpecMode==MeasureSpec.AT_MOST){
    setMeasuredDimension(mWidth, heightSpceSize);
    }else if(heightSpecMode==MeasureSpec.AT_MOST){
    setMeasuredDimension(widthSpceSize, mHeight);
    }
    }


    ViewGroup 的测量

    • 可以分为两步
      • 测量所有子控件 (调用API即可)
      • 测量自己. 不同的控件有不同的测量规则,很显然,LinearLayout与RelativeLayout肯定就不一样,所以,这个就需要根据实际情况自己去处理了

    View的 layout

    //l, t, r, b分别表示子View相对于父View的左、上、右、下的坐标
     public void layout(int l, int t, int r, int b) {
         if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) { 
              onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec); 
              mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT; 
          } 
          int oldL = mLeft; 
          int oldT = mTop; 
          int oldB = mBottom; 
          int oldR = mRight; 
          boolean changed = isLayoutModeOptical(mParent) ? setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b); 
          if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) { 
              onLayout(changed, l, t, r, b); 
              mPrivateFlags &= ~PFLAG_LAYOUT_REQUIRED;         
              ListenerInfo li = mListenerInfo; 
              if (li != null && li.mOnLayoutChangeListeners != null) { 
                  ArrayList<OnLayoutChangeListener> listenersCopy = (ArrayList<OnLayoutChangeListener>)li.mOnLayoutChangeListeners.clone(); 
                  int numListeners = listenersCopy.size(); 
                     for (int i = 0; i < numListeners; ++i) { 
                         listenersCopy.get(i).onLayoutChange(this,l,t,r,b,oldL,oldT,oldR,oldB); 
                     }
               }
           } 
           mPrivateFlags &= ~PFLAG_FORCE_LAYOUT; 
           mPrivateFlags3 |= PFLAG3_IS_LAID_OUT; 
      }
    

    ...代码没看懂,引用一段网上的解释

    确定该View在其父View中的位置,把l,t, r, b分别与之前的mLeft,mTop,mRight,mBottom一一作比较,假若其中任意一个值发生了变化,那么就判定该View的位置发生了变化 ,若View的位置发生了变化则调用onLayout()方法

    前面说过 onLayout 才是自定义控件时需要关注的方法

    /** 
      * Called from layout when this view should 
      * assign a size and position to each of its children. 
      * 
      * Derived classes with children should override 
      * this method and call layout on each of 
      * their children. 
      * @param changed This is a new size or position for this view 
      * @param left Left position, relative to parent 
      * @param top Top position, relative to parent 
      * @param right Right position, relative to parent 
      * @param bottom Bottom position, relative to parent 
      */
      protected void onLayout(boolean changed, int left, int top, int right, int bottom) {}
    

    这是个空方法(为什么是空方法?)

    Called from layout when this view should assign a size and position to each of its children.
    在layout方法中调用该方法,用于指定子View的大小和位置。

    我们知道,只有ViewGroup才有子控件,也就是说, 自定义View是不需要考虑该方法的.再看看 该方法在ViewGroup中的实现

    protected abstract void onLayout(boolean changed,int l, int t, int r, int b);
    

    是个抽象方法,其实很好理解,因为每种布局都有其自己的逻辑,比如:FrameLayou,LinearLayout,RelativeLayout等对 onLayout的实现肯定就不一样.进一步说,自定义ViewGroup时,系统肯定是不知道我们想要什么样的逻辑,必须要自己根据需求去实现.

    简单的模仿一个垂直方向线性布局的需求

    @Override 
    protected void onLayout(boolean changed, int l, int t, int r, int b) { 
          int childCount=getChildCount(); 
          int height = 0;
          if(childCount>0){ 
              View child=getChildAt(i);
               int childHeight=child.getMeasuredHeight(); 
               if(child.getVisibility != View.GONE){
                   child.layout(l,height,r,height += childHeight); 
               }
          }
     }
    

    大部分时候,如果可能,尽量避免直接继承ViewGroup,而是继承LinearLayout,RelativeLayout等系统已有的布局来简化这些步骤。


    View 的绘制 -draw

    draw()的源码就不看了(实在太长了),我们真正关注的是onDraw()方法

    /** 
     * Implement this to do your drawing. 
     * 需要由具体的子View去实现各自不同的需求
     * @param canvas the canvas on which the background will be drawn 
     */
     protected void onDraw(Canvas canvas) {}
    

    结论:
    一般来说,自定义ViewGroup不需要实现该方法(布局容器,更关注的应该是子控件的测量和摆放);
    自定义View 则需要根据具体的需求实现该方法.

    • 我们看到 onDraw()方法有一个参数 Canvas ,顾名思义,就是画布的意思.像我们平时画图一样,需要两个工具,纸和笔。有了画布,当然还要有画笔才行.
      //创建画笔
      Paint paint=new Paint();
      paint.setAntiAlias(true);//抗锯齿功能
      paint.setColor(Color.RED); //设置画笔颜色
      paint.setStyle(Style.FILL);//设置填充样 Style.FILL/Style.FILL_AND_STROKE/Style.STROKE
      paint.setStrokeWidth(5);//设置画笔宽度
      paint.setShadowLayer(10, 15, 15, Color.GREEN);//设置阴影
      paint.setTextAlign(Align.CENTER);//设置文字对齐方式,取值:align.CENTER、align.LEFT或align.RIGHT
      paint.setTextSize(12);//设置文字大小

        //样式设置  
        paint.setFakeBoldText(true);//设置是否为粗体文字  
        paint.setUnderlineText(true);//设置下划线  
        paint.setTextSkewX((float) -0.25);//设置字体水平倾斜度,普通斜体字是-0.25  
        paint.setStrikeThruText(true);//设置带有删除线效果   
      

    关于使用画布和画笔绘制图形,API很多,不一一列举,只介绍一些常用的

     // 画背景色
     canvas.drawColor(Color.BLUE);
     canvas.drawRGB(255, 255, 0);   
     // 画点
     void drawPoint (float x, float y, Paint paint)
     void drawPoints (float[] pts, Paint paint)
     void drawPoints (float[] pts, int offset, int count, Paint paint)
     // 画线
     void drawLine (float startX, float startY, float stopX, float stopY, Paint paint)
     void drawLines (float[] pts, Paint paint)
     void drawLines (float[] pts, int offset, int count, Paint paint)
     // 画矩形
     void drawRect (float left, float top, float right, float bottom, Paint paint)
     void drawRect (RectF rect, Paint paint)
     void drawRect (Rect r, Paint paint)
     // 圆角矩形
     void drawRoundRect (RectF rect, float rx, float ry, Paint paint)
     // 圆形
     void drawCircle (float cx, float cy, float radius, Paint paint)
     // 椭圆
     void drawOval (RectF oval, Paint paint)
     // 文字
     void drawText (String text, float x, float y, Paint paint)
     void drawText (CharSequence text, int start, int end, float x, float y, Paint paint)
     void drawText (String text, int start, int end, float x, float y, Paint paint)
     void drawText (char[] text, int index, int count, float x, float y, Paint paint)
    

    关于控件的绘制,这里只列举了一些简单的,常用的API,实际上控件的绘制非常复杂,涉及到的知识点也很多,推荐一个大神的博客,非常值得学习.
    http://blog.csdn.net/harvic880925/article/details/50995268

    关于自定义控件的主要流程总结下来就是 :

    初始化 --> onMeasure --> onLayout --> onDraw
    

    关于自定义控件还有更多的东西需要学习:控件的滑动,事件的分发...

    相关文章

      网友评论

      本文标题:自定义View

      本文链接:https://www.haomeiwen.com/subject/wzjudttx.html