前言 轮式选择器,顾名思义,就是像滚轮一样的选择器。在老式的Android系统上,曾经有过轮式选择器样式的DatePicker (如下图)。
优点是系统封装好的,直接调用即可,缺点就是样式不好定制(例如我想改选中线的颜色长度,改选中文字的颜色字号大小什么的)。即使他提供有相应的修改接口,定制起来也是相当的麻烦。我也想过通过自定义View的方式来实现,但是苦于一直没办法做出无限循环的效果,另外即使自定义View,也同样会有自定义样式麻烦的问题。
是否没有办法了呢? 当然不是! 直到我遇到Google的天才程序员们设计出的一个全新的控件[RecyclerView](https://developer.android.com/guide/topics/ui/layout/recyclerview 需要全球互联网)。它能代替几乎所有的适配器型容器控件(ListView, GridView等),更能更自由的定制子元素样式(通过自定义ItemDecoration),它使得列表的布局方式定制更容易,仅仅需要自定义LayoutManager 即可。
有请主角LayoutManager登场 LayoutManager 是RecyclerView最重要的合作伙伴,通过切换LayoutManager,可以瞬间从ListView变成GridView,或者变成其他样子。正因为这样的高度可变性才使得实现真正好用的轮式选择器成为了可能。废话不多说,开始吧。
WheelLayoutManager 首先我还是要感谢 陈小缘 写的这篇文章 《看完让你直呼666的自定义LayoutManager之旅》 为我实现WheelLayoutManager 提供了思路。
自定义LayoutManager与自定义ViewGroup有许多共通之处,基本流程也是一致的:测量、布局、刷新(滚动)。
最终效果
需求分析 不过首先,我们还是要来做一个需求分析,对于轮式选择器,需要实现以下功能:
第一个元素垂直居中于控件,其余的按顺序往后排列
居中的元素高亮效果
居中的元素旁边添加一些装饰元素
滑动到某两个元素中间时,自动滚动到某个元素居中(即选中效果)
无限循环
垂直居中 这个其实很简单,后续的功能都要以此为基础。因为要考虑滑动的问题,所以需要设置一个变量scrollOffsetY 来记录当前的偏移值。当scrollOffsetY = 0
的时候,第一个元素垂直居中于控件。我们考虑设置一个基本参数visibleCount ,即轮式选择器当中可显示的元素个数,假定为5。
如图,假定每个元素的高度为一致的140px
,整个控件的高度为5 x 140 = 700 px
,那么第一个元素的顶部坐标即为 2 x 140 = 280 px
,此时定为scrollOffsetY = 0。(且不考虑无限循环模式)
这里需要算出一个元素的布局位置计算公式(顶部),在布局时带入当前的偏移值
和元素的index
即可算出特定的元素的布局位置。将前边所说的数值考虑进来,当scrollOffsetY=0时index为0的元素位置为280px,index为1的元素位置为420px,……由此可以类推出如下的通用公式:
1 2 3 4 5 6 7 8 private fun getLayoutTop (index: Int ) : Int { return index * requiredItemHeight + requiredMarginTop - scrollOffsetY }
其中:
requiredItemHeight:元素高度(140px)
requiredMarginTop:第一个元素居中所需要的距离(280px)
重要的全局参数 说到这几个全局参数,不得不把剩下的几个参数也在这里全部说明:
visibleCount:可见的元素个数,作为构造函数参数传进来的,必须为大于等于3的奇数,否则就没有任何意义(暂不考虑其他情况)
requiredSpaceCount:第一个元素垂直居中所需要补充的空白元素的个数,这里可见元素为5,因此这里需要的数量为2,至于计算公式:requiredSpaceCount = (visibleCount - 1) /2
requiredItemHeight:这里我们要求每个元素的高度相同,当我们只限定RecyclerView的高度的时候,就需要计算这个值:requiredItemHeight = height / visibleCount
requiredMarginTop:第一个元素垂直居中需要补充的空白元素的总高度,这里需要补充2个空白元素,所以总高度为280px,计算公式:requiredMarginTop = requiredItemHeight * requiredSpaceCount
布局所有元素 说完了重要参数,布局位置计算公式也有了,那么接下来就该布局代码上场了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 override fun onLayoutChildren (recycler: RecyclerView .Recycler , state: RecyclerView .State ) { if (state.itemCount == 0 ) { removeAndRecycleAllViews(recycler) return } calculateParams() detachAndScrapAttachedViews(recycler) if (state.itemCount > ((visibleCount + 1 ) / 2 )) { canScrollVertically = true } layoutChildren(recycler, state) }
这里的**layoutChildren()**方法不止在这里用得到,这是关键的方法,但是本质上也不复杂。在布局之前一定要通过detachAndScrapAttachedViews(recycler)
暂时分离和回收全部有效元素,然后再布局:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 private fun layoutChildren (recycler: RecyclerView .Recycler , state: RecyclerView .State ) { val (headIndex, tailIndex) = getLayoutRange(state.itemCount) posLayoutHead = if (headIndex >= 0 ) headIndex else 0 posLayoutTail = if (tailIndex >= 0 ) tailIndex else 0 if (headIndex >= 0 && tailIndex >= 0 ) { for (i in (headIndex..tailIndex)) { val child = getItemView(recycler, i) val decoratedWidth = getDecoratedMeasuredWidth(child) val childTop = getLayoutTop(i) val childBottom = childTop + getDecoratedMeasuredHeight(child) layoutDecorated(child, 0 , childTop, decoratedWidth, childBottom) } } val removalList = ArrayList<RecyclerView.ViewHolder>() removalList.addAll(recycler.scrapList) removalList.forEach { holder -> removeView(holder.itemView) recycler.recycleView(holder.itemView) } }
根据scrollOffsetY计算出布局的范围:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 private fun getLayoutRange (itemCount: Int ) : Pair<Int , Int > { var headIndex = -1 var tailIndex = -1 for (i in (0 until itemCount)) { val childTop = getLayoutTop(i) val childBottom = childTop + requiredItemHeight if (isLayoutInVisibleArea(childTop, childBottom)) { headIndex = i break } } for (i in (headIndex + 1 until itemCount)) { val childTop = getLayoutTop(i) if (childTop > height) { tailIndex = i - 1 break } } if (tailIndex < 0 ) { tailIndex = itemCount - 1 } return headIndex to tailIndex }
布局完成后,从recycler 的“废品列表”(scrapList )中拿出要回收的View,全部移除和回收掉。还记得刚才的detachAndScrapAttachedViews(recycler)
吗?所有分离和回收的都会暂时放到这里的scrapList中,而布局时获取View也是优先从这里取出“原料”,因此,布局完成后彻底回收时,从scrapList中找出所有的View移除和回收即可。至此,布局所有元素的任务便完成了。
让元素动起来 当你在屏幕上放上你的手指,然后拖动时,会触发回调方法scrollVerticallyBy ,在这个方法中,需要更新偏移值 ,然后重新布局元素 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 override fun scrollVerticallyBy (dy: Int , recycler: RecyclerView .Recycler , state: RecyclerView .State ) : Int { if (state.itemCount == 0 ) { return 0 } calculateParams() detachAndScrapAttachedViews(recycler) val lastOffset = scrollOffsetY updateScrollOffsetY(dy, lastOffset, state.itemCount) layoutChildren(recycler, state) return if (lastOffset == scrollOffsetY) 0 else dy }
你是否注意到,LinearLayoutManager滑动到边界(第一个元素或最后一个元素)时有个回弹效果,不用担心,回弹效果不需要我们来实现,我们只需要计算好最大偏移值和最小偏移值 即可。因为要实现垂直居中功能,前边我们计算了第一个元素垂直居中时,第一个元素距离顶部的位置(280px),那如果滑动到最后一个元素垂直居中时呢?也是一样的道理,最后一个元素的下边就需要2个元素的“补白”(也是280px),这样的效果需要设置一个溢出值:
1 2 3 4 5 6 7 8 9 10 11 private fun updateScrollOffsetY (dy: Int , lastOffsetY: Int , itemCount: Int ) { scrollOffsetY += dy val childrenHeight = itemCount * requiredItemHeight val maxOverflowHeight = childrenHeight - requiredItemHeight if (scrollOffsetY < 0 ) { scrollOffsetY = 0 } else if (scrollOffsetY > maxOverflowHeight) { scrollOffsetY = if (maxOverflowHeight > 0 ) maxOverflowHeight else lastOffsetY } }
往下滑自然不必多说,当滑到小于0的位置时,强制把偏移值设置为0,那么滑动到顶部就不能继续再往下滑了。往上滑滑动最后一个元素居中时,根据前边算出的顶部距离公式(getLayoutTop),带入最后一个元素的Index值,这里设scrollOffsetY为x:
$$ (itemCount - 1) * requiredItemHeight + requiredMarginTop - x = requiredMarginTop $$
计算出最大偏移值:
$$ x = itemCount * requiredItemHeight -requiredItemHeight $$
重新布局元素,直接再调用**layoutChildren(recycler, state)**即可,没有任何区别。
选中效果,居中元素装饰,居中元素高亮 之所以我要把这3个功能放到一起,因为元素装饰和元素高亮是以选中效果为基础的。怎么做选中效果呢,根据我们的设计,只有当scrollOffsetY的值为一些特定的值的时候(280px,420px,560px,……),元素的布局才会呈现给我们一种居中选中的特殊视觉效果。
所以滑动到某个偏移值的时候,可以计算出这个值最接近的某元素居中所需要的偏移值,然后通过动画,滑动到这个特定偏移值。
选中效果 1 2 3 4 5 6 7 8 9 10 11 12 private fun findClosestItemPosition () : Int { var estimatedPosition = -1 var minDistance = Int .MAX_VALUE for (i in (0 until itemCount)) { val distance = Math.abs(getRequiredScrollOffset(i) - scrollOffsetY) if (distance < minDistance) { minDistance = distance estimatedPosition = i } } return estimatedPosition }
1 2 3 4 5 6 private fun getRequiredScrollOffset (targetPosition: Int ) : Int { return targetPosition * requiredItemHeight }
滑动停止时调用,然后生成动画并执行:
1 2 3 4 5 6 7 8 9 10 11 12 override fun onScrollStateChanged (state: Int ) { super .onScrollStateChanged(state) when (state) { RecyclerView.SCROLL_STATE_DRAGGING -> { draggingStartListener?.invoke() stopScrollAnimation() } RecyclerView.SCROLL_STATE_IDLE -> { startScrollAnimation(findClosestItemPosition(), itemCount) } } }
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 private fun startScrollAnimation (position: Int , itemCount: Int ) { if (position < 0 ) { return } stopScrollAnimation() val scrollDistance = (getRequiredScrollOffset(position) - scrollOffsetY) scrollValueAnimator = ValueAnimator.ofInt(0 , scrollDistance).setDuration(300 ) scrollValueAnimator.addUpdateListener(ScrollAnimatorUpdateListener { deltaValue -> updateScrollOffsetY(deltaValue, scrollOffsetY, itemCount) requestLayout() }) scrollValueAnimator.addListener(object : AnimatorListenerProxy() { override fun onAnimationEnd (animation: Animator ) { selectedPosition = position } }) scrollValueAnimator.start() } private class ScrollAnimatorUpdateListener (val valueUpdated: (Int ) -> Unit ) : ValueAnimator.AnimatorUpdateListener { private var lastValue: Int = 0 override fun onAnimationUpdate (animation: ValueAnimator ) { val currentValue = animation.animatedValue as Int if (currentValue != 0 ) { valueUpdated(currentValue - lastValue) } lastValue = currentValue } }
居中元素高亮 本质上还是依托于选中效果,还记得前边我们用一个全局变量selectedPosition 记录了选中的元素吗,同时暴露了一个回调方法selectionChangedListener 给外界,在外界调用更新:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 yearLayoutManager = WheelLayoutManager(5 ).apply { selectionChangedListener = this @MainActivity ::onYearSelectionChanged draggingStartListener = { deselectIndex(yearAdapter) } } ...... private fun onYearSelectionChanged (position: Int ) { val selectedValue = bindModel.yearAdapter?.getValue(position) ?: -1 if (selectedValue > 0 ) { selectedYear = selectedValue onDayChanged() selectIndex(bindModel.yearAdapter, position) bindModel.yearDisplay = selectedYear } }
这里我使用了DataBinding 来实现高亮效果,不过多描述,建议看源码(源码地址在文末):
1 2 3 4 5 6 7 8 9 10 <TextView android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:text ="@{@string/int_to_str(model.value)}" android:textColor ="@{model.selected ? @color/hex_db262e : @color/hex_9c9c9c}" android:textSize ="@dimen/px_53" app:layout_constraintBottom_toBottomOf ="parent" app:layout_constraintEnd_toEndOf ="parent" app:layout_constraintStart_toStartOf ="parent" app:layout_constraintTop_toTopOf ="parent" />
居中元素装饰 比如这里做的日历选择器,要添加背景颜色,高亮区域背景颜色,以及其他装饰性图案,都需要一个坐标,在LayoutManager中将计算好的相关参数暴露给外界即可。然后自定义ItemDecoration 来实现:
1 2 3 4 5 6 7 val selectionTop: Int get () = requiredMarginTop val itemHeight: Int get () = requiredItemHeight
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 override fun onDrawOver (c: Canvas , parent: RecyclerView , state: RecyclerView .State ) { val layoutManager = parent.layoutManager as ? WheelLayoutManager ?: return val highlightLineTop = layoutManager.selectionTop + 0f val highlightLineBottom = highlightLineTop + layoutManager.itemHeight + 0f val parentWidth = parent.width + 0f val parentHeight = parent.height + 0f c.drawLine(0f , 0f , parentWidth, 0f , highlightLinePaint) c.drawLine(0f , highlightLineTop, parentWidth, highlightLineTop, highlightLinePaint) c.drawLine(0f , highlightLineBottom, parentWidth, highlightLineBottom, highlightLinePaint) c.drawLine(0f , parentHeight, parentWidth, parentHeight, highlightLinePaint) if (enableHighlightMarker) { c.drawRect( 0f , highlightLineTop, highlightMarkerWidth.toFloat(), highlightLineBottom, highlightMarkerPaint ) } if (enableHintText) { val textY = highlightLineTop + layoutManager.itemHeight / 2 + hintTextDrawingOffsetY val textX = parent.width - hintTextPaint.measureText(hintText) - hintTextRightMargin c.drawText(hintText, textX, textY, hintTextPaint) } }
其他就不贴代码了,请参考源码。
无限循环 标准模式下的实现相对容易,如图
但是无限循环,乍看之下不知道从何下手,既然如此,那干脆先把图画出来,再来分析。
补充布局
如图,普通模式第一个元素是1,从上往下依次是13,1的上边应该是最后一个元素31,再往上是30、29、……。所以说无限循环的本质就是头接尾、尾接头。 先考虑scrollOffsetY = 0
的情况,上图即是,13的布局保持不变,重点是1上边的31、30。我姑且把它们称之为负序列布局 ,把之前的layoutChildren
方法稍微修改一下:
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 37 private fun layoutChildren (recycler: RecyclerView .Recycler , state: RecyclerView .State ) { val (headIndex, tailIndex) = getLayoutRange(state.itemCount) posLayoutHead = if (headIndex >= 0 ) headIndex else 0 posLayoutTail = if (tailIndex >= 0 ) tailIndex else 0 if (headIndex >= 0 && tailIndex >= 0 ) { for (i in (headIndex..tailIndex)) { val child = getItemView(recycler, i) val decoratedWidth = getDecoratedMeasuredWidth(child) val childTop = getLayoutTop(i) val childBottom = childTop + getDecoratedMeasuredHeight(child) layoutDecorated(child, 0 , childTop, decoratedWidth, childBottom) } } if (isInfiniteScrollEnabled(state.itemCount)) { val (negHeadIndex, negTailIndex) = getNegativeLayoutRange(state.itemCount) negLayoutHead = if (negHeadIndex >= 0 ) negHeadIndex else 0 negLayoutTail = if (negTailIndex >= 0 ) negTailIndex else 0 if (negHeadIndex >= 0 && negTailIndex >= 0 ) { for (i in (negTailIndex downTo negHeadIndex)) { val child = getItemView(recycler, i) val decoratedWidth = getDecoratedMeasuredWidth(child) val childTop = getNegativeLayoutTop(i, state.itemCount) val childBottom = childTop + getDecoratedMeasuredHeight(child) layoutDecorated(child, 0 , childTop, decoratedWidth, childBottom) } } } val removalList = ArrayList<RecyclerView.ViewHolder>() removalList.addAll(recycler.scrapList) removalList.forEach { holder -> removeView(holder.itemView) recycler.recycleView(holder.itemView) } }
计算负序列布局的布局范围:
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 private fun getNegativeLayoutRange (itemCount: Int ) : Pair<Int , Int > { var headIndex = -1 var tailIndex = -1 for (i in (itemCount - 1 downTo 0 )) { val childTop = getNegativeLayoutTop(i, itemCount) val childBottom = childTop + requiredItemHeight if (isLayoutInVisibleArea(childTop, childBottom)) { tailIndex = i break } } for (i in (tailIndex - 1 downTo 0 )) { val childTop = getNegativeLayoutTop(i, itemCount) val childBottom = childTop + requiredItemHeight if (childBottom < 0 ) { headIndex = i + 1 break } } if (headIndex < 0 ) { headIndex = 0 } return headIndex to tailIndex }
还需要一个负序列位置计算公式:
1 2 3 4 5 6 7 8 9 private fun getNegativeLayoutTop (index: Int , itemCount: Int ) : Int { return (requiredSpaceCount - 1 ) * requiredItemHeight - scrollOffsetY - (itemCount - 1 - index) * requiredItemHeight }
这个负序列位置计算就不能再依靠之前的位置公式了,因为负序列采用了不同的定位方式:从下往上 。从最后一个元素开始,往前布局。还是采用前边的假设,所以31的顶部位置为140px,30的位置为0px,29的位置为-140px。由此可以推算出上述的计算公式。
偏移值临界点 初步的布局已经成功了,接下来就该让它动起来了。如果我手指拖动往下滑,滑到scrollOffsetY < 0 还继续往下滑,当滑动了一圈之后发现,咦?怎么上边没了?
别着急,我们分析一下为什么没了?看看Logcat,这个时候的scrollOffsetY值大概已经是-18xx了吧。往下滑,当前布局的就是负序列,再往前,负序列前边却没办法再补充一个负序列了,所以才会出现没了。同理,如果你往上滑,滑动到正序列的尾部的时候,也会没了,正序列后边不可能再补充一个正序列。说到这里,你可能会问我,那可怎么办?
其实答案很简单,我们需要处理一下scrollOffsetY 的值,限定它的范围。先来看看一个临界点位置:
如图,可见区域的元素是27、28、29、30、31
,这时候scrollOffsetY的值是多少?答案是有两种可能,-420px或者3920px,往下滑-420px(完全布局负序列),往上滑3920px(完全布局正序列)。也就是说,无论当前是-420px还是3920px,我们看到的元素排列样子完全相同。
那么,用这两个数值作为scrollOffsetY的范围能行吗?
答案是肯定的,如果scrollOffsetY的值大于3920px,就强制变为大于-420px的数;小于-420px,就强制变为小于3920px的数。 分别根据正负序列位置计算公式,算出正负阈值。稍微修改一下**updateScrollOffsetY()**方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 private fun updateScrollOffsetY (dy: Int , lastOffsetY: Int , itemCount: Int ) { scrollOffsetY += dy val childrenHeight = itemCount * requiredItemHeight if (isInfiniteScrollEnabled(itemCount)) { val negLenThreshold = (requiredSpaceCount + 1 ) * requiredItemHeight val posLenThreshold = childrenHeight - negLenThreshold val mod = scrollOffsetY % requiredItemHeight if (scrollOffsetY > posLenThreshold) { scrollOffsetY = -negLenThreshold + mod } else if (scrollOffsetY <= -negLenThreshold) { scrollOffsetY = posLenThreshold - mod } } else { val maxOverflowHeight = childrenHeight - requiredItemHeight if (scrollOffsetY < 0 ) { scrollOffsetY = 0 } else if (scrollOffsetY > maxOverflowHeight) { scrollOffsetY = if (maxOverflowHeight > 0 ) maxOverflowHeight else lastOffsetY } } }
如此,真正的无限循环便实现了。
修改选中效果 虽然无限循环的效果是实现了,但还有一个工作需要完成:选中效果只做了普通模式,无限循环模式的选中效果还需要修改。
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 private fun createScrollAnimation () { if (isInfiniteScrollEnabled(itemCount)) { val negLenThreshold = (requiredSpaceCount + 1 ) * requiredItemHeight val posLenThreshold = itemCount * requiredItemHeight - negLenThreshold val criticalValue = -requiredItemHeight / 2 var targetPosition = -1 var minDistance = Int .MAX_VALUE if (scrollOffsetY > -negLenThreshold && scrollOffsetY < criticalValue) { for (i in (negLayoutTail downTo negLayoutHead)) { val distance = Math.abs(getRequiredScrollOffset(i) - scrollOffsetY) if (distance < minDistance) { minDistance = distance targetPosition = i } } if (targetPosition < 0 ) { targetPosition = 0 } } else if (scrollOffsetY in criticalValue..posLenThreshold) { for (i in (posLayoutHead..posLayoutTail)) { val distance = Math.abs(getRequiredScrollOffset(i) - scrollOffsetY) if (distance < minDistance) { minDistance = distance targetPosition = i } } } startScrollAnimation(targetPosition, itemCount) } else { startScrollAnimation(findClosestItemPosition(), itemCount) } }
修改获取特定偏移值:
1 2 3 4 5 6 7 private fun getRequiredScrollOffset (targetPosition: Int ) : Int { return if (scrollOffsetY >= -requiredItemHeight / 2 ) { targetPosition * requiredItemHeight } else { -(itemCount - targetPosition) * requiredItemHeight } }
适配元素数量变化 基本功能都已经全部实现了,但还有最后一项不可忽视的工作:适配元素数量变化。当元素数量产生变化时,同时必然会导致布局产生变化,然后会引起选中元素变化。适配需要复写的方法主要有两个:onItemsAdded 和onItemsRemoved 。
1 2 3 4 5 6 7 8 9 override fun onItemsAdded (recyclerView: RecyclerView , positionStart: Int , itemCount: Int ) { super .onItemsAdded(recyclerView, positionStart, itemCount) fixSelection(positionStart, itemCount) } override fun onItemsRemoved (recyclerView: RecyclerView , positionStart: Int , itemCount: Int ) { super .onItemsRemoved(recyclerView, positionStart, itemCount) fixSelection(positionStart, -itemCount) }
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 fixSelection (positionStart: Int , changeLength: Int ) { val positionEnd = positionStart + Math.abs(changeLength) var newPosition = selectedPosition if (changeLength > 0 ) { if (selectedPosition >= positionEnd) { newPosition += Math.abs(changeLength) } } else if (changeLength < 0 ) { if (selectedPosition in (positionStart until positionEnd)) { newPosition = if (itemCount - 1 - positionEnd > positionStart) { positionEnd } else { positionStart - 1 } } else if (selectedPosition >= positionEnd) { newPosition -= Math.abs(changeLength) } } updateScrollOffsetY(0 , 0 , itemCount) scrollToPosition(newPosition) }
源码地址 源码地址