【Android自定义View】高仿飞书日历--日视图
在上一篇【Android自定义View】高仿飞书日历(一) – 三日视图中,我们实现了飞书日历中的三日视图,今天趁热打铁,介绍一下日视图的实现思路和细节,先上效果图。
需求确定
对比三日视图,日视图在渲染、滑动、拖曳等方面几乎完全一致,只是一天的显示宽度(dayWidth
)是三日视图的三倍。
不同的点在于:
- 三日视图中,左右滑动时,没有距离限制,只需保证静止时保持
scrollX
等于N * dayWidth
就行;日视图中,左右滑动时,每次最多只能滑动dayWidth
。 - 三日视图中,横坐标轴显示三天的日期,与坐标区同步左右滑动;日视图中,横坐标轴显示整周的日期,独立滑动,但与坐标区的滑动是互动的。
- 三日视图中,当前选中的日期(
selectedDayTime
)这个数据(或者说概念)是模糊的;日视图中,selectedDayTime
变得清晰。左右滑动结束时,当前显示的那一天就是selectedDayTime
,相应的,横坐标显示的也是这一天所在的一周,并且对selectedDayTime
有高亮处理。
框架先行
渲染&滑动框架
既然日视图和三日视图的需求大致相同,那么渲染和滑动框架,我们也可以复用三日视图,甚至我们不需要去定义一个新的实现类,只需要让ScheduleView
拥有切换模式的能力就行了。由于ScheduleView
的布局是由ScheduleWidget
管理的,所以我们给IScheduleWidget
增加切换视图的能力:
1 | interface IScheduleWidget { |
但还有一个问题,日视图的横轴是独立滑动的,我们可以像处理拖曳日程那样处理,但想来处理起来会很麻烦。《代码大全》里面说过,软件工程的核心是控制复杂度。
处理拖曳日程已经让我们的代码复杂度提高了一个等级,经(jing)验(chang)丰(jia)富(ban)的开发都有意识——在已经产生复杂度的代码上增加复杂度,会导致复杂度指数级提高。
组合ViewGroup
笔者的处理方式是,在ScheduleView
外面加一层父布局,单独写一个周控件与ScheduleView
进行组合,周控件暂且就用RecyclerView
实现。
这时,有的朋友可能会问,github上那么多优秀的开源日历项目,为啥还要重复造轮子呢?我先卖个关子。
1 | class ScheduleGroup @JvmOverloads constructor( |
schedule_group
布局很简单,就是一个FrameLayout
中加了一个ScheduleView
和一个RecyclerView
:
1 | <?xml version="1.0" encoding="utf-8"?> |
之前我们需要在Activity
中维护ScheduleWidget
,现在有了父布局,我们就可以在父布局中维护ScheduleWidget
了,这不正是代理模式的应用场景吗?我们让ScheduleGroup
实现IScheduleWidget
接口,并持有一个ScheduleWidget
的实例,ScheduleGroup
就可以作为ScheduleWidget
的代理了。
1 | class ScheduleGroup @JvmOverloads constructor( |
这么做还有一个好处,就是让咱们的整个日历(包括三日、日、月、日程)有了树状结构的雏形,这部分马上展开讲。
日历框架
在上一篇中,我们将三日视图上显示的各种组件,抽象成了IScheduleModel
:
1 | interface IScheduleModel { |
认真看到现在的朋友,都可以看出来,笔者一直秉持着面向抽象编程的思想在构建代码。而我们现在需要新增一个周控件,并且新加了一个selectedDayTime
的概念,它又怎么抽象呢?
仔细看日视图的效果图,我们需要考虑把日程数据与日历控件区别开了。比如我们的周控件,它是日历控件,但不是日程数据;它有着beginTime-endTime的骨骼,但同时又是日程数据的承载者。
于是,我们需要将原来的IScheduleModel
进一步抽象。
不具有数据属性的beginTime-endTime,就抽象为ITimeRangeHolder
:
1 | interface ITimeRangeHolder { |
而具有数据属性的IScheduleModel
,就去实现java.io.Serializable
接口,这也是为以后数据存储和传输作准备。
1 | interface IScheduleModel : ITimeRangeHolder, java.io.Serializable |
有了这个抽象基础,我们终于可以对日历控件进行抽象了。日历控件需要一个Calendar
属性来标定它的时间,一个List<IScheduleModel>
来维护日程数据,并且selectedDayTime
这一概念也可以定义为它的属性。
1 | interface ICalendarRender : ITimeRangeHolder { |
另外,考虑到日历有年-月-周-日这样的层次,我们可以开始考虑层次结构的问题了。这里我们又模仿View
的api,给我们的ICalendarRender
添加一个parentRender
。顶层的ICalendarRender
是没有parentRender
的,所以它是可空的。类似View
中的parent
,这个parentRender
给我们提供了向上遍历的基础。
1 | interface ICalendarRender : ITimeRangeHolder { |
相应的,可以拥有子ICalendarRendar
的组件,也应该具有向下遍历的能力:
1 | interface ICalendarParent { |
这样,我们就有了上一节提到的树状结构的基础了!我们可以利用它做这样的事:
1 | // 赋值的同时给childRenders赋值 |
1 | // 获取根ICalendarRender |
这里不妨提前预告一下,这个基于ICalendarRender
的日历框架有多重要:
可以说内聚度相当高了!
我们可以看到,IScheduleWidget
也实现了ICalendarRender
接口。后面实现周控件相关的WeekView/WeekDayView/WeekAdapter
也是ICalendarRender
的子类。我们可以看出,ICalendarRender
是抽象的,它的实现不一定是View
,而可以是任何对象。
至此,咱们三日/日视图就统一到同一套框架下面了,甚至还为整个项目制定了日历框架。
接下来我们就去实现其中的细节吧。
具体实现
处理切换三日/日视图
首先,三日视图中不显示周控件,而日视图中需要显示,简单啊,在renderRange
的setter
方法中处理就行了。
我们将renderRange
由val
常量改成var
变量,添加一个setter
方法:
1 | override var renderRange: IScheduleWidget.RenderRange |
小思考:接口中定义为val
常量,为啥实现中可以改为var
变量呢?反过来行不行呢?
然后,切换视图后需要刷新UI,自然要在ScheduleWidget
中实现了:
1 | override var renderRange: IScheduleWidget.RenderRange by setter(IScheduleWidget.RenderRange.ThreeDayRange) { _, value -> |
这个
by setter
是我简单封装了一下Delegates.observable
方法,代码整洁一丢丢,当然用普通的setter
方法也是可以的。1
2
3
4
5
6inline fun <T> setter(
default: T,
crossinline onSet: (old: T, new: T) -> Unit = { old, new -> }
): ReadWriteProperty<Any?, T> = Delegates.observable(default) { _, old, new ->
onSet(old, new)
}notifyModelsChanged
在上一篇里提到过,它的作用和RecyclerView.Adapter
中的notifyDataSetChanged
差不多,就是为了刷新UI,将一些缓存删掉,这里简单贴一下代码不多展开了。1
2
3
4
5
6override fun notifyModelsChanged() {
_taskComponentCache.clear()
_modelsGroupByDay.clear()
models.groupBy { it.beginTime.dDays.toInt() }
.apply { _modelsGroupByDay.putAll(this) }
}这里的
scrollTo((selectedDayTime.dDays * dayWidth).roundToInt(), scrollY, 0)
是啥意思呢?实际上就是在切换模式后,将代表日期的scrollX
基于最新的dayWidth
刷新一下,而dayWidth
在切换模式后取不同的值即可。1
2
3
4
5
6val dayWidth: Float
get() = if (ScheduleWidget.isThreeDay) {
((screenWidth - clockWidth) / 3).roundToInt().toFloat()
} else {
(screenWidth - clockWidth).roundToInt().toFloat()
}
滑动控制
由于日视图中,左右滑动一次最多只能滑一天,所以需要特殊处理一下,实际上就是修改一下Scroller
的行为。我们更新一下在上一篇中提到的autoSnap()
方法,根据当前滑动的距离和松手后的速度,共同确定目标scrollX
就好了:
1 | private fun autoSnap() { |
实现周控件
周控件的实现,就是基于咱们的日历框架,Calendar
和自定义控件的简单应用了。
前面有提到,我们是用WeekAdapter
-WeekView
-WeekDayView
这样的层次结构实现的,它们都是实现了ICalendarRender
接口的。
WeekAdapter
实现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
58class WeekAdapter(private val recyclerView: RecyclerView) : RecyclerView.Adapter<VH>(),
ICalendarRender, ICalendarParent {
override val parentRender: ICalendarRender?
get() = recyclerView.parent as? ICalendarRender
override val calendar: Calendar = beginOfDay()
override var selectedDayTime: Long by setter(nowMillis) { oldTime, time ->
// 滚动到selectedDayTime对应的那一周的位置
if (!byDrag && abs(oldTime.dDays - time.dDays) < 30) {
recyclerView.smoothScrollToPosition(time.parseWeekIndex())
} else {
recyclerView.scrollToPosition(time.parseWeekIndex())
}
recyclerView.post {
childRenders.forEach { it.selectedDayTime = time }
}
}
override var scheduleModels: List<IScheduleModel> = listOf()
// 无限滑动,起止时间尽量久远就行
override val beginTime: Long
get() = ScheduleConfig.scheduleBeginTime
override val endTime: Long
get() = ScheduleConfig.scheduleEndTime
override val childRenders: List<ICalendarRender>
get() = recyclerView.children.filterIsInstance<ICalendarRender>().toList()
private val weekCount: Int by lazy {
val startWeekDay = beginOfDay(beginTime).apply {
timeInMillis -= (get(Calendar.DAY_OF_WEEK) - Calendar.SUNDAY) * dayMillis
}.timeInMillis
val result = ((endTime - startWeekDay) / (7 * dayMillis)).toInt()
result.apply { Log.i(TAG, "week count = $result") }
}
init {
recyclerView.run {
adapter = this@WeekAdapter
post {
scrollToPosition(selectedDayTime.parseWeekIndex())
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
return VH(parent.context)
}
override fun getItemCount() = weekCount
override fun onBindViewHolder(holder: VH, position: Int) {
val weekView = holder.itemView as WeekView
// 为WeekView设置时间
weekView.calendar.timeInMillis =
beginOfDay(ScheduleConfig.scheduleBeginTime).timeInMillis + position * 7 * dayMillis
// WeekView基于自己的起止时间获取日程数据
weekView.reloadSchedulesFromProvider()
}
}
Tips:reloadSchedulesFromProvider()
方法是我们为ICalendarRender
接口新增的方法,默认实现为从外部获取scheduleModels
以刷新UI。
1 | fun reloadSchedulesFromProvider(onReload: () -> Unit = {}) { |
WeekView
实现
添加子View
的简单的处理方式就是在onAttachedToWindow()
方法中,添加7个WeekDayView
,并分别给它们设置calendar/selectedDayTime/scheduleModels
就行了。然后在onDetachedFromWindow()
方法中removeAllViews()
。
PS:当然我们也可以做WeekDayView
的复用,但因为笔者先写的月视图中的月控件,其中一个月的天数不固定,为了简单就是这样暴力处理了,周控件和月控件结构一样的所以就偷懒也这样处理了。影响不大就有空再优化吧~
咱们也不用再自定义LayoutParams
了,直接在onLayout()
方法中计算子View
的位置就OK了,这里onLayout()
中的处理也兼容月视图中的月控件哦(其实就是从月控件中拷过来的)。
1 | class WeekView @JvmOverloads constructor( |
Tips:这里的getSchedulesFrom(scheduleModels)
类似reloadSchedulesFromProvider()
方法,也是获取数据用的,不同在于它不需要从外部获取,而是从parentRender
的scheduleModels
中截取就行了。
1 | fun getSchedulesFrom(from: List<IScheduleModel>) { |
WeekDayView
实现
这是最小控件了,也就不需要实现ICalendarParent
接口了,自己根据属性绘制自己就好了。
1 | class WeekDayView @JvmOverloads constructor( |
杀割
本篇涉及的细节部分不多,最重要的就是对日历框架的构思,其中包含了笔者一些对代码结构的思考和实践。
此时,我也回答了为啥要重复造轮子的问题:因为有了一套日历框架后,项目中各个日历模块是有机的整体,而不是为了实现需求拼凑起来的一堆三方库;并且基于这个框架去开发其他日历视图就事半功倍了。
有问题或建议的朋友,欢迎评论区交流。笔者接下来还会抽时间介绍一下其他两种视图的实现思路和细节,感兴趣的朋友可以关注一下。
再贴一下源码,欢迎star和issues。