想要实现功能:
- 1.如果数量比较少不能占满全屏,条目就居中,大于2个的情况第一个和最后一个放到两端,其他居中。
- 2.如果条目宽度大于屏幕宽度,就可以滚动展示。
- 3.支持 margin和padding。
- 4.支持 fling 效果。支持不滚动出边界。
- 5.支持子 view 居中展示在父容器中。
实现上述功能需要考虑的功能点如下:
1.采用哪种方式实现拖拽和滚动效果
2.如果进行测量并包含 margin值。
3.触摸事件在拖动滚动时,需要进行事件拦截,以及触发拖动和 fling 效果。
4.需要了解最小滚动距离,以及松开手指后获取滚动速度和获取速度类的释放。
实现页面拖拽时,采用哪些方法;
setx/seTranslationX 和 scrollTo/scrollBy 的区别:
方法 | 本质 | 作用对象 | 典型用途 |
---|---|---|---|
setX() | 设置位置 | View 本身 | 拖动、动画、定位 |
translationX | 偏移量 | View 渲染位置 | 动画、滑动返回 |
scrollX / scrollTo() | 内容滚动 | View 的内容 | 自定义滑动容器 |
场景 | 使用方式 |
---|---|
想要“滑动内容”,如 scroll view、轮播图、自定义容器滑动 | scrollTo() / scrollBy() |
想让整个 View 动起来(动画、拖动、改变位置) | setX() / translationX |
多个 item 横向滚动,但 ViewGroup 不动 | scrollTo() 内容滑动 |
View 要响应用户手势移动(如拖动) | translationX 较为平滑 |
你要写 fling、惯性滑动等功能 | 用 scrollTo() + Scroller / OverScroller |
如果想要实现的是内容(也可以是多个子 view )的拖拽或者滚动效果,那么使用 scrollTo或者 scrollBy ,这样后面可以结合OverScroller 实现惯性滚动效果。以及结果computeScroll方法实现滚动的计算,达到惯性滚动效果。
override fun computeScroll() {
if (scroller.computeScrollOffset()) {
// scroller.currX 滚动过程中当前偏移量,x 也是按照左边界为起始点 值为0
//这种终止动画的方式在 overScroller 不太适合,会导致滑动暂停过于僵硬。
//在Scroller 中使用下面代码的原因是为了避免过度偏移。
/* if (scroller.currX >= computeMaxScrollX() || scroller.currX <= 0) {
scroller.abortAnimation()
return
}*/
scrollTo(scroller.currX, scroller.currY)
// 滚动到了边界处
if (scroller.isOverScrolled) {
// 如果需要区分方向就针对垂直和水平进行处理
scroller.notifyHorizontalEdgeReached(
scroller.startX,
scroller.finalX,
20.dp()// overX 越界弹性距离
)
if (scroller.springBack(scrollX, 0, 0, computeMaxScrollX(), 0, 0)) {
invalidate()
}
}
postInvalidate()
}
}
结合scrollTo 方法实现滚动的边界效果,避免出现滚动移除可见区域的情况。
override fun scrollTo(x: Int, y: Int) {
//x 表示滚动相对于左边 的距离,
var newX = x
if (newX < 0) {
newX = 0
}
if (newX > computeMaxScrollX()) {
newX = computeMaxScrollX()
}
super.scrollTo(newX, y)
}
实现测量子 View 并携带对应的 margin 值:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
resetValue()
for (i in 0 until childCount) {
//如果在测量的时候设置了widthUser 那么将按照父容器的宽度- 已经使用的空间分配,同时最终的宽度不会超过父容器的宽度。
//如果想要子 view 的宽度超过父容器的宽度,那么 width user就直接设置0.如果想要根据剩余空间进行计算子 view,就使用。
// measureChildWithMargins(getChildAt(i), widthMeasureSpec, width, heightMeasureSpec, 0)
measureChildWithMargins(getChildAt(i), widthMeasureSpec, 0, heightMeasureSpec, 0)
//记录中的宽度
totalWidth += getChildAt(i).measuredWidth
Log.e("", "-------- > center view $i width $totalWidth ")
maxHeight = max(maxHeight, getChildAt(i).measuredHeight)
}
// 获取宽高
totalWidth += paddingLeft + paddingRight
maxHeight += paddingTop + paddingBottom
//设置最终的测量结果
setMeasuredDimension(
resolveSize(max(totalWidth, screenWidth), measuredWidth),//这个地方宽度设置的是最小是整个屏幕的宽度
resolveSize(max(maxHeight, MeasureSpec.getSize(heightMeasureSpec)), measuredHeight)
)
}
这个地方主要的是通过measureChildWithMargins方法进行子 view 的测量。
| 特性 | `measureChild()` | `measureChildWithMargins()` |
| -------------------------------- | ---------------- | --------------------------- |
| 是否处理子 View 的 `Margin` | ❌ 否 | ✅ 是 |
| 需要子 View 使用 `MarginLayoutParams` | ❌ 否 | ✅ 是 |
| 调用时是否额外传入 used space | ❌ 否 | ✅ 是(横向和纵向已占空间) |
| 适用场景 | 不关心 margin 的简单布局 | 支持 margin 的复杂布局 |
就是说想要处理子 view 的margin 值,就需要使用measureChildWithMargins方法。但是注意:在该方法内部有 widthUser,heightUser 数据,是表示已经使用的宽度;如果累加了子 view 的宽度,并widthUser = 累加宽度;那么计算的最大宽度就是父容器的最大宽度。这样如果子 view 过多,同时累加的宽度 等于父容器宽度的时候,后面没有测量的 子 View,宽度将为0;也就导致了无法正常的展示。
代码如下:
class CenterHorizontalScrollView @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null
) : ViewGroup(context, attributeSet, 0) {
init {
// setWillNotDraw(false); // 如果需要绘制滑动条等
// setClipChildren(false); // 防止内容被裁剪
}
/**
* 需要记录 view总的宽度
* 需要记录 最小的滚动距离
*/
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var totalWidth = 0
private var maxHeight = 0//最大高度
private val screenWidth = getScreenWidth()
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
resetValue()
for (i in 0 until childCount) {
//如果在测量的时候设置了widthUser 那么将按照父容器的宽度- 已经使用的空间分配,同时最终的宽度不会超过父容器的宽度。
//如果想要子 view 的宽度超过父容器的宽度,那么 width user就直接设置0.如果想要根据剩余空间进行计算子 view,就使用。
// measureChildWithMargins(getChildAt(i), widthMeasureSpec, width, heightMeasureSpec, 0)
measureChildWithMargins(getChildAt(i), widthMeasureSpec, 0, heightMeasureSpec, 0)
//记录中的宽度
totalWidth += getChildAt(i).measuredWidth
Log.e("", "-------- > center view $i width $totalWidth ")
maxHeight = max(maxHeight, getChildAt(i).measuredHeight)
}
// 获取宽高
totalWidth += paddingLeft + paddingRight
maxHeight += paddingTop + paddingBottom
//设置最终的测量结果
setMeasuredDimension(
resolveSize(max(totalWidth, screenWidth), measuredWidth),//这个地方宽度设置的是最小是整个屏幕的宽度
resolveSize(max(maxHeight, MeasureSpec.getSize(heightMeasureSpec)), measuredHeight)
)
}
//重置宽度和最大高度
private fun resetValue() {
totalWidth = 0//重置避免重复累加
maxHeight = 0
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
Log.e("", "-------- > center view onLayout width $totalWidth screen width $screenWidth")
var left = paddingLeft
val top = if (maxHeight < measuredHeight) (measuredHeight - maxHeight) / 2 else paddingTop
//进行排版
//如果总的累计宽度小于屏幕就居中展示
if (totalWidth <= screenWidth) {
for (i in 0 until childCount) {
//如果想要居中平均分配那么就需要知道中间间距的大小值。
val innerValue = (screenWidth - totalWidth) / (childCount - 1)
//这个这种方式值排列展示
getChildAt(i).let {
it.layout(
left,
top,
it.measuredWidth + left,
top + maxHeight//这个地方用最大的高度作为设置
)
//位置向左边偏移
left += it.measuredWidth + innerValue
}
}
} else {
// 排版
for (i in 0 until childCount) {
getChildAt(i).let {
it.layout(left, top, left + it.measuredWidth, top + maxHeight)
left += it.measuredWidth
}
}
}
}
private var downX = 0f
// 指定滑动效果
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
when (ev?.actionMasked) {
MotionEvent.ACTION_DOWN -> {
//在点击的时候如果在滚动就需要暂停滚动
scroller.abortAnimation()
downX = ev.x
}
MotionEvent.ACTION_MOVE -> {
//如果 x轴的移动距离大于最小移动距离就进行移动
val move = abs(ev.x - downX)
if (move > touchSlop) {
return true
}
}
}
return super.onInterceptTouchEvent(ev)
}
private var lastX = 0
private var scroller: OverScroller = OverScroller(context)
private var velocityTracker: VelocityTracker? = null
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (velocityTracker == null) {
velocityTracker = VelocityTracker.obtain()
}
velocityTracker?.addMovement(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastX = event.x.toInt()
}
MotionEvent.ACTION_MOVE -> {
val x = event.x.toInt()
val dx: Int = lastX - x
scrollBy(dx, 0) // 滚动内容
lastX = x
}
MotionEvent.ACTION_UP -> {
velocityTracker?.computeCurrentVelocity(1000)
//scrollX:向左边滚动的距离
//计算得知:计算的xVelocity在原来的基础上缩小,滚动的速度将会变快。如果想要速度快一点,就减少该值
scroller.fling(
scrollX, 0, (-velocityTracker?.xVelocity!!).toInt(), 0,
0, computeMaxScrollX(), 0, 0, 0, 0//overX属于距离。
)
invalidate()
velocityTracker?.recycle()
velocityTracker = null
}
MotionEvent.ACTION_CANCEL -> {
velocityTracker?.recycle()
velocityTracker = null
}
}
return true
}
override fun computeScroll() {
if (scroller.computeScrollOffset()) {
// scroller.currX 滚动过程中当前偏移量,x 也是按照左边界为起始点 值为0
//这种终止动画的方式在 overScroller 不太适合,会导致滑动暂停过于僵硬。
//在Scroller 中使用下面代码的原因是为了避免过度偏移。
/* if (scroller.currX >= computeMaxScrollX() || scroller.currX <= 0) {
scroller.abortAnimation()
return
}*/
scrollTo(scroller.currX, scroller.currY)
// 滚动到了边界处
if (scroller.isOverScrolled) {
// 如果需要区分方向就针对垂直和水平进行处理
scroller.notifyHorizontalEdgeReached(
scroller.startX,
scroller.finalX,
20.dp()// overX 越界弹性距离
)
if (scroller.springBack(scrollX, 0, 0, computeMaxScrollX(), 0, 0)) {
invalidate()
}
}
postInvalidate()
}
}
//重写 scrollTo 来设滚动的最终边界点。
override fun scrollTo(x: Int, y: Int) {
//x 表示滚动相对于左边 的距离,
var newX = x
if (newX < 0) {
newX = 0
}
if (newX > computeMaxScrollX()) {
newX = computeMaxScrollX()
}
super.scrollTo(newX, y)
}
private fun computeMaxScrollX(): Int {
return totalWidth - screenWidth
}
//用于计算子 view 的 margin 值
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
}
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
}
}
xml:
<com.example.verifykt.move.CenterHorizontalScrollView
android:layout_width="match_parent"
android:layout_height="80dp"
android:background="@color/color_fafaaa"
android:clipChildren="false"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/llone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dp"
android:text="你好啊"
android:textColor="@color/color_333333"
android:textSize="15sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dp"
android:text="在哪里"
android:textColor="@color/color_333333"
android:textSize="15sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dp"
android:text="在这里"
android:textColor="@color/color_333333"
android:textSize="15sp" />
.
.
.
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="1"
android:padding="10dp"
android:text="大家都快来啊"
android:textColor="@color/color_333333"
android:textSize="15sp" />
</com.example.verifykt.move.CenterHorizontalScrollView>