0%

View 的工作原理(四):自定义 View

1. 自定义 View 常见的分类

  • 继承 View 重写 onDraw 方法

    • 主要用于实现一些不规则的效果,即这种效果不方便通过布局的组合方式来达到,往往需要静态或动态显示一些不规则的图形
    • 这种方式需要自己支持 wrap_content,并且 padding 也需要处理
  • 继承 ViewGroup 派生特殊的 Layout

    • 主要用于实现自定义的布局,当某种效果看起来很像几种 View 组合在一起的时候,可以采用这种方法
    • 这种方法稍微复杂一些,需要合适地处理 ViewGroup 的测量、布局这两个过程,并同时处理子元素的测量和布局过程
  • 继承特定的 View(比如 TextView)

    • 一般用于扩展已有的 View 的功能,比较常见也较容易实现
    • 不需要自己支持 wrap_content 和 padding
  • 继承特定的 ViewGroup(比如 LinearLayout)

    • 当某种效果看起来很像几种 View 组合在一起的时候,可以采用这种方法,比较常见
    • 不需要自己处理 ViewGroup 的测量和布局这两个过程(方法 2 能实现的效果方法 4 也都能实现,方法 2 更接近 View 的底层)

2. 自定义 View 常见的注意事项

  • 让 View 支持 wrap_content

    • 因为直接继承 View 或者 ViewGroup 的控件,如果不在 onMeasure 中对 wrap_content 做特殊处理,那么当外界在布局中使用 wrap_content 时就无法达到预期的效果
    • 上一篇博客条目 3.5 有说明
  • 如果有必要,让自定义的 View 支持 padding

    • 因为直接继承 View 的控件,如果不在 draw 方法中处理 padding,那么 padding 属性是无法起作用的
    • 另外,直接继承自 ViewGroup 的控件需要在 onMeasure 和 onLayout 中考虑 padding 和子元素的 margin 对其造成的影响,否则将导致 padding 和子元素的 margin 失效
  • 尽量不要在 View 中使用 Handler,没必要

    • 因为 View 内部本身就提供了 post 系列的方法,完全可以代替 Handler 的作用
    • 除非很明确需要使用 Handler 发送消息
  • View 中如果有线程或者动画,需要及时停止,参考 View#onDetachedFromWindow

    • 如果有线程或者动画需要停止时,那么 onDetachdFromWindow() 是一个很好的时机
    • 当包含此 View 的 Activity 退出或者当前 View 被 remove 时,View 的 onDetachedFromWindow() 方法会被调用。与之对应的是 onAttachdToWindow() 方法,当包含此 View 的 Activity 启动时,View 的 onAttachedToWindow() 会被调用
    • 当 View 变得不可见时也需要停止线程和动画,如果不及时处理,可能会造成内存泄漏
  • View 带有滑动嵌套情形时,需要处理好滑动冲突

3. Demo: 继承 View 重写 onDraw 方法

  1. 自定义一个控件,绘制一个圆,必须要考虑到 wrap_contentpadding。同时为了提高便捷性和复用性,实现自定义属性对外提供

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    // 实现一个具有圆形效果的自定义 View,在自己的中心点以宽高的最小值为直径绘制一个红色的实心圆
    public class CircleView extends View {

    private int mColor = Color.RED;
    private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

    public CircleView(Context context) {
    super(context);
    init();
    }

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

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

    private void init() {
    mPaint.setColor(mColor);
    }

    @Override
    protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    int width = getWidth();
    int height = getHeight();
    int radius = Math.min(width, height) / 2;
    canvas.drawCircle(width / 2, height / 2, radius, mPaint);
    }
    }
  2. 调整布局参数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <LinearLayout xmlns:android="http://schamas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#ffffff"
    android:orientation="vertical" >

    <com.szy.chapter_4.ui.CircleView
    android:id="@+id/circleView1"
    <!--android:layout_width="match_parent"-->
    android:layout_width="wrap_content"
    android:layout_height="100dp"
    android:layout_margin="20dp"
    android:padding="20dp"
    android:background="#000000" />

    </LinearLayout>
    • 运行之后发现宽度使用 wrap_content 和 使用 match_parent 没有任何区别。即:对于直接继承自 View 的控件,如果不对 wrap_content 做特殊处理,那么使用 wrap_content 就相当于使用 match_parent
    • 运行之后发现 padding 没有生效。即:直接继承自 View 和 ViewGroup 的控件,padding 是默认无法生效的,需要自己处理
    • 运行之后发现 margin 属性是生效的。即:margin 属性是由父容器控制的,可以直接生效,不需要自己做特殊处理
  3. 布局参数问题的解决方案

    • 针对 wrap_content 不生效的问题:这里只需要在 onMeasure 方法中指定一个 wrap_content 模式的默认宽高即可,比如选择 200px 作为默认的宽高

    • 针对 padding 不生效的问题:在绘制的时候考虑一下 padding 即可,修改后 onDraw 方法:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      // 思路:绘制的时候考虑到 View 四周的空白即可,其中圆心和半径都会考虑到 View 四周的 padding,做相应的调整
      protected void onDraw(Canvas canvas) {
      super.onDraw(canvas);
      final int paddingLeft = getPaddingLeft();
      final int paddingRight = getPaddingRight();
      final int paddingTop = getPaddingTop();
      final int paddingBottom = getPaddingBottom();
      int width = getWidth() - paddingLeft - paddingRight;
      int height = getHeight() - paddingTop - paddingBottom;
      int radius = Math.min(width, height) / 2;
      canvas.drawCircle(paddingLeft + width / 2, paddingTop + height / 2, radius, mPaint);
      }
  1. 为提高易用性和复用性,这里提供自定义属性(以 android 开头的属性是系统自带的属性)。添加自定义属性的步骤:

    1. 在 values 目录下创建自定义属性的 XML 文件,文件名没有限制,建议以 attrs 开头

      1
      2
      3
      4
      5
      6
      7
      // 文件名为 attrs.xml
      <?xml version="1.0" encoding="utf-8"?>
      <resources>
      <declare-styleable name="CircleView">
      <attr name="circle_color" format="color" />
      </declare-styleable>
      </resource>
    2. 在 View 的构造方法中解析自定义属性的值并做相应处理

      1
      2
      3
      4
      5
      6
      7
      public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
      super(context, attrs, defStyleAttr);
      TypeArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
      mColor = ta.getColor(R.styleable.CircleView_circle_color, Color.RED); // 如果没有指定属性值,则默认值为红色
      ta.recycle(); // 释放资源
      init();
      }
    3. 在布局文件中使用自定义的属性

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      <LinearLayout xmlns:android="http://schema.android.com/apk/res/android"
      xmlns:app="http://schema.android.com/apk/res-auto"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:background="#ffffff"
      android:orientation="vetical" >

      <com.szy.chapter_4.ui.CircleView
      android:id="@+id/circleView1"
      android:layout_width="wrap_content"
      android:layout_height="100dp"
      android:layout_margin="20dp"
      app:circle_color="@color/light_green"
      android:padding="20dp"
      android:background="#000000" />

      </LinearLayout>
      • 为了使用自定义属性,必须在布局文件中添加 schemas 声明:xmlns:app=http://schema.android.com/apk/res-autoapp 是自定义属性的前缀,可以换其他名字,但 CircleView 中的自定义属性的前缀必须和这里的一致
      • 也可以这样声明 schemas: xmlns:app=http://schema.android.com/apk/res/com.szy.chapter_4,这种方式会在 apk/res/ 后面附加应用的包名
  2. CircleView 的完整代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    // 此时的 CircleView 已经是一个很规范的自定义的 View
    public class CircleView extends View {

    private int mColor = Color.RED;
    private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

    public CircleView(Context context) {
    super(context);
    init();
    }

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

    public CircleView(Context context, AttributeSet atts, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    TypeArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
    mColor = ta.getColor(R.styleable.CircleView_circle_color, Color.RED);
    ta.recycle();
    init();
    }

    private void init() {
    mPaint.setColor(mColor);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);

    // 解决 wrap_content 的问题
    if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
    setMeasureDimension(200, 200);
    } else if (widthSpecMode == MeasureSpec.AT_MOST) {
    setMeasureDimension(200, heightSpecSize);
    } else if (heightSpecMode == MeasureSpec.AT_MOST) {
    setMeasureDimension(widthSpecSize, 200);
    }
    }

    @Override
    protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    final int paddingLeft = getPaddingLeft();
    final int paddingRight = getPaddingRight();
    final int paddingTop = getPaddingTop();
    final int paddingBottom = getPaddingBottom();

    // 解决 padding 的问题
    int width = getWidth() - paddingLeft - paddingRight;
    int height = getHeight() - paddingTop - paddingBottom;
    int radius = Math.min(width, height) / 2;
    canvas.drawCircle(paddingLeft + width / 2, paddingTop + height / 2, radius, mPaint);
    }
    }

4. Demo: 继承 ViewGroup 派生特殊的 Layout

  • 自定义一个类似于水平方向的 LinearLayout 的控件,内部的子元素可以进行水平滑动并且子元素的内部还可以进行竖直滑动。注意解决滑动冲突问题以及自身和子元素的 paddingmargin 等问题

  • HorizontalScrollViewEx 的完整代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    public class HorizontalScrollViewEx extends ViewGroup {
    private static final String TAG = "HorizontalScrollViewEx";

    private int mChildrenSize;
    private int mChildWidth;
    private int mChildIndex;

    // 分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;

    // 分别记录上次滑动的坐标(onInterceptTouchEvent)
    private int mLastXIntercept = 0;
    private int mLastYIntercept = 0;

    private Scroller mScroller;
    private VelocityTracker mVelocityTracker;

    public HorizontalScrollViewex(Context context) {
    super(context);
    init();
    }

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

    public HorizontalScrollViewEx(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    init();
    }

    private void init() {
    if (mScroller == null) {
    mScroller = new Scroller(getContext());
    mVelocityTracker = VelocityTracker.obtain();
    }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercepted = false;
    int x = (int) event.getX();
    int y = (int) event.getY();

    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
    intercepted = false;
    if (!mScroller.isFinished()) {
    mScroller.abortAnimation();
    intercepted = true;
    }
    break;
    case MotionEvent.ACTION_MOVE:
    int deltaX = x - mLastXIntercept;
    int deltaY = y - mLastYIntercept;
    if (Math.abs(deltaX) > Math.abs(deltaY)) {
    intercepted = true;
    } else {
    intercepted = false;
    }
    break;
    case MotionEvent.ACTION_UP:
    intercepted = false;
    break;
    default:
    break;
    }

    Log.d(TAG, "intercepted=" + intercepted);
    mLastX = x;
    mLastY = y;
    mLastXIntercepted = x;
    mLastYIntercepted = y;

    return intercepted;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
    mVelocityTracker.addMovement(event);
    int x = (int) event.getX();
    int y = (int) event.getY();

    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
    if (mScroller.isFinished()) {
    mScroller.abortAnimation();
    }
    break;
    case MotionEvent.ACTION_MOVE:
    int deltaX = x - mLastX;
    int deltaY = y - mLastY;
    scrollBy(-deltaX, 0);
    break;
    case MotionEvent.ACTION_UP:
    int scrollX = getScrollX();
    mVelocityTracker.computeCurrentVelocity(1000);
    float xVelocity = mVelocityTracker.getXVelocity();
    if (Math.abs(xVelocity) >= 50) {
    mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
    } else {
    mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
    }
    mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
    int dx = mChildIndex * mChildWidth - scrollX;
    smoothScrollBy(dx, 0);
    mVelocityTracker.clear();
    break;
    default:
    break;
    }

    mLastX = x;
    mLastY = y;
    return true;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int measuredWidth = 0;
    int measuredHeight = 0;
    final int childCount = getChildCount();
    measureChildren(widthMeasureSpec, heightMeasureSpec);

    int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
    int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
    int heightSpecMode = MeasureSpec.getMode(heightMeasrueSpec);
    if (childCount == 0) {
    setMeasuredDimension(0, 0); // 根据 LayoutParams 设置似乎更合理
    } else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
    final View childView = getChildAt(0);
    measuredWith = childView.getMeasuredWidth() * childCount;
    measuredHeight = childView.getMeasuredHeight();
    setMeasuredDimension(measuredWidth, measuredHeight);
    } else if (heightSpecMode == MeasureSpec.AT_MOST) {
    final View childView = getChildAt(0);
    // TODO: measuredHeight = childView.getMeasuredHeight();
    // TODO: setMeasuredDimension(widthSpecSize, childView.getMeasuredHeight());
    } else if (widthSpecMode == MeasureSpec.AT_MOST) {
    final View childView = getChildAt(0);
    measuredWidth = childView.getMeasuredWidth() * childCount;
    setMeasuredDimension(measuredWidth, heightSpecSize);
    }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childLeft = 0;
    final int childCount = getChildCount();
    mChildSize = childCount;

    for (int i = 0; i < childCount; i++) {
    final View childView = getChildAt(i);
    if (childView.getVisibility() != View.GONE) {
    final int childWidth = childView.getMeasuredWidth();
    mChilWidth = childWidth;
    childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
    childLeft += childWidth();
    }
    }
    }

    private void smoothScrollBy(int dx, int dy) {
    mSroller.startScroll(getScrollX(), 0, dx, 0, 500);
    invalidate();
    }

    @Override
    public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
    scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
    postInvalidate();
    }
    }

    @Override
    protected void onDetachedFromWindow() {
    mVelocityTracker.recycle();
    super.onDetachedFromWindow();
    }
    }
-------------------- 本文结束感谢您的阅读 --------------------