View的基础知识

View的定义

View是Android中所有控件的基类,View是一种界面层的控件的一种抽象,它代表了一个控件。除了View还有ViewGroup,可以理解成控件组,ViewGroup也继承自View,所以View可以是单个控件也可以是多个控件组成的控件组,通过这种关系就形成了View树的结构

View的位置参数

  • View的位置主要由四个顶点决定:top(左上角纵坐标),bottom(右下角纵坐标),right(右下角横坐标),left(左上角横坐标)

  • 获取四个参数的方法:

    1
    2
    3
    4
    mLeft=getLeft();
    mRight=getRight();
    mTop=getTop();
    mBottom=getBottom();
  • Android3.0之后新引入的参数:x和y(View左上角的坐标),translationX和translationY(View左上角相对于父容器的偏移量,默认值为0),这几个参数也都是相对于父容器的坐标,这几个参数的换算关系如下:

    1
    2
    x=left+translationX
    y=top+translationY

    View发生平移时top,left表示原始左上角的坐标信息不改变,发生改变的是x,y,translationX,translationY这四个参数

MotionEvent和TouchSlop

  1. MotionEvent

    • 典型事件类型:

      1
      2
      3
      ACTION_DOWN//手指刚刚接触屏幕
      ACTION_MOVE//手指在屏幕上移动
      ACTION_UP//手指从屏幕上松开的一瞬间
    • 正常情况下,一次手指触摸屏幕的行为会触发一系列点击事件,考虑如下几种情况:

      点击屏幕后离开松开,事件序列为DOWN -> UP;

      点击屏幕滑动一会再松开,事件序列为DOWN -> MOVE-> …> MOVE-> UP

    • 通过MotionEvent对象也可以获得点击事件发生的x和y坐标,提供了两组方法:getX/getY(相对于当前View左上角的x,y坐标)和getRawX/getRawY(相对于手机屏幕左上角的x,y坐标)

  2. TouchSlop

    • TouchSlop是系统所能识别出的被认为滑动的最小距离,这是一个常量和设备有关,当手指滑动距离小于这个常量,那么系统不认为它是滑动,可以通过ViewConfiguration.get(getContext()).getScaledTouchSlop()方法获取这个常量
    • 这个常量的意义:当我们在处理滑动时可以利用这个常量做一些过滤

VelocityTracker,GestureDetector和Scroller

  1. VelocityTracker

    • 速度追踪,用于追踪手指滑动过程中的速度,包括水平和竖直方向上的速度,使用过程简单,首先在View的onTouchEvent方法中追踪当前点击事件的速度:

      1
      2
      VelocityTracker velocityTracker=VelocityTracker.obtain();//获取(或复用)一个速度追踪器实例。
      velocityTracker.addMovement(event);//将当前的手势事件(坐标、时间)存入追踪器。
    • velocityTracker.computercurrentVelocity(1000);//计算当前的速度,参数单位是ms
      int xVelocity=(int) velocityTracker.getXVelocity();//获取水平方向(X轴)的速度值。
      int yVelocity=(int) velocityTracker.getYVelocity();//获取垂直方向(Y轴)的速度值。
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10

      注意:(1)一定要先计算速度

      (2)这里的速度指一段时间内手指所滑过的像素数,速度可以为负值,公式为:速度=(终点位置-起点位置)/ 时间段

      - 最后不使用时需要回收

      ```java
      velocityTracker.clear();
      velocityTracker.recycle();

2.GestureDetector

  • 手势检测,用于辅助检测用户的单击,滑动,长按,双击等行为。

  • 使用GestureDetector过程:

    1. 先创建一个GestureDetector对象并实现OnGestureListener接口,根据我们需要还可以实现OnDoubleTapListener从而能够监听双击行为

      1
      2
      3
      GestureDetector mGestureDetector=new GestureDetector(this);
      //解决长按屏幕后无法拖动的现象
      mGestureDetector.setIsLongpressEnabled(false);
    2. 接着,接管目标View的onTouchEvent方法,在待监听View的onTouchEvent方法中添加如下实现:

      1
      2
      boolean consume=mGestureDetector.onTouchEvent(event);
      return consume;

    3.做完以上两步就可以有选择的实现两个接口中的方法了:

  • 日常开发中常用的方法:onSingleTapUp(单击),onFling(快速滑动),onScroll(拖动),onLongPress(长按)和onDoubleTap(双击)。

3.Scroller

  • 弹性滑动对象,用于实现View的弹性滑动。他需要和View的computeScroll方法配合使用才能完成这个功能,使用Scroller的典型代码是固定的如下所示:

    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
    class View @JvmOverloads constructor(context: Context,attrs: AttributeSet): View(context,attrs) {
    //获取Scroller对象
    private val mScroller: Scroller= Scroller(context)
    //下达命令,定义如何移动的指令
    fun smoothScrollTo(destX: Int, destY: Int){
    //获得当前位置
    val scrollX = scrollX
    //需要滑动的净距离(目标位置-当前位置)
    val delta=destX-scrollX
    //启动滚动计算器(起始X,起始Y,偏移量X,偏移量Y,持续时间)
    //1000ms内滑向destX,效果就是慢慢滑动
    mScroller.startScroll(scrollX,0,delta,0,1000)
    //触发onDraw
    invalidate()
    }
    //执行指令,核心驱动引擎
    override fun computeScroll() {
    super.computeScroll()
    //判断动画是否还在进行
    if (mScroller.computeScrollOffset()){
    scrollTo(mScroller.currX,mScroller.currY)
    postInvalidate()
    }
    }
    }

View的滑动

使用ScrollTo/ScrollBy

  • ScrollTo是绝对移动,会直接移动到坐标点。ScrollBy是相对移动,是指相对于当前位置再挪动多远
  • ScrollTo/ScrollBy只能改变View内部内容的位置而不可以改变View在布局中的位置
  • **mScrollX/mScrollY(当前位置)的正负:**当View左边缘在View内容左边缘的右侧时mScrollX为正,当View上边缘在view内容上边缘的下侧时mScrollY为正

使用动画

  • 既可以采用传统的View动画又可以采用属性动画,如果采用属性动画,为了可以兼容3.0以下的版本需要采用开源动画库nineoldandroids(http://nineoldandroids.com/)

  • 传统动画使用案例

    1. 在res文件夹下创建anim文件夹

    2. 在里面创建xml文件并定义动画

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      <?xml version="1.0" encoding="utf-8"?>
      <set xmlns:android="http://schemas.android.com/apk/res/android"
      android:fillAfter="true"//它表示动画结束后View 是否停留在结束时的状态
      android:zAdjustment="normal"//控制动画执行时 View Z 层级上的表现。>
      <translate
      android:duration="100"
      android:fromXDelta="0"
      android:fromYDelta="0"
      android:interpolator="@android:anim/linear_interpolator"//它决定了动画运动的速度曲线
      android:toXDelta="100"
      android:toYDelta="100"/>
      </set>
    3. 调用

      1
      2
      3
      4
      5
      6
      7
      binding.btn.setOnClickListener {
      // 加载你刚才写的那个 XML 动画文件
      val animation = AnimationUtils.loadAnimation(requireContext(), R.anim.translate_test)

      // 让 View 开始执行这个动画
      binding.decompressionBubble.startAnimation(animation)
      }
  • 属性动画使用案例

    直接调用

    1
    2
    3
    4
    5
    6
    binding.btn.setOnClickListener {
    ObjectAnimator.ofFloat(binding.decompressionBubble, "translationX", 0f, 100f).apply {
    duration = 100
    start()
    }
    }

改变参数布局

  • 改变LayoutParams或者在View左边放一个空的View宽为0需要向右移动重新设置宽度即可

  • 设置LayoutParams步骤

    1
    2
    3
    4
    5
    MarginLayoutParams params=(MarginLayoutParams)mButton.getLayoutParams();
    params.width+=100;
    params.leftMargin+=100;
    mButton.requestLayout();
    //或者mButton.setLayoutParams(params);

各种滑动方式对比

scrollTo/ScrollBy 操作简单,适合对View内容的滑动
动画 操作简单,主要适用于没有交互的View和实现复杂的动画效果
改变布局参数 操作稍微复杂,适用于有交互的View
  • 跟手滑动的实例

    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
    override fun onTouchEvent(event: MotionEvent?): Boolean {
    // 1. 安全校验:如果 event 为空直接返回
    if (event == null) return false

    val x = event.rawX.toInt()
    val y = event.rawY.toInt()

    when (event.action) {
    MotionEvent.ACTION_DOWN -> {
    // 记录初始点
    mLastX = x
    mLastY = y
    }
    MotionEvent.ACTION_MOVE -> {
    // 计算偏移
    val deltaX = x - mLastX
    val deltaY = y - mLastY

    // 执行位移
    translationX += deltaX
    translationY += deltaY

    // 关键:实时更新上一次的坐标
    mLastX = x
    mLastY = y
    }
    }
    // 2. 必须返回 true,否则 ACTION_MOVE 和 ACTION_UP 不会被触发
    return true
    }

弹性滑动

使用Scroller

  • Scroller本身并不能实现View的滑动,他需要配合View的computeScroll方法才能完成弹性滑动的效果,他不断的让View重绘,而每次重绘据滑动起始时间有一个时间间隔,通过这个时间间隔Scroller就可以得出View当前的滑动位置,知道了滑动位置就可以使用scrollTo方法来完成滑动。就这样View每一次重绘都会造成小幅度滑动,而多次小幅度滑动就组成了弹性滑动,这就是Scroller工作机制

使用动画

  • 动画本身就是一个渐进过程,因此通过它来实现的滑动天然就具有弹性效果

  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    //实现平滑移动
    private fun startBackAnimation() {
    // 初始位置和目标偏移
    val startX = 0
    val deltaX = 100

    //初始化动画器,创建一个数值发生器,让数值在指定时间内从 0.0 平滑变化到 1.0。
    val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000)
    //监听动画每一帧的变化
    animator.addUpdateListener { animation ->
    // 获取当前动画完成的比例 (0.0 ~ 1.0)
    val fraction = animation.animatedFraction

    //计算当前位置并强转为 Int
    // 公式:当前位置 = 开始位置 + (总距离 * 比例)
    val scrollX = (startX + (deltaX * fraction)).toInt()

    //执行滚动
    // 注意:scrollTo 移动的是内容,方向与 translation 往往相反
    this.scrollTo(scrollX, 0)
    }

    animator.start()
    }

    这里是View内容的滑动而非View本身

使用延时策略

  • 它的核心思想是通过发送一些延时消息从而达到渐进式的效果,具体来说可以使用Handler或者View的postDelayed方法,也可以使用线程的sleep方法。对于postDelayed来说我们可以通过他来延时发送一个消息,然后在消息中来进行View的滑动,如果接连不断地发送这种延时消息就可以实现弹性滑动的效果。对于sleep来说,通过While循环不断地滑动View和sleep,就可以实现这种效果

View的事件分发机制

点击事件的传递规则

核心方法详解

  • dispatchTouchEvent

    • 作用:分发触摸事件,是事件分发的核心方法。

    • 返回值的含义:

      1. true:事件被消费,不会继续传递
      2. false:事件未被消费,继续向上传递
    • 在不同组件中的行为:

      1. Activity.dispatchTouchEvent:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        // 如果事件被消费,返回 true
        if (window.superDispatchTouchEvent(ev)) {
        return true
        }
        // 否则调用自己的 onTouchEvent
        return onTouchEvent(ev)
        }

      2. ViewGroup.dispatchTouchEvent:

        • 首先调用 onInterceptTouchEvent 判断是否拦截
        • 如果拦截,自己处理(调用 onTouchEvent
        • 如果不拦截,分发给子 View
      3. View.dispatchTouchEvent:

        • 如果有 OnTouchListener,先调用它的 onTouch
        • 如果 onTouch 返回 true,不再继续
        • 否则调用 onTouchEvent
  • onInterceptTouchEvent

    • 作用:ViewGroup 专用的方法,用于判断是否拦截事件。

    • 返回值:

      1. true:拦截事件,事件由当前 ViewGroup 处理,不再分发给子 View
      2. false:不拦截,事件继续传递给子 View
    • 重要特性:

      1. 只在 ACTION_DOWN 时调用一次,后续事件会自动按照 DOWN 时的决定处理
      2. 可以通过 requestDisallowInterceptTouchEvent 让子 View 阻止父 View 拦截
    • 实现事例:

      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
      class CustomViewGroup @JvmOverloads constructor(
      context: Context,
      attrs: AttributeSet? = null
      ) : ViewGroup(context, attrs) {

      private var intercepting = false

      override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
      when (ev?.action) {
      MotionEvent.ACTION_DOWN -> {
      intercepting = false
      // 重置拦截状态
      }
      MotionEvent.ACTION_MOVE -> {
      // 根据滑动距离判断是否拦截
      val dx = Math.abs(ev.x - initialX)
      val dy = Math.abs(ev.y - initialY)
      if (dx > touchSlop || dy > touchSlop) {
      intercepting = true // 拦截滑动事件
      }
      }
      }
      return intercepting
      }
      }

  • onTouchEvent

    • 作用:处理触摸事件,是事件处理的最终方法。

    • 返回值:

      1. true:事件被处理,事件流结束
      2. false:事件未被处理,事件向上传递
    • 事件处理顺序:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      override fun onTouchEvent(event: MotionEvent?): Boolean {
      // 1. 如果设置了 OnTouchListener,优先调用
      if (onTouchListener != null && isEnabled &&
      onTouchListener!!.onTouch(this, event)) {
      return true
      }

      // 2. 否则调用 onTouchEvent
      return super.onTouchEvent(event)
      }

    • View.onTouchEvent 的默认行为:

      1. ACTION_DOWN:返回 true(表示愿意处理后续事件)
      2. ACTION_MOVE:处理滑动
      3. ACTION_UP:触发点击事件(如果设置了 OnClickListener)
  • 完整事件分发流程图

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    用户触摸屏幕

    Activity.dispatchTouchEvent

    Window(DecorView).dispatchTouchEvent

    ViewGroup.dispatchTouchEvent

    [调用 onInterceptTouchEvent 判断是否拦截]

    是拦截? → 是 → ViewGroup.onTouchEvent
    ↓ ↓
    否 返回 true/false

    ViewGroup 遍历子 View

    子 View.dispatchTouchEvent

    子 View.onTouchEvent

    返回 true/false,。。。。

View的滑动冲突

常见的滑动冲突场景

  • 场景一:外部滑动方向和内部滑动方向不一致
    1. 典型场景:ViewPager + ListView
    2. ViewPager 水平滑动,ListView 垂直滑动
    3. 冲突原因:无法判断用户意图
  • 场景二:外部滑动方向和内部滑动方向一致
    1. 典型场景:ScrollView + ListView
    2. 两者都是垂直滑动
    3. 冲突原因:都想要处理滑动事件
  • 场景三:上面两种情况的嵌套
    1. 典型场景:外层 ScrollView + 内层 ScrollView
    2. 多层嵌套的滑动容器
    3. 冲突原因:多个 ViewGroup 都想拦截

滑动冲突的处理规则

  • 处理场景一:

    1. 当用户左右滑动时让外部的view拦截点击事件,用户上下滑动时内部View拦截点击事件
    2. 可以通过水平和竖直方向距离差来判断水平滑动还是竖直滑动,如果竖直方向滑动的距离大就是竖直滑动
  • 处理场景二:

    1. 场景二比较特殊一般能在业务上找到突破点,比如说业务上有规定:当处于某种状态时需要外部View响应用户滑动,当处于另一种状态时需要内部View来响应滑动
  • 处理场景三:

    和场景二一样也是只能从业务上找到突破点

滑动冲突的解决方式

  • 外部拦截法
  1. 点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,否则就不拦截。外部拦截需要重写父容器的onInterceptTouchEvent方法,在内部做相应的拦截即可。

  2. override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
        var intercepted = false
        val x = event.x.toInt()
        val y = event.y.toInt()
    
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                intercepted = false
            }
            MotionEvent.ACTION_MOVE -> {
                intercepted = if (父容器需要当前点击事件) {
                    true
                } else {
                    false
                }
            }
            MotionEvent.ACTION_UP -> {
                intercepted = false
            }
        }
        mLastXIntercept=x
        mLastYIntercept=y
        return intercepted
    }
    
    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

    以上是针对不同滑动冲突,只需要修改父容器需要当前点击事件这个条件即可其他均无需修改也不能修改

    - **内部拦截法**

    1. 内部拦截是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则交由父容器处理,此方式需要配合requestDisallowInterceptTouchEvent方法才能正常工作。

    2. ​

    ```kotlin
    override fun dispatchTouchEvent(event: MotionEvent): Boolean {
    val x = event.x.toInt()
    val y = event.y.toInt()

    when (event.action) {
    MotionEvent.ACTION_DOWN -> {
    // 禁止父容器拦截 DOWN 事件
    parent.requestDisallowInterceptTouchEvent(true)
    }
    MotionEvent.ACTION_MOVE -> {
    val deltaX = x - mLastX
    val deltaY = y - mLastY
    if (父容器需要此类点击事件) {
    // 如果判定需要父容器处理,则允许父容器拦截
    parent.requestDisallowInterceptTouchEvent(false)
    }
    }
    MotionEvent.ACTION_UP -> {
    // 抬起阶段通常不做额外限制
    }
    }

    mLastX = x
    mLastY = y
    return super.dispatchTouchEvent(event)
    }
    当面对不同的滑动策略时只需要修改里面的条件即可