自定义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(View).dispatchTouchEvent()
根 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 .