【Android自定义View】高仿飞书日历(四) — 列表视图

前段时间,笔者陆续发布了“高仿飞书日历”系列的三篇文章:

【Android自定义View】高仿飞书日历(一) — 三日视图

【Android自定义View】高仿飞书日历(二) — 日视图

【Android自定义View】高仿飞书日历(三) — 月视图

今天继续分享最后一个视图:列表视图。先上效果图:

列表视图.gif

需求确定

相对来说,这个视图中的交互逻辑要略微复杂一点。

  • 列表视图包含两个部分:顶部周/月控件,底部日程列表。周/月控件显示日期和是否有日程(圆点表示);日程列表各个Item显示月份、周数、日程等信息。
  • 周/月控件可以左右滑动切换周/月;可以通过手势或点击箭头来切换周/月模式。
  • 在周模式下,日程列表可上下滑动;在月模式下,日程列表向上滑动时切换周模式,禁止向下滑动。
  • 日程列表滑动时选中列表顶部的日期,如果当天有多个日程,滑动过程中日期固定(pin)在列表顶部。
  • 周/月控件中可以通过点击选中某一天,选中时,日程列表自动定位到当天的日程;如果被选中的某天没有日程,则显示“暂无日程安排,点击创建”。
  • 在周模式下,如果当前选中了某一天(比如:周三),那么左/右滑动后选中上/下周的周三。
  • 在月模式下,左/右滑动后选中上/下月的一号。

框架先行

布局&渲染框架

从效果图和需求来看,控件整体上是一个Header+List的形式,它们之间存在滑动交互。要实现它,我们很直观地想到CoordinatorLayout(协调布局)。很多同学(包括前两年的笔者)在这时,可能就不管三七二十一,开始翻阅CoordinatorLayout的相关博客,Copy/Paste代码了。

请先等一等。

笔者想和大家聊聊一个可能比较重要的问题。

怎样选择技术实现方案?

当我们接到需求时,基于经验去选择了一个实现方案,如果这个方案我们并不是十分熟悉,需要临时去查阅资料,那么这个实现方案很可能不是适合我们的方案。比如当前这个需求,我们如果选择了不太熟悉的CoordinatorLayout,希望Copy代码就能够帮我们快速实现需求时,可能实际操作起来会让我们失望,甚至让我们陷入进退两难的泥淖。CoordinatorLayout是Google官方针对Material Design,基于NestedScrollingParent/2/3实现的一套UI框架,固然它提供了一些常见的UI效果的快速实现,但这些效果本来就是服务于Material Design的,虽然看起来像,但可能和我们的需求差一点点,这种时候我们只能继续去找解决方案,比如怎么自定义Behavior,甚至需要去了解和调试NestedScrollingParent/2/3的各个方法是怎么协调工作。

一边学习一边调试一边开发需求,渐渐地,我们发现估时不够用了,只能加班、延期或者找产品Battle改需求了。最惨的是,由于学习得很仓促、零碎,脑壳都是昏的,没有系统地理解清楚,即使这次把功能实现了,下次遇到类似的需求又得重新来一遍,心态崩了。。。

如果我精通CoordinatorLayoutNestedScrollingParent/2/3框架,那么我会毫不犹豫地选择它来实现这个需求,但是我明白自己并不熟悉它,可能我强行基于它们来构建代码,很可能会踩到坑里。

当笔者去选择实现方案时,大多是以这样的优先级:本人精通的轮子 > 最基础的API > 官方轮子 > 第三方轮子。

最基础的API虽然实现起来有点麻烦甚至枯燥,但它的优势在于稳定可靠。与其去选择本人不熟悉的CoordinatorLayoutNestedScrollingParent/2/3,还不如退而求其次,选择最笨的方法,用最基础的dispatchTouchEvent/onInterceptTouchEvent/onTouchEvent来处理滑动事宜。

本系列的第一篇中,笔者在做渲染框架时,选择了继承View的方式,而不是基于ScrollViewRecyclerView之类的滑动控件,也是在这个思路下作出的决定。

PS:工作中尽量采用这样的思路去提高工作效率和质量,私底下还是需要花时间学习,补齐短板哟~

言归正传。

上一篇的月视图一样,我们选择RecyclerView来实现周/月控件;同样的,选择RecyclerView来实现日程列表;然后,将它们组合到一个LinearLayout中。

// 周/月控件
class FlowHeaderGroup @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null

) : RecyclerView(context, attrs) {

    ...

}

// 日程列表
class ScheduleFlowView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null

) : RecyclerView(context, attrs) {

    ...

}

class FlowContainer @JvmOverloads constructor(

    context: Context, attrs: AttributeSet? = null



) : LinearLayout(context, attrs) {
    private val flowHeader: FlowHeaderGroup

    private val flowHeaderArrow: ImageView

    private val scheduleList: ScheduleFlowView

    ...
}

布局&渲染框架至此就搭建完成了。

日历框架

在本系列的第二篇中,我们已经定义好了基于ICalenderRender的日历框架了,这里的实现还是老样子:每个控件都去实现ICalendarRender,如果有子render就实现ICalendarParent。以FlowHeaderGroup为例,它和日视图、月视图中的日历控件实现上几乎是一样的,

class FlowHeaderGroup @JvmOverloads constructor(

    context: Context, attrs: AttributeSet? = null



) : RecyclerView(context, attrs), ICalendarRender, ICalendarParent {
    ...

}

不过也有一点不一样,列表视图中的日历控件,是支持周/月模式切换的。简单啊,按照惯例,抽象一个接口为其赋能:



interface ICalendarModeHolder {
    var calendarMode: CalendarMode
}




sealed interface CalendarMode {
    data class MonthMode(
        val expandFraction: Float = 0f,
    ) : CalendarMode


    object WeekMode : CalendarMode
}

笔者定义了一个ICalendarModeHolder接口,以及一个密封接口:CalendarMode。为啥要用密封接口而不用枚举呢?因为笔者需要用数据驱动UI。周/月模式,被我抽象为CalendarMode;而切换的进度,被我抽象为MonthMode下的expandFraction。这样一来,我们进行滑动操作时,对calendarMode赋值就行了。

// 日历收起时
calendarMode = WeekMode
// 日历展开一半时
calendarMode = MonthMode(0.5f)
// 日历完全展开时
calendarMode = MonthMode(1.0f)

相应的,FlowHeaderGroup去实现ICalendarModeHolder

class FlowHeaderGroup @JvmOverloads constructor(

    context: Context, attrs: AttributeSet? = null



) : RecyclerView(context, attrs), ICalendarRender, ICalendarParent, ICalendarModeHolder {
    ...

    override var calendarMode: CalendarMode by setter(CalendarMode.WeekMode) { oldMode, mode ->
        if (oldMode is CalendarMode.MonthMode && mode is CalendarMode.MonthMode) {
            onExpandFraction(mode.expandFraction)
        }
        onCalendarModeSet(mode)
    }
    
    
    private fun onExpandFraction(fraction: Float) {
        // TODO 更新布局
    }
    
    
    private fun onCalendarModeSet(mode: CalendarMode) {
        // 周/月模式下,子render的样式也会改变
        childRenders.filterIsInstance<ICalendarModeHolder>().forEach {
            it.calendarMode = mode
        }
        // 周/月模式切换时,更新recyclerView的数据源
        if (mode is CalendarMode.WeekMode || (mode as? CalendarMode.MonthMode)?.expandFraction == 0f) {
            adapter?.notifyDataSetChanged()
            scrollToPosition(selectedDayTime.parseIndex())
        }

    }




}

至此,日历框架也搭建完成了。

具体实现

滑动手势处理

有的同学对滑动手势处理望而却步,其实只要一点一点地拆解开,手势处理并不困难,无非是在拦截(onInterceptTouchEvent)和消费(onTouchEvent)这两个过程中,判断和处理我们的滑动手势逻辑。

前面我们已经提到,手势是在父布局(FlowContainer)中处理的。我们要处理的手势状态,主要包括ACTION_DOWN/ACTION_MOVE/ACTION_UP,在这里它们各自的用途是什么呢?

  • ACTION_DOWN:重置按下状态(justDown),并记录按下的位置(downX/downY);
  • ACTION_MOVE:判断滑动方向和方向,进而判断是否拦截;更改周/月模式(即:计算并设置calendarMode);
  • ACTION_UP:计算松手后的速度和位置,进而确定最终的calendarMode

代码如下:

class FlowContainer @JvmOverloads constructor(

    context: Context, attrs: AttributeSet? = null



) : LinearLayout(context, attrs), ICalendarRender, ICalendarParent {
    private val flowHeader: FlowHeaderGroup

    private val flowHeaderArrow: ImageView

    private val scheduleList: ScheduleFlowView



    // ... 省略掉ICalendarRender的实现

    private var downX: Float = 0f
    private var downY: Float = 0f
    private var justDown: Boolean = false
    private val touchSlop = ViewConfiguration.getTouchSlop()
    private var intercept = false
    private var fromMonthMode = false

    private val velocityTracker by lazy {
        VelocityTracker.obtain()
    }

    // Header(日历控件)底部
    private val headerBottom: Int
        get() = (flowHeaderArrow.parent as View).bottom

    override fun onTouchEvent(event: MotionEvent): Boolean {
        // 在消费事件时,如果不拦截,则调用默认的super.onTouchEvent(event)
        return performInterceptTouchEvent(event) || super.onTouchEvent(event)
    }




    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        // 在拦截事件时,如果不拦截,则调用默认的super.onInterceptTouchEvent(ev)
        val intercept = performInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev)
        return intercept
    }

    private fun performInterceptTouchEvent(ev: MotionEvent): Boolean {
        velocityTracker.addMovement(ev)
        return when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                downX = ev.x
                downY = ev.y
                justDown = true
                false
            }
            MotionEvent.ACTION_MOVE -> {
                // 如果justDown为true,就要先判断是否拦截事件
                if (justDown) {
                    fromMonthMode = flowHeader.calendarMode is CalendarMode.MonthMode
                }
                if (justDown && (abs(downX - ev.x) > touchSlop || abs(downY - ev.y) > touchSlop)) {
                    val moveUp = abs(downX - ev.x) < abs(downY - ev.y) && ev.y < downY
                    val moveDown = abs(downX - ev.x) < abs(downY - ev.y) && ev.y > downY
                    // 根据按下位置,滑动方向和当前的calendarMode来判断是否拦截事件
                    intercept = (moveUp && flowHeader.calendarMode is CalendarMode.MonthMode)
                            || (moveDown && downY < headerBottom && flowHeader.calendarMode is CalendarMode.WeekMode)
                            || (moveDown && downY > headerBottom && flowHeader.calendarMode is CalendarMode.MonthMode)
                    justDown = false
                }
                if (intercept) { 
                    // 在拦截事件时,calendarMode就在MonthMode(0.0f~1.0f)范围内变化了
                    if (!fromMonthMode && flowHeader.calendarMode is CalendarMode.WeekMode) {
                        flowHeader.calendarMode = CalendarMode.MonthMode(0f)
                    }
                    val maxHeight = (6 * flowHeaderDayHeight)
                    if (fromMonthMode) {
                        flowHeader.calendarMode = CalendarMode.MonthMode(
                            expandFraction = ((maxHeight - downY + ev.y) / maxHeight).coerceAtLeast(0f).coerceAtMost(1f),
                        )
                    } else {
                        flowHeader.calendarMode = CalendarMode.MonthMode(
                            expandFraction = ((flowHeaderDayHeight - downY + ev.y) / maxHeight).coerceAtLeast(
                                0f
                            ).coerceAtMost(1f),
                        )
                    }
                    true
                } else {
                    false
                }
            }
            MotionEvent.ACTION_UP -> {
                velocityTracker.computeCurrentVelocity(1000)
                val velocity = velocityTracker.yVelocity
                // 当速度绝对值大于1000时,最终位置以速度方向为准;否则,以当前位置为准
                if (intercept && flowHeader.calendarMode is CalendarMode.MonthMode) {
                    val target = if (velocity < -1000) {
                        CalendarMode.WeekMode
                    } else if (velocity > 1000) {
                        CalendarMode.MonthMode(1f)
                    } else if ((flowHeader.calendarMode as CalendarMode.MonthMode).expandFraction < 0.5f) {
                        CalendarMode.WeekMode
                    } else {
                        CalendarMode.MonthMode(1f)
                    }
                    flowHeader.autoSwitchMode(target.apply {
                        flowHeaderArrow.rotation = if (this is CalendarMode.MonthMode) {
                            0f
                        } else {
                            180f
                        }
                    })
                }
                intercept = false
                false
            } 
            else -> {
                false
            }
        }
    }
}

只要我们明确每一个手势状态下需要做的事情,那么其实手势处理并不困难吧。

日程列表

这里的日程列表主要有两个特点:需要显示月、周以及每天的日程数据,即多类型Item;需要上下无限滑动,即需要处理前后的LoadMore

很多同学可能都准备引入第三方的RecyclerView轮子了,但前面笔者已经提到官方轮子>第三方轮子了,这里我们采用androidx.recyclerview.widget.ListAdapter来实现。

ListAdapter的核心思想就是数据驱动UI,无论列表中的逻辑再复杂,我们也不需要去手动操作adapter中的数据,只需要在我们的ViewModelPresenter中构建数据集,然后submitList就完事了。并且,Kotlin给我们提供的丰富而强大的集合扩展方法,大大地简化了我们的数据处理,甚至还提高了性能。

多类型Item

为了实现多类型,我们先定义一下我们的数据模型(IFlowModel),它也是基于IScheduleModel的,因为我们需要处理排序(Month->Week->Day),我们给它添加一个sortValue属性。

然后三种Item类型分别用MonthText/WeekText/FlowDailySchedules来表示。



interface IFlowModel : IScheduleModel {
    val sortValue: Long
}




data class MonthText(
    override val beginTime: Long,
) : IFlowModel {
    override val sortValue: Long = beginTime
    override val endTime: Long = beginTime.calendar.lastDayOfMonthTime
}

data class WeekText(
    override val beginTime: Long,
) : IFlowModel {
    override val sortValue: Long = beginTime + 1
    override val endTime: Long = beginTime + 7 * dayMillis
}

data class FlowDailySchedules(
    override val beginTime: Long,
    val schedules: List<IScheduleModel>
) : IFlowModel {
    override val sortValue: Long = beginTime + 2
    override val endTime: Long = beginTime + dayMillis
}

相应的, adapter的实现如下:

class ScheduleFlowAdapter : ListAdapter<IFlowModel, VH>(
    object : DiffUtil.ItemCallback<IFlowModel>() {
        override fun areItemsTheSame(
            oldItem: IFlowModel,
            newItem: IFlowModel
        ) = oldItem == newItem


        override fun areContentsTheSame(
            oldItem: IFlowModel,
            newItem: IFlowModel
        ): Boolean {
            if (oldItem is MonthText && newItem is MonthText) {
                return oldItem.beginTime == newItem.beginTime
            } else if (oldItem is WeekText && newItem is WeekText) {
                return oldItem.beginTime == newItem.beginTime
            } else if (oldItem is FlowDailySchedules && newItem is FlowDailySchedules) {
                return oldItem.beginTime == newItem.beginTime && oldItem.schedules == newItem.schedules
            }
            return false
        }
    }
) {
    private val MONTH_TEXT = 1
    private val WEEK_TEXT = 2
    private val DAILY_TASK = 3
    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is MonthText -> MONTH_TEXT
            is WeekText -> WEEK_TEXT
            else -> DAILY_TASK
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        return when (viewType) {
            MONTH_TEXT -> MonthTextVH(parent.context)
            WEEK_TEXT -> WeekTextVH(parent.context)
            else -> DailyTaskVH(
                LayoutInflater.from(parent.context)
                    .inflate(R.layout.flow_daily_item, parent, false)
            )
        }
    }

    override fun onBindViewHolder(holder: VH, position: Int) {
        holder.onBind(getItem(position))
    }
}

abstract class VH(view: View) : RecyclerView.ViewHolder(view) {
    abstract fun onBind(scheduleModel: IScheduleModel)
}

class MonthTextVH(context: Context) : VH(TextView(context)) {
    // ...
}

class WeekTextVH(context: Context) : VH(TextView(context)) {
    // ...
}

class DailyTaskVH(itemView: View) : VH(itemView) {
    // ...
}

构建IFlowModel的逻辑看似复杂,其实在数据驱动UI思想和Kotlin语法糖的加持下,可以变得如此简单:

override var scheduleModels: List<IScheduleModel> by setter(emptyList()) { _, list ->
    generateViewModels(list)
}

private fun generateViewModels(list: List<IScheduleModel>) {
    // 将日程数据按天分组,然后map为FlowDailySchedules
    list.groupBy { it.beginTime.dDays }.values.map {
        FlowDailySchedules(
            beginTime = beginOfDay(it[0].beginTime).timeInMillis,
            schedules = it.sortedBy { model -> model.beginTime }
        )
    }.toMutableList<IFlowModel>().apply {
        // 然后在列表中插入月(MonthText)和周(WeekText)
        val days = map { it.beginTime.dDays }
        for (time in beginTime..endTime step dayMillis) {
            if ((time.dDays == nowMillis.dDays || time.dDays == focusedDayTime.dDays) && !days.contains(
                    time.dDays
                )
            ) {
                add(
                    FlowDailySchedules(
                        beginTime = time,
                        schedules = emptyList()
                    )
                )
            }
            if (time.dayOfMonth == 1) {
                add(MonthText(time))
            }
            if (time.dayOfWeek == Calendar.SUNDAY) {
                add(WeekText(time))
            }
        }
    }.sortedBy { it.sortValue }.apply { // 最后排序后submitList
        flowAdapter.submitList(this)
    }
}

LoadMore

看过本系列前面几篇的同学应该记得,咱们的日历框架是基于ITimeRangeHolder的。

interface ITimeRangeHolder {
    val beginTime: Long
    val endTime: Long
}


这个beginTimeendTime就确定了日历控件的显示范围。那么对于日程列表来说,去更新beginTimeendTime,就能更新日程列表的前后长度,也就实现了LoadMore的效果了。这里仍然是数据驱动UI的体现。

在以下代码中,我们通过监听RecyclerView的滑动,得到当前是否快要滑动到顶/底部,然后减小beginTime/增大endTime就可以了。

addOnScrollListener(object : OnScrollListener() {
    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        super.onScrolled(recyclerView, dx, dy)
        val llm = recyclerView.layoutManager as LinearLayoutManager
        val firstVisible = llm.findFirstVisibleItemPosition()
        val lastVisible = llm.findLastVisibleItemPosition()
        checkLoadMore(firstVisible, lastVisible)
    }
})


private fun checkLoadMore(firstVisible: Int, lastVisible: Int) {
    if (firstVisible < 10) {
        beginTime = beginTime.calendar.apply {
            add(Calendar.YEAR, -1)
        }.timeInMillis
        if (!loadingMore) {
            loadingMore = true
            reloadSchedulesFromProvider()
        }
    } else if (lastVisible > ((adapter?.itemCount ?: 0) - 10).coerceAtLeast(0)) {
        endTime = endTime.calendar.apply {
            add(Calendar.YEAR, 1)
        }.timeInMillis
        if (!loadingMore) {
            loadingMore = true
            reloadSchedulesFromProvider()
        }

    }


}

更新beginTime/endTime后,我们调用reloadSchedulesFromProvider()方法更新数据(scheduleModels),然后调用前面的generateViewModels方法就行了。

杀割

更多的实现细节,这里就不展开介绍了,想要详细了解请移步源码

整个“高仿飞书日历”项目的构思和实现心得,其实总结起来就这么几点:

  • 坚持数据驱动UI思想
  • 面向抽象构建代码
  • 掌握最基本的布局、绘制、滑动手势处理
  • 培养选择技术实现方案的思路
  • Kotlin赛高!

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYIxaTnp' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片