RecyclerView 实现WheelView和省市区多级联动

作者:丨小夕

前言

滚轮经常在选择中用到,主要包括 类型选择 省市区联动选择 年月日联动选择 等。

项目中的 WheelView 一般都是 ScrollView+LinearLayout 组合完成的。

但是自定义起来比较复杂,也有一些优秀的第三方库DateSelecter 通过 Adapter 的思想来灵活解决自定义的问题。

但是既然用到了 Adapter 的思想,那为啥不利用 RecyclerView 来实现呢?,毕竟我们比较熟悉 RecyclerView.Adapter 也方便和项目中现有的 Adapter 复用。

于是我基于 RecyclerView 实现了一个滚轮模块,这些是他的基础功能:

  • RecyclerView Adapter 低侵入性,逻辑单独封装成 RecyclerWheelViewModule
  • 支持通过 Adapter 自定义 WheelView 样式
  • 支持横向和竖向
  • 支持自定义 WheelView 边框

同时在滚轮模块的基础上,实现了联动滚轮 View 的封装。

效果

滚轮模块的用法也很简单,同时侵入性极低,使用拓展就能 recyclerView.setupWheelModule() RecyclerView 改造成 WheelView

它会返回 RecyclerWheelViewModule 里面包含了操作滚轮模块的各种API

class XxxActivity {
    val recyclerView: RecyclerView
    val wheelAdapter =
        BindingAdapter<String, ItemWheelVerticalBinding>(ItemWheelVerticalBinding::inflate) { _, item ->
            itemBinding.text.text = item
            itemBinding.text.setTextColor(if (isWheelItemSelected) Color.BLACK else Color.GRAY)
    fun onCreate() {
        recyclerView.adapter = wheelAdapter
        val wheelModule = recyclerView.setupWheelModule()
        wheelModule.apply {
            offset = 1
            orientation = RecyclerWheelViewModule.VERTICAL
            setWheelDecoration(DefaultWheelDecoration(10.dp, 10.dp, 2.dp, "#dddddd".toColorInt()))
            onSelectChangeListener = {
}

原理

WheelView 的功能本身并不复杂, 布局 滚动 都是 RecyclerView 已经处理好的。

因此我们只需要解决一些 WheelView 的特性即可。

本着代码越少,Bug越上的原则。绝大多数特性都尽量使用 RecyclerView 提供的API,或者官方已有的模块去实现。

实现一个 WheelView 的功能主要完成以下实现:

  • 选中的 Item 居中显示,在滚动后自动居中选中的位置
  • Item 的最上和最下有一定滚动间距,来使得最边缘的 Item 可以居中,同时让 WheelView 的尺寸恰好显示3个或5个 Item
  • 支持绘制上下边界线来标识给用户滚轮选中区域
  • 用户滑动时,更新选中位置,并刷新 Item 数据,如加粗或者设置字体颜色为黑色
  • 支持代码设置和获取当前选中位置

Item居中

WheelView 在滚动停止后,会自动使得当前最靠近中间的 Item 滚动到布局的中心位置。

官方提供了 SnapHelpe 来帮助我们处理 Item 的对齐。

而它的子类 LinearSnapHelper 可以监听 RecyclerView 的滚动,在滚动结束后,使得最接近 RecyclerView 视图中心的那个 Item 的中心对齐到视图中心,简单描述就是 它能使Item居中

同时它也提供了很多有用的API的可重写的方法,我们通过重写 onFling 可以控制一下滚动速度。

class WheelLinearSnapHelper : LinearSnapHelper() {
    override fun onFling(velocityX: Int, velocityY: Int): Boolean =
        super.onFling(
            (velocityX * flingVelocityFactor).toInt(),
            (velocityY * flingVelocityFactor).toInt()
}

上下留白

这里的留白是指,在 WheelView 的开始和结束位置有一定的空白,以便于最后一个 Item 能滚动到中心。

因为留白的存在,当 RecyclerView 滚动到最上面时,第一个 Item 刚好处于中间位置

上下留白的数量我们定义为 offset ,从另外一个角度来看可以将留白看成 offset Header offset Footer

这里的每个留白的高度一般就是 Item 的高度。

一般情况 WheelView 的每个 Item 的高度是一致的,我们取第一个 Item 的高度作为留白的高度。

以下是 Header/Footer Adapter 实现:

class OffsetAdapter(private val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder>) :
    RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        adapter.createViewHolder(parent, viewType)
    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        if (adapter.itemCount > 0) {
            adapter.onBindViewHolder(holder, 0)
        holder.itemView.visibility = View.INVISIBLE
        measureItemSize(holder.itemView) //测量和记录一下留白的高度
    override fun getItemCount(): Int = offset
}

通过 ConcatAdapter 依次连接 HeaderAdapter + 数据Adapter + FooterAdapter 就实现了留白功能。

在项目中,我们想给RecyclerView 的开始和结束加padding 也可以通过这种方式。

然后重新设置 RecyclerView Adapter

fun setAdapter(adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>?) {
    val startOffsetAdapter = OffsetAdapter(adapter)
    val endOffsetAdapter = OffsetAdapter(adapter)
    recyclerView.adapter = ConcatAdapter(
        startOffsetAdapter,
        adapter,
        endOffsetAdapter
}

一般情况下我们的 WheelView 的高度都是中间选中 Item 的高度加上上下额外显示 offset Item 的高度。

也就是一共显示 offset+1+offset Item

所以一般 WheelView 高度为 (offset+1+offset)*itemSize

这里我们在 OffsetAdapter 测量出 Item 尺寸后顺便设置一下 WheelView 的高度。

fun measureItemSize(itemView: View) {
    //....
    itemSize = itemView.measuredHeight + margin
    recyclerView.layoutParams = recyclerView.layoutParams.apply {
        width = (offset + offset + 1) * itemSize
}

绘制边界线

边框是一般指 WheelView 中间有2条边界线,用来标识 WheelView 选中区域。用来告知用户,滚动到这2个边界线中间的是选中的 Item

RecyclerView 中绘制在 Item 之上的内容我们可以使用 RecyclerView.ItemDecoration ,所以几乎也不需要我们实现。

我们主要主要根据 itemSize 去计算当前上下边框位置来绘制即可。

class DrawableWheelDecoration(
    val drawable: Drawable,
    @Px private val size: Int,
) : WheelDecoration() {
    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        //...
        val center = parent.width / 2
        val left = center - wheelItemSize / 2
        val right = center + wheelItemSize / 2
        drawable.setBounds(left, top, right, top + size,)
        drawable.draw(canvas)
        drawable.setBounds(left, bottom, right, bottom + size)
        drawable.draw(canvas)
}

用户滑动更新选中

用户滑动更新选中包括 滚动的过程中选中 ,和 滚动结束后选中

  • 滚动的过程中选中:一般用于实时更新 Item 选中状态。
  • 滚动结束后选中:一般用于选择回调。

滚动的过程中选中

先通过 SnapHelper.findSnapView() 可以获取当前 RecyclerView 视图中心的 View

然后通过 layoutManager.getPosition(snapView) 来获取 View 的位置,就是选中的位置 currentSelectedPosition

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
    if (itemSize == 0) {
        return
    val layoutManager = recyclerView.layoutManager ?: return
    val globalAdapter = recyclerView.adapter ?: return
    val dataAdapter = dataAdapter ?: return
    val snapView = wheelLinearSnapHelper.findSnapView(layoutManager) ?: return
    val snapViewHolder = recyclerView.getChildViewHolder(snapView)
    val snapViewPositionGlobal = layoutManager.getPosition(snapView)
    val snapViewPosition = globalAdapter.findRelativeAdapterPositionIn(
        dataAdapter,
        snapViewHolder,
        snapViewPositionGlobal
    if (snapViewPosition == RecyclerView.NO_POSITION) {
        return
    updateSelectPosition(snapViewPosition)
@SuppressLint("NotifyDataSetChanged")
private fun updateSelectPosition(selectedPositionNew: Int) {
    if (currentSelectedPosition != selectedPositionNew) {
        currentSelectedPosition = selectedPositionNew
        onScrollingSelectListener?.invoke(selectedPositionNew)
        //...
}

滚动过程的的选中往往是用来更新 WheelView 的选中状态的。

如果是 BindingAdapter 的情况,我们可以添加 ViewHolder.isWheelItemSelected 拓展属性来方便在 onBindViewHolder 时获取当前是否 Item 是否选中。

var BindingViewHolder<*>.isWheelItemSelected: Boolean
    set(value) { setTag(R.id.binding_adapter_view_holder_tag_wheel_selected, value) }
    get() = getTag(R.id.binding_adapter_view_holder_tag_wheel_selected) == true

滚动结束后选中

在滚动停止时的 currentSelectedPosition 就是滚动结束后选中 currentDeterminePosition

override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
    super.onScrollStateChanged(recyclerView, newState)
    if (newState == RecyclerView.SCROLL_STATE_IDLE) {
        notifySelectDetermine(currentSelectedPosition)
private fun notifySelectDetermine(value: Int) {
    if (currentDeterminePosition == value) {
        return
    currentDeterminePosition = value
    onSelectChangeListener?.invoke(value)
}

设置选中位置

除了用户滑动去选中 Item ,有时候我们需要默认回显之前选中的位置,也就是代码设置选中的位置。 其关键在于如何使得 目标 View 滚动到 RecyclerView 中心

可以先了解下 RecyclerView 的滚动到指定位置的API。

  • scrollToPosition 使得位于 position View 显示在 RecyclerView 中,无法控制具体显示在上面中间还是下面。
  • scrollToPositionWithOffset 使得位于 position View 显示在 RecyclerView 的顶部,并且通过 offset 控制偏移距离。
  • smoothScrollToPosition 使得位于 position View 滑动到 RecyclerView 中,无法控制具体显示在上面中间还是下面。

可见 RecyclerView 没有提供直接使得目标 View 滚动到中心的API,因此我们需要自己实现。


实现前先稍微介绍下 RecyclerView 的一些布局概念:

RecyclerView 重点在 Recycler Adapter 里面即使有1万个数据,实际上 RecyclerView 的子 View 也只有几个,只是复用了同样 View 去绑定不同数据。 这是和用 LinearLayout 来实现 WheelView 的一个比较大的区别

也就是想设置选中位置为 position=1000 ,并不能直接通过找 position=1000 View 然后计算的距离来滚动的。 因为 position=1000 可能在屏幕外面很远,所以对应的 View 可能还不存在,或者说是还没有 Layout RecyclerView 中。

你可能会疑问,那 scrollToPosition 是咋实现的呢?

可以看看源码,它实际是设置了标记位 mPendingScrollPosition ,然后触发 requestLayout() ,来使得 RecyclerView mPendingScrollPosition 开始布局子 View

因此我们实现时还需要考虑 View还没有Layout到RecyclerView 的情况。

幸运的是它提供了 layoutManager.findViewByPosition(position) 这个API来得知是否目标 View 是否 Layout RecyclerView ,如果已经 Layout 返回其 View 引用。


非平滑滚动(smooth=false)

我们通过 scrollToPositionWithOffset 来触发目标位置的 View 进行 Layout ,然后再下一次 Layout 时找到目标位置的 View ,并使它滚动到中心。

doOnNextLayout 是core-ktx 实现的一个拓展函数,可以监听下一次onLayout
layoutManager.scrollToPositionWithOffset(position, 0)
recyclerView.doOnNextLayout {
    //...
    recyclerView.scrollToPositionCenter(wheelLinearSnapHelper, adapter, position)
}

然后实现滚动到中心的逻辑,利用 snapHelper.calculateDistanceToFinalSnap 计算目标view到中心的距离,然后通过 scrollBy 滚动即可

internal fun RecyclerView.scrollToPositionCenter(
    snapHelper: SnapHelper,
    adapter: RecyclerView.Adapter<*>,
    position: Int
    val globalAdapter = this.adapter ?: return
    val globalPosition =
        globalAdapter.findGlobalAdapterPositionInCompatSelf(adapter, position)
    val layoutManager = this.layoutManager as? LinearLayoutManager ?: return
    val view = layoutManager.findViewByPosition(globalPosition)
        ?: return
    val snapDistance =
        snapHelper.calculateDistanceToFinalSnap(layoutManager, view)
            ?: return
    scrollBy(snapDistance[0], snapDistance[1])
}
经过了scrollToPositionWithOffset和doOnNextLayout,这里的目标View应该是已经Layout到RecyclerView中的, 也就是layoutManager.findViewByPosition(globalPosition) 理论上不会返回null。 如果使用了ConcatAdapter,findViewByPosition使用的position需要注意使用全局的position,具体如何转换可以查看本项目源码,这里不展开讲。

平滑滚动(smooth=true)

平滑滚动在设置选中目标 View 时,它是通过 动画 慢慢滑动到对应的 View 的。

View 是已经 Layout 的情况下,和非平滑滚动的处理方式一样,只需把上面的 scrollBy 改成 smoothScrollBy 即可实现滚动动画。

View 是没有 Layout 的情况,稍微复杂一点,先看看 RecyclerView 提供的API。

查看源码可以看到 recyclerView.smoothScrollToPosition 最终是调用的 layoutManager.startSmoothScroll(linearSmoothScroller)

也就是将滑动逻辑封装在了 LinearSmoothScroller 中,查看 LinearSmoothScroller 源码可以发现,它会通过动画慢慢向目标位置滚动。 边滚动边触发那些快要进入 RecyclerView 可见范围的 View 进行数据绑定和 Layout , 直到发现目标位置的 View 时会回调 onTargetFound

因为发现的时候,可能是在RecyclerView的边缘,这个时候它通过 calculateDyToMakeVisible

去计算能使得这个 View 完全显示所需要的滚动距离。

从方法名也可以看出,calculateDyToMakeVisible只是为了让View变得可见,而不是让View变得到顶部或底部或正中间,所以它只能保证是可见的。

calculateDyToMakeVisible calculateDxToMakeVisible 只是适配了横向和竖向,最终都调用了 calculateDtToFit 来计算滚动距离的计算。

calculateDtToFit 的默认实现就是只是滚动到让 View 可见的距离,比如 View 顶部和 RecyclerView 顶部对其时的距离。

我们的目的是让 View 在正中间,所以需要计算子 View 的中心位置和 RecyclerView 的中心位置的距离。

private class CenterLinearSmoothScroller(context: Context?) : LinearSmoothScroller(context) {
    override fun calculateDtToFit(
        viewStart: Int,
        viewEnd: Int,
        boxStart: Int,
        boxEnd: Int,
        snapPreference: Int
    ): Int {
        val childCenter = viewStart + ((viewEnd - viewStart) / 2)
        val containerCenter = boxStart + ((boxEnd - boxStart) / 2)
        return containerCenter - childCenter
}

最后实例化这个 CenterLinearSmoothScroller 使用 layoutManager.startSmoothScroll 来实现滑动到中心。

internal fun RecyclerView.smoothScrollToPositionCenter(
    snapHelper: SnapHelper,
    duration: Int,
    position: Int
    if (position < 0) {
        return
    val layoutManager = this.layoutManager as? LinearLayoutManager ?: return
    val view = layoutManager.findViewByPosition(position)
    if (view == null) {
        val linearSmoothScroller = CenterLinearSmoothScroller(this.context)
        linearSmoothScroller.targetPosition = position
        layoutManager.startSmoothScroll(linearSmoothScroller)
        return
    val snapDistance =
        snapHelper.calculateDistanceToFinalSnap(layoutManager, view)
            ?: return
    this.smoothScrollBy(snapDistance[0], snapDistance[1], null, duration)
}

多个滚轮的联动封装

联动就是多个滚轮的数据是有联系的,比如 年月日 省市区 等。

在一个 滚轮的选中变化 时, 被联动的滚轮数据也会进行变化

以省市区为例, 的数据是根据选的 定的, 的数据是根据选的 定的。

也就是这种联动关系本质也是一种依赖关系, A依赖[],B依赖[A],C依赖[A,B],D依赖[A,B,C],...

这里为了方便我们就不单独成一个模块了,而是直接继承 LinearLayout 来线性放置多个滚轮 WheelView

然后使用去存储来支持任意个 WheelView 的联动。

为了方便存储和获取各个 WheelView 的属性,我们使用 WheelWrapper 来稍微包装一下每一个 WheelView , 并存储到 List<WheelWrapper> 中。

同时我们将各个 WheelView 上述的依赖关系也存储起来,方便加载数据的时候使用。

class LinkageWheelView : LinearLayout(context, attrs) {
    //...
    val children: MutableList<WheelWrapper> = ArrayList()
    class WheelWrapper internal constructor(
        parent: WheelWrapper?,
        private val dataProvider: DataProvider,
        private val wheelModule: RecyclerWheelViewModule,
        private val adapter: BindingAdapter<CharSequence, *>,
        private val parents: Array<WheelWrapper>
        //...
}

因为 WheelView 的数据是不定的,因此每个 WheelView 提供数据时不是直接提供固定的 List 数据,而是需要提供一个 DataProvider 作为数据加载器,根据其依赖的的 WheelView 去加载。

class LinkageWheelView {
    //...
    private val currentData: MutableList<DataProvider> = ArrayList()
    fun interface DataProvider {
        fun onLoad(
            cancellationSignal: CancellationSignal,
            parents: Array<WheelWrapper>,
            loadCallback: (List<CharSequence>) -> Unit
}
CancellationSignal 是Android的一个帮助实现取消的工具。

比如 WheelView 根据省和市来加载的 DataProvider 大约是这样的:

DataProvider { cancel, parents, callback ->
    val (provinceWheel, cityWheel) = parents
    val counties = getCounties(provinceWheel.selectItem, cityWheel.selectItem)
    callback(counties)
}

然后设置 DataProvider 的时候,根据每个 DataProvider 去实例化 WheelView

然后按顺序依次建立联动:也就是其依赖的 WheelView 选中数据变化时,重新加载数据。

fun setData(currentData: List<DataProvider>) {
    //...
    var parent: WheelWrapper? = null
    for ((index, dataProvider) in currentData.withIndex()) {
        //创建一个WheelView,addView 添加到布局
        val wheelItem = instantWheelWrapper(index, factory, parent, dataProvider)
        parent?.observeDetermineChange {
            //联动的WheelView选中数据变化时,重新加载本WheelView的数据
            wheelItem.loadData()
        children.add(wheelItem)
        parent = wheelItem
    //加载初始数据
    children.firstOrNull()?.loadData()
}

observeDetermineChange 的作用是设置监听器,监听当前选中数据的变化,一般就是 WheelView 选中变化和数据更新2种情况

class WheelWrapper {
    private var selectChangeListener: (() -> Unit)? = null
    init {
        wheelModule.onSelectChangeListener = { selectChangeListener?.invoke() }
    internal fun observeDetermineChange(listener: () -> Unit) {
        selectChangeListener = listener
    internal fun loadData() {
        //...
        dataProvider.onLoad(cancellationSignal, parents) { data ->
            //...
            adapter.replaceData(data)
            selectChangeListener?.invoke()
}

至此滚轮联动的核心逻辑就完成了。

设置选中位置

但是往往我们选中 省市区 时,需要 回显当前选中的数据 ,因此我们还需要支持设置选中位置。

在前面的 WheelView 中我们已经实现了设置单个 WheelView 的选中位置。 因此如果 WheelView 之间关联不大,能直接各自设置自己位置就简单了 比如3个 WheelView 都是选择一个 0~9 的数,那么只需要计算其位置设置上去即可

fun setCurrentPositions(positions: List<Int>, smooth: Boolean = true) {
    for (i in 0 until positions.size.coerceAtMost(children.size)) {
        children[i].setSelectedPosition(positions[i], smooth)
}

但是联动的情况,选中 广东省-韶关市-曲江区 的流程是这样的:

  1. 先计算 广东省 [广西省,广东省,...] 的位置,然后设置第1个 WheelView 的选中
  2. 第2个 WheelView 监听到第1个 WheelView 的选中变化了,于是加载市的数据 [韶关市,广州市,珠海市,...]
  3. 计算 韶关市 [韶关市,广州市,珠海市,...] 的位置,然后设置第2个 WheelView 的选中
  4. 第3个 WheelView 监听到第2个 WheelView 的选中变化了,于是加载区的数据 [浈江区,曲江区,武江区,...]
  5. 计算 曲江区 [浈江区,曲江区,武江区,...] 的位置,然后设置第3个 WheelView 的选中

因此我们需要实现一个API,能单独设置某个 WheelView 的选中位置,然后其引导的数据加载完成后,需要回调给调用者。

/**
 * 设置指定WheelView位置
 * @param childIndex 指定index的滚轮
 * @param position 滚轮需要滑动到的位置
 * @param smooth 是否平滑滚动
 * @param childDataLoad 滚轮滑动完成回调,并且子滚轮数据已经加载完成。如果当前滚轮已经是最后的,那么不会触发回调
fun setCurrentPosition(
    childIndex: Int,
    position: Int,
    smooth: Boolean = true,
    childDataLoad: ((WheelWrapper) -> Unit)
    //...
    children.getOrNull(childIndex)?.setSelectedPosition(position, smooth)
    //省略其他情况...
    val after = children.getOrNull(childIndex + 1)?.scheduleDataLoadCallback {
        childDataLoad(after)
}

scheduleDataLoadCallback 就是放置一个 callback 来监听数据加载。

class WheelWrapper {
    private var dataLoadedCallback: ((data: List<CharSequence>) -> Unit)? = null
    fun scheduleDataLoadCallback(callback: (data: List<CharSequence>) -> Unit) {
        dataLoadedCallback = callback
    internal fun loadData() {
        //...
        dataProvider.onLoad(cancellationSignal, parents) { data ->
            //...
            adapter.replaceData(data)
            dataLoadedCallback?.invoke(data)
            dataLoadedCallback = null
}

使用起来就像这样:

val p0 = linkageWheelView.wheels.first().items.indexOf("广东省")
linkageWheelView.setCurrentPosition(0, p0) {
    val p1 = it.items.indexOf("韶关市")
    linkageWheelView.setCurrentPosition(1, p1) {
        val p2 = it.items.indexOf("曲江区")
        linkageWheelView.setCurrentPosition(2, p2) {
}

好像方式丑了一点,那就再用 DSL 拓展优化一下:

linkageWheelView.setCurrentPositions {
    setPosition { it.indexOf("广东省") }
    setPosition { it.indexOf("韶关市") }
    setPosition { it.indexOf("曲江区") }
}

linkageWheelView.setData 也顺便通过DSL 拓展简化了下:

linkageWheelView.setData {
    provideData { arrayOf("Wheel0-0", "Wheel0-1") }
    provideData { arrayOf("Wheel1-0", "Wheel1-1") }
}

联动选择示例

实现联动选择需要只需要调用 linkageWheelView.setData 实现数据加载即可。

但是为了便捷使用一般也会实现数据转换,可以看以下2个例子。

实现省市区联动选择

数据

首先我们去网上下载,省市区数据JSON格式,并解析出来。

data class Province(
    val name: String,
    val cities: List<City>,
) : Serializable
data class City(
    val name: String,
    val counties: List<String>,
) : Serializable
@JvmStatic
fun loadLocalData(context: Context, fileName: String = "address.json"): List<Province> {
    val text = context.assets.open(fileName).use { it.bufferedReader().readText() }
    return JSONArray(text)
        .objects()
        .map { province ->
            val cities = province.getJSONArray("city").objects().map { city ->
                val counties = city.getJSONArray("area").strings()
                City(city.getString("name"), counties)
            Province(province.getString("name"), cities)
}

得到了 List<Province> 作为原始数据。

然后通过 linkageWheelView.setData 设置即可。

fun setAddressData(provinces: List<Province>) {
    //...
    linkageWheelView.setData {
        provideData { provinces.map { it.name } }
        provideData { provinces[it[0].selectedPosition].cities.map { it.name } }
        provideData { provinces[it[0].selectedPosition].cities[it[1].selectedPosition].counties }
}

数据转换

数据转换主要是给调用者更加方便去设置和获取当前的省市区数据。 先定义一个Model类,描述省市区。

data class AddressBean(
    val provinceName: String,
    val cityName: String,
    val countyName: String,
)

然后实现 set方法 get方法

var current: AddressBean?
    get() {
        //...
        val (province, city, county) = linkageWheelView.currentItems.map { it.toString() }
        return AddressBean(province, city, county)
    set(value) {
        //...
        linkageWheelView.setCurrentPositions {
            setPosition { it.indexOf(value.provinceName) }
            setPosition { it.indexOf(value.cityName) }
            setPosition { it.indexOf(value.countyName) }
    }

实现年月日联动选择

省市区的实现比较简单,我们来实现一个稍微复杂一点的。

实现一个年月日选择滚轮,支持以下配置内容,

  • 可以支持 年-月-日 , 月-日 , 年-月 , , , 模式。
  • 能动态计算日的范围。 如选择 年=2023 月=02 时,需要计算出可选的日的范围是 0-28
  • 支持设置最大、最小日期。当最小日期是 2022-05-05 时,最小年份只能选到 2022 ;当 年=2022 时,月最小只能选到 05 ;当 年=2022 月=05 时,日最小只能选到 05 。最大日期同理。
  • 支持配置年月日的显示格式。

配置类如下:

/**
 * @param year 支持年
 * @param month 支持月
 * @param day 支持日
 * @param maxValue 最大日期
 * @param minValue 最小日期
 * @param yearFormatter 年格式
 * @param monthFormatter 月格式
 * @param dayFormatter 日格式
data class Options(
    val year: Boolean = true,
    val month: Boolean = true,
    val day: Boolean = true,
    val maxValue: Calendar = Calendar.getInstance(),
    val minValue: Calendar = Calendar.getInstance().apply { add(Calendar.YEAR, -10) },
    var yearFormatter: Formatter = Formatter { it.toString() },
    var monthFormatter: Formatter = Formatter { (it + 1).toString() },
    var dayFormatter: Formatter = Formatter { it.toString() }
)

数据

定义一些常量,方便后续用到,年月日的范围标准和 Calendar 类定义一致 。

companion object {
    val DEFAULT_MONTH_RANGE: IntRange = (0..11)
    val DEFAULT_DAY_RANGE: IntRange = (1..31)
}

年月日数据,通过 linkageWheelView.setData 设置即可

  • 配置年 provideData :没有依赖项,直接设置范围即可
  • 配置月 provideData :可能会依赖年,取决于是否存在年,所以需要处理 月- 年-月- 2种情况(不依赖日,所以不需考虑 月-日 年-月-日 的情况)
  • 配置日 provideData :可能会依赖年和月,取决于是否存在年和月,所以需要处理 , 月-日 年-月-日 3种情况 ( 年-日 暂时没见过,所以不实现这种情况)

处理时只需要判断当前选中的值是否 处于边界 情况即可。

linkageWheelView.setData {
    val (maxYear, maxMonth, maxDay) = options.maxValue.flattenYmd()
    val (minYear, minMonth, minDay) = options.minValue.flattenYmd()
    if (options.year) {
        //模式“年-”
        provideData {
            (maxYear downTo minYear).formatYear()
    if (options.month) {
        provideData {
            if (it.size < 1) {
                //模式“月-”
                return@provideData (minMonth..maxMonth).formatMonth()
            //模式“年-月-”
            val selectedYear = it[0].realValue//当前年份
            val startYear =
                if (selectedYear == minYear) minMonth else DEFAULT_MONTH_RANGE.first
            val endYear =
                if (selectedYear == maxYear) maxMonth else DEFAULT_MONTH_RANGE.last
            (startYear..endYear).formatMonth()
    if (options.day) {
        provideData {
            if (it.size < 1) {
                //模式“日”
                return@provideData (minDay..maxDay).formatDay()
            if (it.size < 2) {
                //模式“月-日” (不存在“年-日”模式)
                val selectedMonth = it[0].realValue
                val startDay =
                    if (selectedMonth == minMonth) minDay else DEFAULT_DAY_RANGE.first
                val endDay =
                    if (selectedMonth == maxMonth) maxDay else DEFAULT_DAY_RANGE.last
                return@provideData (startDay..endDay).formatDay()
            //模式“年-月-日”
            val selectedYear = it[0].realValue//当前年份
            val selectedMonth = it[1].realValue//当前月份
            val selected = Calendar.getInstance().apply {
                set(selectedYear, selectedMonth, 1)
            val startDay =
                if (selectedYear == minYear && selectedMonth == minMonth) minDay else DEFAULT_DAY_RANGE.first
            val endDay =
                if (selectedYear == maxYear && selectedMonth == maxMonth) maxDay else selected.getActualMaximum(
                    Calendar.DAY_OF_MONTH
            return@provideData (startDay..endDay).formatDay()
}
format 和flattenYmd就是工具类,这里就不贴出来了,感兴趣可以看源码

数据转换

数据类直接使用 Calendar 完成年月日到对应位置的转换即可。

var current: Calendar
    get() {
        val values = linkageWheelView.currentItems.map { (it as ValueWrapper).realValue }
        val calendar = Calendar.getInstance()
        var index = 0
        if (options.year && index < values.size) {
            calendar.set(Calendar.YEAR, values[index++])
        if (options.month && index < values.size) {
            calendar.set(Calendar.MONTH, values[index++])
        if (options.day && index < values.size) {
            calendar.set(Calendar.DAY_OF_MONTH, values[index])
        } else {
            calendar.set(Calendar.DAY_OF_MONTH, 1)
        return calendar
    set(value) {
        linkageWheelView.setCurrentPositions {
            if (options.year) {
                setPosition {
                    it.indexOf(value.get(Calendar.YEAR)).coerceAtLeast(0)
            if (options.month) {
                setPosition {
                    it.indexOf(value.get(Calendar.MONTH)).coerceAtLeast(0)
            if (options.day) {
                setPosition {
                    it.indexOf(value.get(Calendar.DAY_OF_MONTH)).coerceAtLeast(0)