想要实现在列表中横向滚动的 ViewGroup
在平时开发中会出现 列表中添加横向列表的功能,一般都是使用 recycle 嵌套横向 recycle。或者使用HorizontalScrollView + 容器实现;然后就想自定义一个容器,直接满足横向滚动。
支持:设置 item 的之间的间距,设置容器左右的space空格;支持设置屏幕宽度下展示 item 的个数。
思路:
1.需要进行子 view 的测量,以及获取到容器的最大宽度,宽度包含了 margin 值,以及 padding 值。(需要重写generateLayoutParams 等方法)
2.针对测量结果进行 Layout 排版,排版时候需要考虑 padding 值。
3.需要处理手动横滑和 fling 事件(VelocityTracker);需要获取到滚动速度(VelocityTracker)
4.需要在点击时区分是滚动还是点击事件。需要在onInterceptTouchEvent 方法中进行拦截。在 onTouchEvent 中进行滚动和滑动处理。
发现问题:
1.在重新设置子 view 的宽度时,使用 measureChildWithMargins()方法,无法重置宽度;因为该方法内不具备重新设置子 view 宽度高度的功能,只有跟进已有的Spec+margin 进行的测量。
2.在容器设置 padding 时,滚动时 padding 区域并没有跟随移动。原因是 scrollTo方式不支持 padding 区域跟随移动。因此需要手动的设置左右Space来满足条件。
进行子view 测量以及排版功能
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//由于 measure 会调用多次,如果不重置就会累加
maxHeight = 0
totalWidth = 0
//针对子 view 进行测量
for (i in 0 until childCount) {
//进行子 view 的测量
getChildAt(i).let {
measureChildWithMargins(
it,
widthMeasureSpec, 0,
heightMeasureSpec, 0
)
//获取到最总的宽度
totalWidth += it.measuredWidth
//获取到最大高度
maxHeight = max(maxHeight, it.measuredHeight)
}
}
//宽度拼接 padding
totalWidth += (paddingLeft + paddingRight)
maxHeight += (paddingTop + paddingBottom)
//计算获取父容器的宽度和高度
setMeasuredDimension(
resolveSize(max(totalWidth, screenWidth), measuredWidth),
resolveSize(max(maxHeight, MeasureSpec.getSize(heightMeasureSpec)), measuredHeight)
)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// 进行 view 的排版
//获取到左侧的起始点
var left = paddingLeft
val top = if (maxHeight < measuredHeight) (measuredHeight - maxHeight) / 2 else paddingTop
for (i in 0 until childCount) {
getChildAt(i).let {
it.layout(left, top, left + it.measuredWidth, top + it.measuredHeight)
//进行左边位置确定
left += it.measuredWidth
}
}
maxHorizontalScrollDis = max(0, totalWidth - width)
}
重写generateLayoutParams 避免在获取子 view 的 margin 值时转换异常:
//重写 MarginParams
override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
return MarginLayoutParams(context, attrs)
}
override fun generateDefaultLayoutParams(): LayoutParams {
return MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
override fun generateLayoutParams(p: LayoutParams?): LayoutParams {
return MarginLayoutParams(p)
}
override fun checkLayoutParams(p: LayoutParams?): Boolean {
return p is MarginLayoutParams
}
在事件onInterceptTouchEvent方法中进行事件拦截和分发:
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
//在down 事件的时候移除滚动效果
//如果触摸距离小于最小距离,就不进行拦截,将事件交给子 view。否则交给滚动处理。
when (ev?.actionMasked) {
MotionEvent.ACTION_DOWN -> {
if (!scroller.isFinished) {
scroller.abortAnimation()
}
//获取到当前的位置
curDownX = ev.x
}
MotionEvent.ACTION_MOVE -> {
val moveX = abs(ev.x - curDownX)
if (moveX > touchSlop) {
return true
}
}
}
return super.onInterceptTouchEvent(ev)
}
在事件onTouche 中进行移动事件的处理。
private var touchDownX = 0f
override fun onTouchEvent(event: MotionEvent?): Boolean {
//在up 中进行数据计算,并进行fling操作,回收速度计算
//在 cancel 中回收速度计算
//在 down 中初始化 速度计算,并记录点击位置
//在 move 中,计算移动距离,然后通过 scrollBy 进行移动
//添加移动监听
velocityTracker = velocityTracker ?: VelocityTracker.obtain()
velocityTracker?.addMovement(event)
when (event?.actionMasked) {
MotionEvent.ACTION_DOWN -> {
scroller.abortAnimation()
touchDownX = event.x
//避免被事件拦截
parent?.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
//获取到移动的距离,进行移动
// scrollBy(+X) → 内容往右滚动 → 就是手指向左拖动的效果
// scrollBy(-X) → 内容往左滚动 → 就是手指向右拖动的效果
event.x.let {
scrollBy((touchDownX - it).toInt(), 0)//这个地方不能位置别反了,内容移动和手指移动方向相反。
touchDownX = it
}
}
MotionEvent.ACTION_UP -> {
//计算速度
velocityTracker?.computeCurrentVelocity(1000)
//执行 fling
scroller.fling(
scrollX,
0,
(-(velocityTracker?.xVelocity!!)).toInt(),
0,
0,
computeMaxScrollX(),
0,
0,
0,
0
)
//触发重新绘制
invalidate()
recycleVelocity()
}
MotionEvent.ACTION_CANCEL -> {
recycleVelocity()
}
}
return true//必须返回 true 都是滚动将无法响应,事件将会传到上级的 onTouch中
}
对应的 attr
<declare-styleable name="Horizontal_column">
<attr name="leftSpace" format="dimension"/>
<attr name="rightSpace" format="dimension"/>
<attr name="midSpace" format="dimension"/>
<attr name="showNum" format="float"/>
</declare-styleable>
总的代码:
class HorizontalColumnView @JvmOverloads constructor(context: Context,attributeSet: AttributeSet? = null) :
ViewGroup(context, attributeSet, 0) {
private var maxHorizontalScrollDis = 0
//定义滚动
private val scroller = OverScroller(context)
//定义速度计算器
private var velocityTracker: VelocityTracker? = null
//获取到最小移动距离匹配器
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var totalWidth = 0
private var maxHeight = 0
private val screenWidth = getScreenWidth()
private val edgeEffectLeft = EdgeEffect(context)
private val edgeEffectRight = EdgeEffect(context)
//设置间距
private var leftSpace = 0;
private var rightSpace = 0;
private var midSpace = 0
//如果设置展示的个数呢?例如展示2个半或者3个半
private var showNum = 0f
init {
context.withStyledAttributes(attributeSet, R.styleable.Horizontal_column) {
leftSpace =
getDimensionPixelSize(R.styleable.Horizontal_column_leftSpace, 0.dp())
rightSpace =
getDimensionPixelSize(R.styleable.Horizontal_column_rightSpace, 0.dp())
midSpace = getDimensionPixelSize(R.styleable.Horizontal_column_midSpace, 0.dp())
//一个屏幕展示的数量
showNum = getFloat(R.styleable.Horizontal_column_showNum, 0f)
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//由于 measure 会调用多次,如果不重置就会累加
maxHeight = 0
totalWidth = 0
if (showNum > 0) {
calculateAFixedNumberOfData(widthMeasureSpec, heightMeasureSpec)
} else {
defaultShow(widthMeasureSpec, heightMeasureSpec)
}
totalWidth -= midSpace//移除掉多加的中间间距
//宽度拼接 padding
totalWidth += (paddingLeft + paddingRight + leftSpace + rightSpace)
maxHeight += (paddingTop + paddingBottom)
//计算获取父容器的宽度和高度
setMeasuredDimension(
resolveSize(max(totalWidth, screenWidth), measuredWidth),
resolveSize(max(maxHeight, MeasureSpec.getSize(heightMeasureSpec)), measuredHeight)
)
}
/**
* 根据屏幕宽设置展示 item 个数,进行展示;支持间距设置,不支持 item 的 margin 值了
*/
private fun calculateAFixedNumberOfData(widthMeasureSpec: Int, heightMeasureSpec: Int) {
//获取到当前需要展示的子 view 的宽度
val itemWidth = if (showNum > 0) {
(getScreenWidth() -
((floor(showNum) - 1) * midSpace)
- paddingLeft - leftSpace) * 1.0f / showNum
} else {
0f
}
for (i in 0 until childCount) {
getChildAt(i).let {
//计算活动width spec
val widthSpec = MeasureSpec.makeMeasureSpec(itemWidth.toInt(), MeasureSpec.EXACTLY)
val heightSpec = getChildMeasureSpec(
heightMeasureSpec,
paddingTop + paddingBottom,
LayoutParams.WRAP_CONTENT
)
//重新测量
it.measure(widthSpec, heightSpec)
//计算 total,不考虑子view 的margin 值了
totalWidth += itemWidth.toInt() + midSpace
(it.layoutParams as MarginLayoutParams).let { params ->
maxHeight =
max(
maxHeight,
it.measuredHeight + params.topMargin + params.bottomMargin
)
}
}
}
}
/**
* 默认展示,支持设置间距以及 view margin
*/
private fun defaultShow(widthMeasureSpec: Int, heightMeasureSpec: Int) {
for (i in 0 until childCount) {
getChildAt(i).let {
measureChildWithMargins(
it,
widthMeasureSpec, 0,
heightMeasureSpec, 0
)
(it.layoutParams as MarginLayoutParams).let { params ->
//获取到最总的宽度,需要拼接上 margin 值以及中间的间距
totalWidth += it.measuredWidth + midSpace + params.leftMargin + params.rightMargin
//获取到最大高度
maxHeight =
max(
maxHeight,
it.measuredHeight + params.topMargin + params.bottomMargin
)
}
}
}
}
@SuppressLint("DrawAllocation")
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
//获取到左侧的起始点
val top = if (maxHeight < measuredHeight) (measuredHeight - maxHeight) / 2 else paddingTop
if (showNum > 0) {
calWidthLayout(top)
} else {
defaultWidthLayout(top)
}
maxHorizontalScrollDis = max(0, totalWidth - width)
}
/**
* 计算item 的宽度然后进行排版
*/
private fun calWidthLayout(top: Int) {
var left = paddingLeft + leftSpace
for (i in 0 until childCount) {
getChildAt(i).let {
it.layout(
left,
top,
left + it.measuredWidth,
top + it.measuredHeight
)
//进行左边位置确定
left += it.measuredWidth + midSpace //加上间距,
}
}
}
/**
* 根据 view 的宽度,默认展示,支持 margin padding
*/
private fun defaultWidthLayout(top: Int) {
var left = paddingLeft + leftSpace
for (i in 0 until childCount) {
getChildAt(i).let {
//需要获取到margin 值
val rect = (it.layoutParams as MarginLayoutParams).let { params ->
//拼接上子 view 左侧的 margin
left += params.leftMargin
Rect(
params.leftMargin.toFloat(),
params.topMargin.toFloat(),
params.rightMargin.toFloat(),
params.bottomMargin.toFloat()
)
}
it.layout(
left,
top + rect.top.toInt(),
left + it.measuredWidth,
top + it.measuredHeight + rect.top.toInt()
)
//进行左边位置确定
left += it.measuredWidth + midSpace + rect.right.toInt()//加上间距,
}
}
}
//重写 MarginParams
override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
return MarginLayoutParams(context, attrs)
}
override fun generateDefaultLayoutParams(): LayoutParams {
return MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
override fun generateLayoutParams(p: LayoutParams?): LayoutParams {
return MarginLayoutParams(p)
}
override fun checkLayoutParams(p: LayoutParams?): Boolean {
return p is MarginLayoutParams
}
//重写滚动需要的方法
override fun computeScroll() {
super.computeScroll()
if (scroller.computeScrollOffset()) {
scrollTo(scroller.currX, scroller.currY)
// 滚动到了边界处
if (scroller.isOverScrolled) {
//如果需要区分方向就针对垂直和水平进行处理
scroller.notifyHorizontalEdgeReached(
scroller.startX,
scroller.finalX,
20.dp()// overX 越界弹性距离
)
if (scroller.springBack(scrollX, 0, 0, maxHorizontalScrollDis, 0, 0)) {
invalidate()
}
}
postInvalidateOnAnimation()
}
//todo
/* if (scroller.computeScrollOffset()) {
scrollTo(scroller.currX, scroller.currY)
invalidate()
}*/
}
private fun computeMaxScrollX(): Int {
return totalWidth - width
}
override fun scrollTo(x: Int, y: Int) {
//x 表示滚动相对于左边 的距离,
// var newX = x
// if (newX < 0) newX = 0
// if (newX > maxHorizontalScrollDis) newX = maxHorizontalScrollDis
//super.scrollTo(newX, y)
//类似于上方功能
val clampedX = x.coerceIn(0, computeMaxScrollX())
super.scrollTo(clampedX, y)
}
private var curDownX = 0f
//重写触摸事件,进行拦截
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
//在down 事件的时候移除滚动效果
//如果触摸距离小于最小距离,就不进行拦截,将事件交给子 view。否则交给滚动处理。
when (ev?.actionMasked) {
MotionEvent.ACTION_DOWN -> {
if (!scroller.isFinished) {
scroller.abortAnimation()
}
//获取到当前的位置
curDownX = ev.x
}
MotionEvent.ACTION_MOVE -> {
val moveX = abs(ev.x - curDownX)
if (moveX > touchSlop) {
return true
}
}
}
return super.onInterceptTouchEvent(ev)
}
private var touchDownX = 0f
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
//在up 中进行数据计算,并进行fling操作,回收速度计算
//在 cancel 中回收速度计算
//在 down 中初始化 速度计算,并记录点击位置
//在 move 中,计算移动距离,然后通过 setx 进行移动
//添加移动监听
velocityTracker = velocityTracker ?: VelocityTracker.obtain()
velocityTracker?.addMovement(event)
when (event?.actionMasked) {
MotionEvent.ACTION_DOWN -> {
scroller.abortAnimation()
touchDownX = event.x
//避免被事件拦截
parent?.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
//获取到移动的距离,进行移动
//scrollBy(+X) → 内容往右滚动 → 就是手指向左拖动的效果
// scrollBy(-X) → 内容往左滚动 → 就是手指向右拖动的效果
event.x.let {
scrollBy((touchDownX - it).toInt(), 0)//这个地方不能位置别反了,内容移动和手指移动方向相反。
touchDownX = it
}
// TODO: 数据绘制边界方式
/* val dx = (touchDownX - event.x).toInt()
touchDownX = event.x
if (scrollX <= 0 && dx < 0) {
edgeEffectLeft.onPull(dx.toFloat() / width)
invalidate()
} else if (scrollX >= computeMaxScrollX() && dx > 0) {
edgeEffectRight.onPull(dx.toFloat() / width)
invalidate()
} else {
scrollBy(dx, 0)
}*/
}
MotionEvent.ACTION_UP -> {
//计算速度
velocityTracker?.computeCurrentVelocity(1000)
//执行 fling
scroller.fling(
scrollX,
0,
(-(velocityTracker?.xVelocity!!)).toInt(),
0,
0,
computeMaxScrollX(),
0,
0,
0,
0
)
//触发重新绘制
invalidate()
//todo 下边数据边界效果
/* velocityTracker?.computeCurrentVelocity(1000)
val velocityX = -velocityTracker?.xVelocity!!.toInt()
if (scrollX <= 0 && velocityX < 0) {
edgeEffectLeft.onAbsorb(velocityX)
invalidate()
} else if (scrollX >= computeMaxScrollX() && velocityX > 0) {
edgeEffectRight.onAbsorb(velocityX)
invalidate()
} else {
scroller.fling(
scrollX, 0,
velocityX, 0,
0, computeMaxScrollX(),
0, 0
)
invalidate()
}*/
recycleVelocity()
}
MotionEvent.ACTION_CANCEL -> {
recycleVelocity()
edgeEffectLeft.onRelease()
edgeEffectRight.onRelease()
}
}
return true
}
private fun recycleVelocity() {
velocityTracker?.recycle()
velocityTracker = null
}
private fun getScreenWidth(): Int {
val metrics = DisplayMetrics()
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val display = windowManager.defaultDisplay
display.getRealMetrics(metrics) // 包括系统栏(导航栏、状态栏)
return metrics.widthPixels
}
//todo 数据绘制边界效果
/* override fun draw(canvas: Canvas) {
super.draw(canvas)
var needsInvalidate = false
if (!edgeEffectLeft.isFinished) {
canvas.withRotation(270f) {
canvas.translate(-height.toFloat(), 0f)
edgeEffectLeft.setSize(height, width)
needsInvalidate = edgeEffectLeft.draw(canvas)
}
}
if (!edgeEffectRight.isFinished) {
canvas.withRotation(90f) {
canvas.translate(0f, -width.toFloat())
edgeEffectRight.setSize(height, width)
needsInvalidate = edgeEffectRight.draw(canvas)
}
}
if (needsInvalidate) postInvalidateOnAnimation()
}*/
}