自定义View(布局)

本文详细讲解了自定义View的布局过程,包括测量和布局两个步骤,以及如何定制尺寸、触摸反馈和嵌套滑动。涉及onMeasure()、onLayout()方法,多点触控、手势检测和OverScroller等技术应用。
摘要由CSDN通过智能技术生成

自定义View(布局)

自定义布局

布局过程

  • 确定每个 View 的位置和尺寸
  • 为绘制和触摸范围做支持

从整体看布局流程
  • 测量流程:从 根View 递归调用每一级 子View 的 measure() 方法,对它们进行测量,获取 子View 的大小,从而确定 子View 的位置;
  • 布局流程:从 根View 递归调用每一级 子View 的 layout() 方法,把测量过程 获得的 子View 的位置和尺寸传给 子View,子View 保存。

布局为什么要分两个流程?

因为可能需要重复测量。


从个体看布局流程
  • 开发者在 xml 文件中写入对 View 的布局要求 layout_xxx;

  • 父View 在自己的 onMeasure() 中,根据开发者在 xml 中写的对 子View 的要求,以及自己的可用空间,得出 子View 的具体尺寸要求;

  • 子View 在自己的 onMeasure() 中,根据 父View 的要求和自己的特性 计算出自己的期望尺寸(如果是 ViewGroup,还会在这里调用每个 子View 的 measure() 进行测量);

  • 父View 在 子View 计算出期望尺寸后,得出 子View 的实际尺寸和位置;

  • 子View 在自己的 layout() 方法中,将 父View 传进来的自己的实际尺寸和位置保存(如果是 ViewGroup,还会在 onLayout() 里调用每个 子View 的 layout() 把它们的尺寸位置传给它们)

尺寸自定义

简单改写已有 View 的尺寸

  • 重写 onMeasure() ;
  • 用 getMeasuredWidth() 和 getMeasureHeight() 获取到测量出的尺寸;
  • 计算出最终的尺寸;
  • 用 setMeasuredDimension(width, height) 将结果保存。

为什么不重写 layout() ?

因为重写 layout() 不会通知 父View 自己的范围有改变,会导致不可预测的结果;


// 正方向的ImageView
class SquareImageView(context: Context, attrs: AttributeSet) : AppCompatImageView(context, attrs) {

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val size = min(measuredWidth, measuredHeight)
        setMeasuredDimension(size, size)
    }

    // 重写 layout 可能会导致 View 显示出现错乱
//    override fun layout(l: Int, t: Int, r: Int, b: Int) {
//        super.layout(l, t, (l + 200.dp).toInt(), (t + 200.dp).toInt())
//    }

}

完全指定 View 的尺寸
  • 重写 onMeasure()
  • 计算出自己的尺寸
  • 用 resolveSize() 修正结果
  • 使用 setMeasuredDimension(width, height) 保存结果

private val RADIUS = 60.dp
private val PADDING = 80.dp

// 固定大小的Circle
class CircleView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.parseColor("#ec5f66")
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        /*var widthSize = ((PADDING + RADIUS) * 2).toInt()
        val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec)
        val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec)
        when (widthSpecMode) {
            MeasureSpec.EXACTLY -> widthSize = widthSpecSize
            MeasureSpec.AT_MOST -> {
                if (widthSize > widthSpecSize) {
                    widthSize = widthSpecSize
                }
            }
            MeasureSpec.UNSPECIFIED -> widthSize = widthSize
        }
        var heightSize = ((PADDING + RADIUS) * 2).toInt()
        val heightSpecSize = MeasureSpec.getSize(heightMeasureSpec)
        val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec)
        when (heightSpecMode) {
            MeasureSpec.EXACTLY -> heightSize = heightSpecSize
            MeasureSpec.AT_MOST -> {
                if (heightSize > heightSpecSize) {
                    heightSize = heightSpecSize
                }
            }
            MeasureSpec.UNSPECIFIED -> heightSize = heightSize
        }
        setMeasuredDimension(widthSize, heightSize)*/
        val size = ((PADDING + RADIUS) * 2).toInt()
        val width = resolveSize(size, measuredWidth)
        val height = resolveSize(size, measuredHeight)
        setMeasuredDimension(width, height)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawCircle(PADDING + RADIUS, PADDING + RADIUS, RADIUS, paint)
    }
}

自定义 Layout
  • 重写 onMeasure()
    • 遍历每个 子View,测量 子View,得出 子View 的实际位置和尺寸,并暂时保存。有些 子View 可能需要重新测量。
    • 测量出所有 子View 的位置和尺寸后,计算出自己的尺寸,并用 setMeasuredDimension(width, height) 保存。

  • 重写 onLayout()
    • 遍历每个 子View,调用它们的 layout() 方法来将位置和尺寸传给它们。

class TabLayout(context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) {
    private val childrenBounds = mutableListOf<Rect>()

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec)
        val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec)
        var lineWidthUsed = 0
        var heightUsed = 0
        var widthUsed = 0
        var lineMaxHeight = 0
        for ((index, child) in children.withIndex()) {
            // 测量子View
            // measureChildWithMargins 内部会调用 子View 的 measure 方法
            measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0)
            if (widthSpecMode != MeasureSpec.UNSPECIFIED
                && lineWidthUsed + child.measuredWidth > widthSpecSize) {// 换行
                lineWidthUsed = 0
                heightUsed += lineMaxHeight
                lineMaxHeight = 0
                //measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0)
            }

            if (index >= childrenBounds.size) {
                childrenBounds.add(Rect())
            }
            val childBound = childrenBounds[index]
            childBound.set(
                lineWidthUsed,
                heightUsed,
                lineWidthUsed + child.measuredWidth,
                heightUsed + child.measuredHeight
            )
            lineWidthUsed += child.measuredWidth
            widthUsed = max(widthUsed, lineWidthUsed)
            lineMaxHeight = max(lineMaxHeight, child.measuredHeight)
        }
        val selfWidth = widthUsed
        val selfHeight = heightUsed + lineMaxHeight
        setMeasuredDimension(selfWidth, selfHeight)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        for ((index, child) in children.withIndex()) {// 布局子View
            val childBound = childrenBounds[index]
            child.layout(childBound.left, childBound.top, childBound.right, childBound.bottom)
        }
    }

    override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return MarginLayoutParams(context, attrs)
    }

}

触摸反馈
自定义单 View 的触摸反馈

重写 onTouchEvent(),在方法内部定制触摸反馈算法,是否消费事件取决于 ACTION_DOWN 事件是否返回 true.


MotionEvent

getActionMasked() 和 getAction() 怎么选?

多点触摸选 getActionMasked(),单点触摸选 getAction() .

注:多点触摸
当 ACTION_DOWN 触发后,又有一个手指触摸后,会触发 ACTION_POINT_DOWN 事件,当任意抬起一个手指后会触发 ACTION_POINT_UP 事件。


getActionIndex() 获取当前触摸点的 index
getX(index) / getY(index) 获取触摸点的 x 、y 坐标,单点触摸 getX() 使用的 index 为 0
当手指按下时,通过 getPointerId(index) 获取触摸点对应的 id,当手指抬起时,通过 findPointerIndex(index) 获取抬起那根手指的 id .


View.onTouchEvent() 的源码逻辑
  • ACTION_DOWN(按下)

    • 如果不在滑动控件中,切换至按下状态,并注册长按计时器

    • 如果在滑动控件中,切换至预按下状态,并注册按下计时器


  • ACTION_MOVE(移动)

    • 重绘 Ripple Effect
    • 如果与移动出自己的范围,自我标记本次事件失效,忽略后续事件

  • ACTION_UP(抬起)

    • 如果是按下状态未触发长按,切换至抬起状态并触发点击事件,并清除一切状态
    • 如果已经触发长按,切换至抬起状态并清除一切状态

  • ACTION_CANCEL(当事件意外结束)
    • 切换至抬起状态,并清除一切状态

自定义 ViewGroup 的触摸反馈
  • 除了重写 onTouchEvent(),还需要重写 onInterceptTouchEvent();

  • onInterceptTouchEvent() 不用在第一时间返回 true,而是在任意事件里,需要拦截的时候返回 true 就行;

  • 在 onInterceptTouchEvent() 中除了判断拦截,还要做好拦截之后的准备工作。


触摸反馈的流程
  • Activity.dispatchTouchEvent()
    • 递归:ViewGroup(View).dispatchTouchEvent()
      • ViewGroup.onInterceptTouchEvent()
      • child.dispatchTouchEvent()
      • super.dispatchTouchEvent()
        • View.onTouchEvent()
    • Activity.onTouchEvent()

根 ViewGroup 最先接收到 MotionEvent,其 dispatchTouchEvent 方法会被调用,该方法内部会调用 onInterceptTouchEvent 方法来判断是否拦截事件。

ViewGroup 的 onInterceptTouchEvent 方法如果返回 true,则表示当前 ViewGroup 要拦截事件,否则就会调用 child (内部的 ViewGroup 或 View)重复分发过程。

View 和 ViewGroup 的 onTouchEvent 方法用来判断是否要消费事件,返回 true 表示事件被消费,终止传递。

View 是否能接受到整个事件序列的消息主要取决于是否消费了 ACTION_DOWN 事件,ACTION_DOWN 事件是整个事件序列的起始点,View 必须消耗了起始事件才有机会完整处理整个事件序列。

// 事件分发伪代码
fun dispatchTouchEvent(event: MotionEvent): Boolean {
	var consume = false
  consume = if(onInterceptTouchEvent(event)) {
    onTouchEvent(event)
  } else {
    child.dispatchTouchEvent(event)
  }
  return consume
}

系统内置了一个最小滑动距离值,只有先后两个坐标点之间的距离超过该值,就会被认为是滑动事件

ViewConfiguration.get(context).scaledTouchSlop

View.dispatchTouchEvent()
  • 如果设置了 OnTouchListener,调用 OnTouchListener.onTouch()
    • 如果 OnTouchListener 消费了事件,返回 true
    • 如果 OnTouchListener 没有消费事件,继续调用自己的 onTouchEvent(),并返回和 onTouchEvent() 相同的结果

  • 如果没有设置 OnTouchListener
    • 调用自己的 onTouchEvent(),并返回和 onTouchEvent() 相同的结果

ViewGroup.dispatchTouchEvent()
  • 如果是初次按下(ACTION_DOWN),清空 TouchTargets 和 DISALLOW_INTERCEPT 标记

  • 拦截处理

  • 如果不拦截并且不是 CANCEL 事件,并且是 DOWN 或 POINTER_DOWN,尝试把 pointer(手指)通过 TouchTarget 分配给子 View;并且如果分配给了新的子 View,调用 child.dispatchTouchEvent() 把事件传给子 View

  • 看有没有 TouchTarget

    • 如果没有,调用自己的 super.dispatchTouchEvent()
    • 如果有,调用 child.dispatchTouchEvent() 把事件传给对应的子 View

  • 如果是 POINTER_UP,从 TouchTargets 中清除 POINTER 信息;如果是 UP 或 CANCEL,重置状态


TouchTarget
  • 作用:记录每个子 View 是被哪些 pointer (手指) 按下的
  • 结构:单向链表

自定义双向滑动的 ScalableImageView
GestureDetector

用于在点击和长按之外,增加了其他手势的监听,如:双击,滑动。通过在 View.onTouchEvent() 里调用 GestureDetector.onTouchEvent(),以代理的形式实现。

override fun onTouchEvent(event: MotionEvent?): Boolean {
    return gestureDetector.onTouchEvent(event)
}

OnGestureListener 的回调方法
override fun onDown(e: MotionEvent?): Boolean {
        // 是否消费事件
        return true
    }

    override fun onShowPress(e: MotionEvent?) {// 用户按下100ms不松手后会被调用
    }

    override fun onSingleTapUp(e: MotionEvent?): Boolean {// 点击时被调用
        return false
    }

    override fun onScroll(
        e1: MotionEvent?,
        e2: MotionEvent?,
        distanceX: Float,// 起始坐标 - 新的坐标
        distanceY: Float
    ): Boolean {// 滑动时调用
        return true
    }

    override fun onLongPress(e: MotionEvent?) {// 长按
    }

    override fun onFling(
        e1: MotionEvent?,
        e2: MotionEvent?,
        velocityX: Float,
        velocityY: Float
    ): Boolean {// 快速滑动时抬起
        return true
    }

OnDoubleTapListener 双击监听器
override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {// 用户单击时调用,不会立即调用,确认没有双击后,才会被调用
        return false
    }
    
override fun onDoubleTap(e: MotionEvent): Boolean {// 用户双击时调用 300ms
    return true
}

override fun onDoubleTapEvent(e: MotionEvent?): Boolean {// 第二次按下后的事件
    return false
}

OverScroller

用于自动计算滑动的偏移

val scroller = OverScroller(context)

常用于 onFling() 方法中,调用 OverScroller.fling() 方法来启动惯性滑动的计算:


ScaleGestureDetector 多指缩放监听器
private val scaleGestureDetectorListener = ScaleGestureDetectorListener()
private val scaleGestureDetector = ScaleGestureDetector(context, scaleGestureDetectorListener)


inner class ScaleGestureDetectorListener : ScaleGestureDetector.OnScaleGestureListener {
        // 返回true表示相对前一次返回true时的缩放因子
        override fun onScale(detector: ScaleGestureDetector): Boolean {
            return true
        }

        override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {// 捏撑开始
            return true
        }

        override fun onScaleEnd(detector: ScaleGestureDetector?) {// 捏撑结束
        }
    }

完整自定义的 ScalableImageView 代码如下

private val IMAGE_WIDTH = 200.dp
private const val EXTRA_SCALE_FACTOR = 1.5f

class ScalableImageView(context: Context?, attrs: AttributeSet?) : View(context, attrs),
    GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val bitmap = getAvatar(IMAGE_WIDTH.toInt())
    private var originOffsetX = 0f
    private var originOffsetY = 0f
    private var offsetX = 0f
    private var offsetY = 0f
    private var smallScale = 0f
    private var bigScale = 0f
    private var big = false
    private var currentScale = 0f
        set(value) {
            field = value
            invalidate()
        }
    private val scaleAnimator = ObjectAnimator.ofFloat(this, "currentScale", smallScale, bigScale)
    private val gestureDetector = GestureDetectorCompat(context, this)
    private val scroller = OverScroller(context)
    private val flingRunner = FlingRunner()
    private val scaleGestureDetectorListener = ScaleGestureDetectorListener()
    private val scaleGestureDetector = ScaleGestureDetector(context, scaleGestureDetectorListener)

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)

        originOffsetX = (width - bitmap.width) / 2f
        originOffsetY = (height - bitmap.width) / 2f

        if (bitmap.width / bitmap.height.toFloat() > width / height.toFloat()) {
            smallScale = width / bitmap.width.toFloat()
            bigScale = height / bitmap.height.toFloat() * EXTRA_SCALE_FACTOR
        } else {
            smallScale = height / bitmap.height.toFloat()
            bigScale = width / bitmap.width.toFloat() * EXTRA_SCALE_FACTOR
        }
        currentScale = smallScale
        scaleAnimator.setFloatValues(smallScale, bigScale)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        val scaleFraction = (currentScale - smallScale) / (bigScale - smallScale)
        canvas.translate(offsetX * scaleFraction, offsetY * scaleFraction)
        canvas.scale(currentScale, currentScale, width / 2f, height / 2f)
        canvas.translate(originOffsetX, originOffsetY)
        canvas.drawBitmap(bitmap, 0f, 0f, paint)
    }

    private fun getAvatar(width: Int): Bitmap {
        val options = BitmapFactory.Options()
        options.inJustDecodeBounds = true
        BitmapFactory.decodeResource(resources, R.drawable.avatar, options)
        options.inJustDecodeBounds = false
        options.inDensity = options.outWidth
        options.inTargetDensity = width
        return BitmapFactory.decodeResource(resources, R.drawable.avatar, options)
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        scaleGestureDetector.onTouchEvent(event)// 优先缩放
        if (!scaleGestureDetector.isInProgress) {
            gestureDetector.onTouchEvent(event)
        }
        return true
    }

    override fun onDown(e: MotionEvent?): Boolean {
        // 是否消费事件
        return true
    }

    override fun onShowPress(e: MotionEvent?) {
    }

    override fun onSingleTapUp(e: MotionEvent?): Boolean {
        return false
    }

    override fun onScroll(
        e1: MotionEvent?,
        e2: MotionEvent?,
        distanceX: Float,// 起始坐标 - 新的坐标
        distanceY: Float
    ): Boolean {
        if (big) {
            offsetX -= distanceX
            offsetY -= distanceY
            offsetX = min(offsetX, (bitmap.width * bigScale - width) / 2)
            offsetX = max(offsetX, -(bitmap.width * bigScale - width) / 2)
            offsetY = min(offsetY, (bitmap.height * bigScale - height) / 2)
            offsetY = max(offsetY, -(bitmap.height * bigScale - height) / 2)
            invalidate()
        }
        return true
    }

    override fun onLongPress(e: MotionEvent?) {
    }

    override fun onFling(
        e1: MotionEvent?,
        e2: MotionEvent?,
        velocityX: Float,
        velocityY: Float
    ): Boolean {
        if (big) {
            scroller.fling(
                offsetX.toInt(),
                offsetY.toInt(),
                velocityX.toInt(),
                velocityY.toInt(),
                (-(bitmap.width * bigScale - width) / 2).toInt(),
                ((bitmap.width * bigScale - width) / 2).toInt(),
                (-(bitmap.height * bigScale - height) / 2).toInt(),
                ((bitmap.height * bigScale - height) / 2).toInt(),
                20.dp.toInt(), 20.dp.toInt()
            )
//            postOnAnimation(flingRunner)
            ViewCompat.postOnAnimation(this@ScalableImageView, flingRunner)
        }
        return true
    }

    override fun onSingleTapConfirmed(e: MotionEvent?): Boolean {
        return false
    }

    override fun onDoubleTap(e: MotionEvent): Boolean {
        big = !big
        if (big) {
            offsetX = (e.x - width / 2f) * (1 - bigScale / smallScale)
            offsetY = (e.y - height / 2f) * (1 - bigScale / smallScale)
            offsetX = min(offsetX, (bitmap.width * bigScale - width) / 2)
            offsetX = max(offsetX, -(bitmap.width * bigScale - width) / 2)
            offsetY = min(offsetY, (bitmap.height * bigScale - height) / 2)
            offsetY = max(offsetY, -(bitmap.height * bigScale - height) / 2)
            scaleAnimator.start()
        } else {
            scaleAnimator.reverse()
        }
        return true
    }

    override fun onDoubleTapEvent(e: MotionEvent?): Boolean {
        return false
    }

    inner class FlingRunner : Runnable {
        override fun run() {
            val computeScrollOffset = scroller.computeScrollOffset()
            if (computeScrollOffset) {
                offsetX = scroller.currX.toFloat()
                offsetY = scroller.currY.toFloat()
                // invalidate()
//                postOnAnimation(flingRunner)
                ViewCompat.postOnAnimation(this@ScalableImageView, flingRunner)
            }
        }
    }

    inner class ScaleGestureDetectorListener : ScaleGestureDetector.OnScaleGestureListener {
        // 返回true表示相对前一次返回true时的缩放因子
        override fun onScale(detector: ScaleGestureDetector): Boolean {
            val tempCurrentScale = currentScale * detector.scaleFactor
            if (tempCurrentScale < smallScale || tempCurrentScale > bigScale) {
                return false
            } else {
                currentScale *= detector.scaleFactor // 0 --> 无穷
                return true
            }
        }

        override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
            offsetX = (detector.focusX - width / 2f) * (1 - bigScale / smallScale)
            offsetY = (detector.focusY - height / 2f) * (1 - bigScale / smallScale)
            return true
        }

        override fun onScaleEnd(detector: ScaleGestureDetector?) {
        }
    }

}

VelocityTracker

用来计算手指滑动的速度。

  • 使用方法:
    • 在每个事件序列开始时(即 ACTION_DOWN 事件到来时),通过 VelocityTracker.obtain() 创建一个实例,或使用 velocityTracker.clear() 把之前的某个实例重置。

    • 对于每个事件(包括 ACTION_DOWN 事件),使用 velocityTracker.addMovement(event) 把事件添加进 VelocityTracker.

    • 在需要速度的时候(例如在 ACTION_UP 中计算是否达到 fling 速度),使用 velocityTracker.computeCurrentVelocity(1000, maxVelocity) 来计算出速度

      • 方法参数中的 1000 指的是计算的时间长度,单位是 ms,表示 1000ms 时间移动的像素数。
      • 第二个参数是速度上限,超过这个速度时,计算出的速度就会回落到这个速度。

scrollTo() / scrollBy() 和 computeScroll()
  • scrollTo() / scrollBy() 会设置绘制时的偏移,通常为滑动控件设置偏移

  • scroll 值表示的是绘制内容在控件内的起始偏移(例如:我要从内容的第 200 个像素开始绘制),因此 scrollTo() 内的参数为正值时,绘制内容会向负移动。

  • scrollTo() 是瞬时方法。如果要用动画,需要配合 View.computeScroll() 方法。

    • computeScroll() 在 View 重绘时会被调用

// 在 ACTION_UP 中 
overScroller.startScroll(scrollX, 0, scrollDistance,0)
postInvalidateOnAnimation()// 下一帧重绘



// 在onDraw方法中会调用这个方法
override fun computeScroll() {
    if(overScroller.computeScrollOffset()) {
        scrollTo(overScroller.currX, overScroller.currY)
        postInvalidateOnAnimation()
    }
}

嵌套滑动

不同向嵌套

  • onInterceptTouchEvent() 中父 View 拦截
  • requestDisallowInterceptTouchEvent() 子View 阻止 父 View 拦截

同向嵌套

  • 父 View 会彻底卡住 子 View
    • 原因:抢夺条件一致,但 父View 的 onIntercptTouch() 早于 子View 的 dispatchTouchEvent()
    • 本质上是策略的问题:嵌套状态下用户手指滑动,是要滑动谁

NestedScrolling

子 View 在准备滑动之前将要滑动的细节信息传递给 父View,父View 可以决定是否部分或全部消耗掉此次滑动,子 View 会在父 View 处理完后收到剩余的没有被 父View 消耗的值,然后根据这个值进行滑动。滑动完成之后如果 子View 没有完全消耗掉这个剩余的值就会再告知一下 父View .


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值