【Android自定义View】高仿飞书日历--日视图

在上一篇【Android自定义View】高仿飞书日历(一) – 三日视图中,我们实现了飞书日历中的三日视图,今天趁热打铁,介绍一下日视图的实现思路和细节,先上效果图。

单日视图.gif

单日2.gif

需求确定

对比三日视图,日视图在渲染、滑动、拖曳等方面几乎完全一致,只是一天的显示宽度(dayWidth)是三日视图的三倍。

不同的点在于:

  • 三日视图中,左右滑动时,没有距离限制,只需保证静止时保持scrollX等于N * dayWidth就行;日视图中,左右滑动时,每次最多只能滑动dayWidth
  • 三日视图中,横坐标轴显示三天的日期,与坐标区同步左右滑动;日视图中,横坐标轴显示整周的日期,独立滑动,但与坐标区的滑动是互动的。
  • 三日视图中,当前选中的日期(selectedDayTime)这个数据(或者说概念)是模糊的;日视图中,selectedDayTime变得清晰。左右滑动结束时,当前显示的那一天就是selectedDayTime,相应的,横坐标显示的也是这一天所在的一周,并且对selectedDayTime有高亮处理。

框架先行

渲染&滑动框架

既然日视图和三日视图的需求大致相同,那么渲染和滑动框架,我们也可以复用三日视图,甚至我们不需要去定义一个新的实现类,只需要让ScheduleView拥有切换模式的能力就行了。由于ScheduleView的布局是由ScheduleWidget管理的,所以我们给IScheduleWidget增加切换视图的能力:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface IScheduleWidget {
val render: IScheduleRender
fun onTouchEvent(motionEvent: MotionEvent): Boolean
fun onScroll(x: Int, y: Int)
fun scrollTo(x: Int, y: Int, duration: Int = 250)
fun isScrolling(): Boolean

// 切换视图
val renderRange: RenderRange
sealed interface RenderRange {
object SingleDayRange : RenderRange
object ThreeDayRange : RenderRange
}
}

但还有一个问题,日视图的横轴是独立滑动的,我们可以像处理拖曳日程那样处理,但想来处理起来会很麻烦。《代码大全》里面说过,软件工程的核心是控制复杂度。
处理拖曳日程已经让我们的代码复杂度提高了一个等级,经(jing)验(chang)丰(jia)富(ban)的开发都有意识——在已经产生复杂度的代码上增加复杂度,会导致复杂度指数级提高。

组合ViewGroup

笔者的处理方式是,在ScheduleView外面加一层父布局,单独写一个周控件与ScheduleView进行组合,周控件暂且就用RecyclerView实现。

这时,有的朋友可能会问,github上那么多优秀的开源日历项目,为啥还要重复造轮子呢?我先卖个关子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ScheduleGroup @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val scheduleView: ScheduleView
private val weekList: RecyclerView
private val weekAdapter: WeekAdapter

init {
inflate(context, R.layout.schedule_group, this)
scheduleView = findViewById(R.id.scheduleView)
weekList = findViewById(R.id.weekList)
PagerSnapHelper().attachToRecyclerView(weekList)
weekAdapter = WeekAdapter(weekList)
}
}

schedule_group布局很简单,就是一个FrameLayout中加了一个ScheduleView和一个RecyclerView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:parentTag="android.widget.FrameLayout">

<me.wxc.widget.schedule.ScheduleView
android:id="@+id/scheduleView"
android:layout_width="match_parent"
android:layout_height="match_parent" />

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/weekList"
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginStart="56dp"
android:orientation="horizontal"
android:visibility="invisible"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

</merge>

之前我们需要在Activity中维护ScheduleWidget,现在有了父布局,我们就可以在父布局中维护ScheduleWidget了,这不正是代理模式的应用场景吗?我们让ScheduleGroup实现IScheduleWidget接口,并持有一个ScheduleWidget的实例,ScheduleGroup就可以作为ScheduleWidget代理了。

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
class ScheduleGroup @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs), IScheduleWidget {
private val scheduleView: ScheduleView
private val weekList: RecyclerView
private val weekAdapter: WeekAdapter
private val scheduleWidget: ScheduleWidget

init {
inflate(context, R.layout.schedule_group, this)
scheduleView = findViewById(R.id.scheduleView)
scheduleWidget = ScheduleWidget(scheduleView)
weekList = findViewById(R.id.weekList)
PagerSnapHelper().attachToRecyclerView(weekList)
weekAdapter = WeekAdapter(weekList)
}

override val render: IScheduleRender
get() = scheduleView
override val renderRange: IScheduleWidget.RenderRange
get() = scheduleWidget.renderRange

override fun onScroll(x: Int, y: Int) {
scheduleWidget.onScroll(x, y)
}

override fun scrollTo(x: Int, y: Int, duration: Int) {
scheduleWidget.scrollTo(x, y, duration)
}

override fun isScrolling(): Boolean = scheduleWidget.isScrolling()
}

这么做还有一个好处,就是让咱们的整个日历(包括三日、日、月、日程)有了树状结构的雏形,这部分马上展开讲。

日历框架

在上一篇中,我们将三日视图上显示的各种组件,抽象成了IScheduleModel

1
2
3
4
interface IScheduleModel {
val beginTime: Long
val endTime: Long
}

认真看到现在的朋友,都可以看出来,笔者一直秉持着面向抽象编程的思想在构建代码。而我们现在需要新增一个周控件,并且新加了一个selectedDayTime的概念,它又怎么抽象呢?

仔细看日视图的效果图,我们需要考虑把日程数据日历控件区别开了。比如我们的周控件,它是日历控件,但不是日程数据;它有着beginTime-endTime的骨骼,但同时又是日程数据的承载者。

于是,我们需要将原来的IScheduleModel进一步抽象。

不具有数据属性的beginTime-endTime,就抽象为ITimeRangeHolder

1
2
3
4
interface ITimeRangeHolder {
val beginTime: Long
val endTime: Long
}

而具有数据属性的IScheduleModel,就去实现java.io.Serializable接口,这也是为以后数据存储和传输作准备。

1
interface IScheduleModel : ITimeRangeHolder, java.io.Serializable

有了这个抽象基础,我们终于可以对日历控件进行抽象了。日历控件需要一个Calendar属性来标定它的时间,一个List<IScheduleModel>来维护日程数据,并且selectedDayTime这一概念也可以定义为它的属性。

1
2
3
4
interface ICalendarRender : ITimeRangeHolder {
val calendar: Calendar
var selectedDayTime: Long
var scheduleModels: List<IScheduleModel>

另外,考虑到日历有年-月-周-日这样的层次,我们可以开始考虑层次结构的问题了。这里我们又模仿View的api,给我们的ICalendarRender添加一个parentRender。顶层的ICalendarRender是没有parentRender的,所以它是可空的。类似View中的parent,这个parentRender给我们提供了向上遍历的基础。

1
2
3
4
interface ICalendarRender : ITimeRangeHolder {
...
val parentRender: ICalendarRender?
}

相应的,可以拥有子ICalendarRendar的组件,也应该具有向下遍历的能力:

1
2
3
interface ICalendarParent {
val childRenders: List<ICalendarRender>
}

这样,我们就有了上一节提到的树状结构的基础了!我们可以利用它做这样的事:

1
2
3
4
5
6
// 赋值的同时给childRenders赋值
override var selectedDayTime: Long
set(value) {
field = value
childRenders.forEach { it.selectedDayTime = value }
}
1
2
3
4
5
6
7
8
9
10
11
12
// 获取根ICalendarRender
val rootCalendarRender: ICalendarRender?
get() {
if (parentRender != null) {
return if (parentRender?.parentRender == null) {
parentRender
} else {
parentRender?.rootCalendarRender
}
}
return null
}

这里不妨提前预告一下,这个基于ICalendarRender的日历框架有多重要:

image.png

可以说内聚度相当高了!

我们可以看到,IScheduleWidget也实现了ICalendarRender接口。后面实现周控件相关的WeekView/WeekDayView/WeekAdapter也是ICalendarRender的子类。我们可以看出,ICalendarRender是抽象的,它的实现不一定是View,而可以是任何对象。

至此,咱们三日/日视图就统一到同一套框架下面了,甚至还为整个项目制定了日历框架。

接下来我们就去实现其中的细节吧。

具体实现

处理切换三日/日视图

首先,三日视图中不显示周控件,而日视图中需要显示,简单啊,在renderRangesetter方法中处理就行了。

我们将renderRangeval常量改成var变量,添加一个setter方法:

1
2
3
4
5
6
7
8
9
10
override var renderRange: IScheduleWidget.RenderRange
get() = scheduleView.widget.renderRange
set(value) {
calendarWidget.renderRange = value
if (value is IScheduleWidget.RenderRange.ThreeDayRange) {
weekList.visibility = GONE
} else {
weekList.visibility = VISIBLE
}
}

小思考:接口中定义为val常量,为啥实现中可以改为var变量呢?反过来行不行呢?

然后,切换视图后需要刷新UI,自然要在ScheduleWidget中实现了:

1
2
3
4
override var renderRange: IScheduleWidget.RenderRange by setter(IScheduleWidget.RenderRange.ThreeDayRange) { _, value ->
render.adapter.notifyModelsChanged()
scrollTo((selectedDayTime.dDays * dayWidth).roundToInt(), scrollY, 0)
}
  • 这个by setter是我简单封装了一下Delegates.observable方法,代码整洁一丢丢,当然用普通的setter方法也是可以的。

    1
    2
    3
    4
    5
    6
    inline 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
    6
    override 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
    6
    val dayWidth: Float
    get() = if (ScheduleWidget.isThreeDay) {
    ((screenWidth - clockWidth) / 3).roundToInt().toFloat()
    } else {
    (screenWidth - clockWidth).roundToInt().toFloat()
    }

滑动控制

由于日视图中,左右滑动一次最多只能滑一天,所以需要特殊处理一下,实际上就是修改一下Scroller的行为。我们更新一下在上一篇中提到的autoSnap()方法,根据当前滑动的距离和松手后的速度,共同确定目标scrollX就好了:

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 autoSnap() {
velocityTracker.computeCurrentVelocity(1000)
if (scrollHorizontal) {
// 自适应滑动结束位置
if (isThreeDay) { // 三日视图,正常滑动距离
// ...
} else { // 单日视图,滑动一页
val velocity = velocityTracker.xVelocity.toInt()
if (!scroller.isFinished) {
scroller.abortAnimation()
}
val currentDDays = selectedDayTime.dDays.toInt()
val destDDays = if (velocity < -1000) { // 左滑
currentDDays + 1
} else if (velocity > 1000) { // 右滑
currentDDays - 1
} else if (scrollX / dayWidth.roundToInt() == currentDDays && scrollX % dayWidth.roundToInt() > dayWidth / 2) {
currentDDays + 1
} else if (scrollX / dayWidth.roundToInt() == currentDDays - 1 && scrollX % dayWidth.roundToInt() < dayWidth / 2) {
currentDDays - 1
} else {
currentDDays
}
val dx = (destDDays * dayWidth).roundToInt() - scrollX
scroller.startScroll(
scrollX,
scrollY,
dx,
0,
(abs(dx) - abs(velocity) / 100).coerceAtMost(400).coerceAtLeast(50)
)
}
} else {
// ...
}
callOnScrolling(true, true)
}

实现周控件

周控件的实现,就是基于咱们的日历框架,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
    58
    class 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()
    }
    }

TipsreloadSchedulesFromProvider()方法是我们为ICalendarRender接口新增的方法,默认实现为从外部获取scheduleModels以刷新UI。

1
2
3
4
5
6
7
8
9
10
11
fun reloadSchedulesFromProvider(onReload: () -> Unit = {}) {
ScheduleConfig.lifecycleScope.launch {
scheduleModels = withContext(Dispatchers.IO) {
ScheduleConfig.scheduleModelsProvider.invoke(
beginTime,
endTime
)
}
onReload()
}
}
  • WeekView实现

添加子View的简单的处理方式就是在onAttachedToWindow()方法中,添加7个WeekDayView,并分别给它们设置calendar/selectedDayTime/scheduleModels就行了。然后在onDetachedFromWindow()方法中removeAllViews()

PS:当然我们也可以做WeekDayView的复用,但因为笔者先写的月视图中的月控件,其中一个月的天数不固定,为了简单就是这样暴力处理了,周控件和月控件结构一样的所以就偷懒也这样处理了。影响不大就有空再优化吧~

咱们也不用再自定义LayoutParams了,直接在onLayout()方法中计算子View的位置就OK了,这里onLayout()中的处理也兼容月视图中的月控件哦(其实就是从月控件中拷过来的)。

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
class WeekView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr), ICalendarRender, ICalendarParent {
override val parentRender: ICalendarRender?
get() = (parent as? RecyclerView)?.adapter as? ICalendarRender
override val calendar: Calendar = beginOfDay()
// 起止时间就是本周的周日零点到周六24点
override val beginTime: Long
get() = beginOfDay(calendar.firstDayOfWeekTime).timeInMillis
override val endTime: Long
get() = beginOfDay(calendar.lastDayOfWeekTime).timeInMillis + dayMillis
override var selectedDayTime: Long by setter(-1L) { _, time ->
childRenders.forEach { it.selectedDayTime = time }
}
override var scheduleModels: List<IScheduleModel> = listOf()
set(value) {
field = value
childRenders.forEach { it.getSchedulesFrom(value) }
}
override val childRenders: List<ICalendarRender>
get() = children.filterIsInstance<ICalendarRender>().toList()

private val dayWidth: Float
get() = measuredWidth / 7f
private val dayHeight: Float
get() = 1f * (measuredHeight - paddingTop) / (childCount / 7)

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
for (index in 0 until childCount) {
val child = getChildAt(index)
val calendar = (child as ICalendarRender).calendar
val dDays = calendar.timeInMillis.dDays - beginTime.dDays
val line = dDays / 7
val left = dDays % 7 * dayWidth
val top = line * dayHeight
val right = left + dayWidth
val bottom = top + dayHeight
if (top.isNaN()) continue
child.layout(
left.roundToInt(),
top.roundToInt(),
right.roundToInt(),
bottom.roundToInt()
)
}
}

override fun onAttachedToWindow() {
super.onAttachedToWindow()
for (time in beginTime..endTime step dayMillis) {
WeekDayView(context).let { child ->
child.calendar.timeInMillis = time
addView(child)
child.setOnClickListener {
// 点击时通过rootCalendarRender设置顶层日历控件的selectedDayTime
if (selectedDayTime.dDays != child.beginTime.dDays) {
rootCalendarRender?.selectedDayTime = child.beginTime
}
}
if (scheduleModels.any()) {
child.getSchedulesFrom(scheduleModels)
}
}
}
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
removeAllViews()
}
}

Tips:这里的getSchedulesFrom(scheduleModels)类似reloadSchedulesFromProvider()方法,也是获取数据用的,不同在于它不需要从外部获取,而是从parentRenderscheduleModels中截取就行了。

1
2
3
4
fun getSchedulesFrom(from: List<IScheduleModel>) {
scheduleModels = from.filter { it.beginTime >= beginTime && it.endTime <= endTime }
.sortedBy { it.beginTime }
}
  • WeekDayView实现

这是最小控件了,也就不需要实现ICalendarParent接口了,自己根据属性绘制自己就好了。

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
class WeekDayView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), ICalendarRender {
override val parentRender: ICalendarRender
get() = parent as ICalendarRender
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
override val calendar: Calendar = beginOfDay()
// 起止时间就是当天的零点到24点
override val beginTime: Long
get() = calendar.timeInMillis
override val endTime: Long
get() = calendar.timeInMillis + dayMillis
override var selectedDayTime: Long by setter(-1) { _, time ->
invalidate()
}
override var scheduleModels: List<IScheduleModel> = listOf()
set(value) {
field = value
invalidate()
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawDate(canvas)
// drawTasks(canvas)
}

private fun drawDate(canvas: Canvas) {
// ...省略掉大段的canvas绘制代码
}
}

杀割

本篇涉及的细节部分不多,最重要的就是对日历框架的构思,其中包含了笔者一些对代码结构的思考和实践。

此时,我也回答了为啥要重复造轮子的问题:因为有了一套日历框架后,项目中各个日历模块是有机的整体,而不是为了实现需求拼凑起来的一堆三方库;并且基于这个框架去开发其他日历视图就事半功倍了。

有问题或建议的朋友,欢迎评论区交流。笔者接下来还会抽时间介绍一下其他两种视图的实现思路和细节,感兴趣的朋友可以关注一下。

再贴一下源码,欢迎star和issues。