自定义view

Posted by ooftf on April 12, 2021

View

View的移动

  • 不改变布局参数(不会触发layout)
    1. scrollTo
    2. 传统动画和属性动画(translationX translationY)
  • 改变布局参数(改变LayoutParams)

平滑滑动动画

  • Scroller
  • ObjectAnimator

View 的四个构造函数

1. 一个参数构造函数

1
constructor(context: Context?) : super(context)

使用Java代码创建 View 的时候,比如

1
new TextView(context)

2. 两个参数构造函数

1
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)

inflate XML布局的时候会调用这个构造函数

1
2
3
4
5
6
7
8
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

任何形式的 xml inflate 都是调用的第二个构造函数

3. 三个参数构造函数

1
2
3
4
5
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
        context,
        attrs,
        defStyleAttr
    )

这个构造函数并不是用于 infalte xml 布局,而是被第二个构造函数调用设置 defStyleAttr

1
2
3
   public TextView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, com.android.internal.R.attr.textViewStyle);
    }
  • TextView 通过 xml 布局 inflate 时会调用 第二个构造函数,而第二个构造函数会调用第三个构造函数,设置一个默认的 style
  • 可以才动态创建 View 的形式直接调用第三个构造函数

4. 四个参数构造函数

1
2
3
4
5
6
constructor(
    context: Context?,
    attrs: AttributeSet?,
    defStyleAttr: Int,
    defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes)

用法和第三个相同

总结

  • 第一个构造函数用于 Java 代码动态创建 View
  • 所有的 xml 的 inflate 都是调用第二个构造函数
  • 第三个和第四个构造函数用于,Java 代码动态创建 View 和 第二个构造函数间接调用

View 触摸事件分发

触摸事件分发

常用手势辅助类

  • GestureDetector
  • ViewFlinger

滑动冲突

常见的滑动冲突

  • 外部滑动方向和内部滑动方向不一致 根据滑动是水平滑动还是数值华东判断到底是由谁来拦截事件,最简单的是通过水平和竖直方向移动的距离来判断
    1. 外部拦截法 重写父容器的onInterceptTouchEvent在内部做响应的拦截即可参考《Android开发探索艺术》408页 伪代码如下
      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
      
          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;
                 break;
             }
             case MotionEvent.ACTION_MOVE: {
                 if (父容器需要当前点击事件) {
                     intercepted = true;
                 } else {
                     intercepted = false;
                 }
                 break;
             }
             case MotionEvent.ACTION_UP: {
                 intercepted = false;
                 break;
             }
             default:
                 break;
               }
               mLastXIntercept = x;
               mLastYIntercept = y;
               return intercepted;
           }
      
    2. 内部拦截法 重写子元素的dispatchTouchEvent方法(思考:为什么不是onTouchEvent方法)配合requestDisallowInterceptTouchEvent 伪代码如下
      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
      
         public boolean dispatchTouchEvent(MotionEvent event) {
             int x = (int) event.getX();
             int y = (int) event.getY();
      
             switch (event.getAction()) {
             case MotionEvent.ACTION_DOWN: {
                 parent.requestDisallowInterceptTouchEvent(true);
                     break;
                 }
                 case MotionEvent.ACTION_MOVE: {
                     int deltaX = x - mLastX;
                     int deltaY = y - mLastY;
                     if (父容器需要此类点击事件)) {
                         parent.requestDisallowInterceptTouchEvent(false);
                     }
                     break;
                 }
                 case MotionEvent.ACTION_UP: {
                     break;
                 }
                 default:
                     break;
                 }
      
                 mLastX = x;
                 mLastY = y;
                 return super.dispatchTouchEvent(event);
             }
      
  • 外部滑动方向和内部滑动方向一直
  • 上面两种情况叠加嵌套

    自定 View onMeasure onLayout onDraw

    一般分为四种

    1. 继承View重写onDraw方法 采用这种方式需要自己支持wrap_content 并且padding也需要自己处理 如果由线程或者动画要及时停止,参考View.OnDetachedFromWindow
    2. 继承ViewGroup派生特殊的Layout 需要适合的处理ViewGroup的 onMeasure onLayout 这两个过程 需要考虑自己的padding和子View的margin和显隐状态 一般LinearLayout等空间是默认不开启绘画功能的,所以在onDraw是无法进行绘制的,需要调用setWillNotDraw进行设置
    3. 继承特定的View(比如TextView)
    4. 继承特定的ViewGroup (比如LinearLayout)

onMeasure

  • ViewGroup.measureChildWithMargins
    如果是自定义 ViewGroup 需要调用 measureChildWithMargins 测量 Child 的宽高,如果自定义 View 是直接继承自 ViewGroup 还需要重写 generateLayoutParams(AttributeSet attrs) 方法
  • View.setMeasuredDimension(int measuredWidth, int measuredHeight)
    onMeasure 最终需要调用 setMeasuredDimension 设置最终的宽高,而其参数 mesuredWidth 和 measuerdHeight 需要通过方法 resolveSizeAndState 来获取以添加一些额外信息
  • resolveSizeAndState(int size, int measureSpec, int childMeasuredState)
    为 measuredWidth 和 measuredHeight 添加一些额外信息,比如:《最终值比预期的小,还是比预期的大;是否发生变化》;第三个参数可以通过 child.getMeasuredState() 获取,多个child 可以通过 View.combineMeasuredStates 获取最终值

onLayout

如果是自定义 View 不需要重写这个方法

如果是 ViewGroup 需要遍历调用 child.layout 方法,计算 child 布局的时候需要考虑到:自身的 padding、child 的 margin、child.getVisibility() 等因素

onDraw

ViewGroup 默认是不会调用 onDraw 方法的,需要调用 View.setWillNotDraw(false) 让ViewGroup 调用 onDraw 方法

自定义属性(不用写了,应该都知道)

如何获取到系统属性? ———- 参考KvLayout

view.post 为什么可以获取到 View 的宽高

1
2
3
4
5
6
7
8
  public boolean View.post(Runnable action) {
        final AttachInfo attachInfo = mAttachInfo;
        if (attachInfo != null) { // attachInfo 不为 null 表示已经执行过 dispatchAttachedToWindow
            return attachInfo.mHandler.post(action);
        }
        getRunQueue().post(action);
        return true;
    }
1
2
3
4
5
6
7
8
9
10
11
//ViewRootImpl.performTraversals 可知 
//执行完 dispatchAttachedToWindow 方法之后就会执行 performMeasuer 方法,
//而在此时通过  mRunQueue.executeActions(info.mHandler); 
//发送的 Messager 肯定在 performMeasuer 之后才执行所以可以获取到 宽高
void View.dispatchAttachedToWindow(AttachInfo info, int visibility) {
    mAttachInfo = info;
    if (mRunQueue != null) {
        mRunQueue.executeActions(info.mHandler);
        mRunQueue = null;
    }
}

generateLayoutParams

相关方法有三个

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
// xml infalte 成对象的时候,获取到的 LayoutParams 就是取自父控件的 generateLayoutParams(AttributeSet attrs) 方法
// 如果自定义 View 直接继承自 ViewGroup ,当使用 measureChildWithMargins 测量子 View 的时候,会报错  ViewGroup.LayoutParam 无法转换成 MarginLayoutParams 
// 这是因为 ViewGroup.generateLayoutParams(AttributeSet attrs) 默认返回的是 ViewGroup.LayoutParam
// 因此如果你需要支持 margin 属性 generateLayoutParams(AttributeSet attrs) 需要被重写
// 具体重写方式可参考 FrameLayout
public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return //TODO;
}

// 当 checkLayoutParams 方法检查到 View 的 LayoutParams 不满足当前 ViewGroup 的要求的时候,
// 会调用 generateLayoutParams(ViewGroup.LayoutParams p) 方法将原先不和的 LayoutParams 转换成满足要求的 LayoutParams
// 因此这个方法需要配合 checkLayoutParams 一起重写
// 具体重写方式可参考 FrameLayout
protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
    return // TODO;
}

// 在调用 addView 的时候,如果添加的 View 没有 LayoutParam 或者调用 addView(View child, int width, int height)  
// 都会通过 generateDefaultLayoutParams 方法获取默认 LayoutParam
// 具体重写方式可参考 FrameLayout
protected LayoutParams generateDefaultLayoutParams() {
    return //TODO;
}

// 具体作用参考 generateLayoutParams(ViewGroup.LayoutParams p) 的说明
// 具体重写方式可参考 FrameLayout
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
    return // TODO;
}

需要注意的问题

  • 在 onDraw 方法中不要创建复杂对象,容易造成内存抖动
  • 在 onDraw 方法中不要调用 invalidate ,造成不断的向消息队列中添加消息调用 onDraw 方法,形成循环

    优秀文章

Android View绘制13问13答