前段时间,笔者陆续发布了“高仿飞书日历”系列的三篇文章:
【Android自定义View】高仿飞书日历(一) – 三日视图
【Android自定义View】高仿飞书日历(二) – 日视图
【Android自定义View】高仿飞书日历(三) – 月视图
今天继续分享最后一个视图:列表视图。先上效果图:
需求确定 相对来说,这个视图中的交互逻辑要略微复杂一点。
列表视图包含两个部分:顶部周/月控件,底部日程列表。周/月控件显示日期和是否有日程(圆点表示);日程列表各个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改需求了。最惨的是,由于学习得很仓促、零碎,脑壳都是昏的,没有系统地理解清楚,即使这次把功能实现了,下次遇到类似的需求又得重新来一遍,心态崩了。。。
如果我精通CoordinatorLayout
和NestedScrollingParent/2/3
框架,那么我会毫不犹豫地选择它来实现这个需求,但是我明白自己并不熟悉它,可能我强行基于它们来构建代码,很可能会踩到坑里。
当笔者去选择实现方案时,大多是以这样的优先级:本人精通的轮子 > 最基础的API > 官方轮子 > 第三方轮子。
最基础的API虽然实现起来有点麻烦甚至枯燥,但它的优势在于稳定可靠。与其去选择本人不熟悉的CoordinatorLayout
和NestedScrollingParent/2/3
,还不如退而求其次,选择最笨的方法,用最基础的dispatchTouchEvent/onInterceptTouchEvent/onTouchEvent
来处理滑动事宜。
本系列的第一篇 中,笔者在做渲染框架时,选择了继承View
的方式,而不是基于ScrollView
、RecyclerView
之类的滑动控件,也是在这个思路下作出的决定。
PS:工作中尽量采用这样的思路去提高工作效率和质量,私底下还是需要花时间学习,补齐短板哟~
言归正传。
和上一篇 的月视图一样,我们选择RecyclerView
来实现周/月控件;同样的,选择RecyclerView
来实现日程列表;然后,将它们组合到一个LinearLayout
中。
1 2 3 4 5 6 // 周/月控件 class FlowHeaderGroup @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RecyclerView(context, attrs) { ... }
1 2 3 4 5 6 // 日程列表 class ScheduleFlowView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RecyclerView(context, attrs) { ... }
1 2 3 4 5 6 7 8 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
为例,它和日视图、月视图中的日历控件实现上几乎是一样的,
1 2 3 4 5 class FlowHeaderGroup @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RecyclerView(context, attrs), ICalendarRender, ICalendarParent { ... }
不过也有一点不一样,列表视图中的日历控件,是支持周/月模式切换的。简单啊,按照惯例,抽象一个接口为其赋能:
1 2 3 4 5 6 7 8 9 10 11 12 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
赋值就行了。
1 2 3 4 5 6 // 日历收起时 calendarMode = WeekMode // 日历展开一半时 calendarMode = MonthMode(0.5f) // 日历完全展开时 calendarMode = MonthMode(1.0f)
相应的,FlowHeaderGroup
去实现ICalendarModeHolder
:
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 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
。
代码如下:
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 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 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
中的数据,只需要在我们的ViewModel
或Presenter
中构建数据集,然后submitList
就完事了。并且,Kotlin给我们提供的丰富而强大的集合扩展方法,大大地简化了我们的数据处理,甚至还提高了性能。
多类型Item 为了实现多类型,我们先定义一下我们的数据模型(IFlowModel
),它也是基于IScheduleModel
的,因为我们需要处理排序(Month->Week->Day
),我们给它添加一个sortValue
属性。
然后三种Item类型分别用MonthText/WeekText/FlowDailySchedules
来表示。
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 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
的实现如下:
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 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 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语法糖的加持下,可以变得如此简单:
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 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
的。
1 2 3 4 interface ITimeRangeHolder { val beginTime: Long val endTime: Long }
这个beginTime
和endTime
就确定了日历控件的显示范围。那么对于日程列表来说,去更新beginTime
和endTime
,就能更新日程列表的前后长度,也就实现了LoadMore的效果了。这里仍然是数据驱动UI的体现。
在以下代码中,我们通过监听RecyclerView
的滑动,得到当前是否快要滑动到顶/底部,然后减小beginTime
/增大endTime
就可以了。
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 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赛高!