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

前几天笔者陆续发布了“高仿飞书日历”系列的两篇文章:

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

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

今天继续分享:月视图。先上效果图:

单月视图.gif

需求确定

月视图的显示和交互相对简单一点。

  • 每页展示一个月的月历,左右滑动切换上/下月。
  • 月历中每天的日期下展示当天的日程名称列表,如果展示不完整则在日期右侧展示剩余日程数。
  • 点击选中某一天时,以这一天所在的周,上下展开。展开时,在中间显示详细的日程列表,如果没有日程则显示空页面。左右滑动切换上/下一天。
  • 展开状态下,日程列表可以左右滑动。点击选中的日期可收起日程列表,点击其他日期可切换日程列表。

框架先行

布局&渲染框架

为便于理解,我们不妨将月视图的布局分为外层和内层。

外层

从效果图和需求中可以看出,月视图整体上就是一个左右翻页控件,那么ViewPager/ViewPager2/RecyclerView都是可以考虑的。笔者对RecyclerView比较熟悉,就选用了RecyclerView+PagerSnapHelper来构建了。

1
2
3
4
5
6
7
8
9
class MonthGroup @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {

init {
layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
PagerSnapHelper().attachToRecyclerView(this)
}
}

目前在外层布局构建阶段,那就暂时不考虑内层的布局细节了。我们可以考虑组合GridViewViewPager这样的方式来构建月历布局,但笔者喜欢自由发挥,就先用一个自定义ViewGroup占坑吧。

1
2
3
4
5
6
7
8
class MonthView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// TODO 布局
}
}

MonthGroup定义一个adapter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MonthAdapter : RecyclerView.Adapter<VH>() {

private val monthCount: Int = 0 // TODO 计算月份数

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
return VH(parent.context)
}

override fun getItemCount() = monthCount

override fun onBindViewHolder(holder: VH, position: Int) {
// TODO 为MonthView绑定日期和日程数据
}
}

class VH(context: Context) : ViewHolder(MonthView(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
})

MonthGroup绑定一个adapter,为了拿到当前的月份,我们定义一个lastPosition字段,并在OnScrollListener中计算更新lastPosition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MonthGroup @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {
private var lastPosition = -1
init {
layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
PagerSnapHelper().attachToRecyclerView(this)
adapter = MonthAdapter()

addOnScrollListener(object : OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == SCROLL_STATE_IDLE) {
val llm = recyclerView.layoutManager as LinearLayoutManager
val position = llm.findFirstCompletelyVisibleItemPosition()
if (position != -1 && lastPosition != position) {
lastPosition = position
// TODO 处理选中某月份的逻辑
}
}
}
})
}
}

这样月视图的外层布局&渲染框架就完成了。

内层

考虑一下,效果图中,显示“日 一 二 三..”那一行(header)是固定的,也不可交互,我们可以直接在onDraw()方法中绘制它们。而日期Item和日程列表的布局需要处理动态更新,在onLayout()中去处理更加直观。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MonthView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 11f.dp
}
private val topPadding = 26f.dp
init {
setWillNotDraw(false)
// topPadding以上用于绘制周header,以下用于布局子View
updatePadding(top = topPadding.roundToInt())
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
paint.color = ScheduleConfig.colorBlack3
// TODO 绘制header
}

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// TODO 布局日期Item和日程列表
}
}

日期Item中需要绘制日期、日程和选中状态,就简单地自定义一个View来渲染吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class DayView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawDate(canvas)
drawTasks(canvas)
drawArrow(canvas)
}

private fun drawDate(canvas: Canvas) {
// TODO 绘制日期
}

private fun drawTasks(canvas: Canvas) {
// TODO 绘制日程
}

private fun drawArrow(canvas: Canvas) {
// TODO 绘制选中箭头
}
}

这时,我们需要让每一个MonthView去添加一些DayView到它的布局中。简单处理,就在onAttachedToWindow()中添加,在onDetachedFromWindow()中清除好了。添加多少个呢?这就需要根据日历数据去计算了,这里暂时不处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MonthView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
...
override fun onAttachedToWindow() {
super.onAttachedToWindow()
// TODO addView(DayView(context))
}

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

别忘了还有一个展开状态,我们需要再添加一个日程列表在MonthView中,这个日程列表是可以左右滑动的,我们继承一个RecyclerView来实现,它就是一个常规的RecyclerView的使用,比较枯燥,这里就点到为止了哈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 日程列表外层
class DailyTaskListViewGroup @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {
...
inner class Adapter() :
RecyclerView.Adapter<VH>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
return VH(DailyTaskListView(parent.context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT,LayoutParams.MATCH_PARENT)
})
}

override fun getItemCount() = 7

override fun onBindViewHolder(holder: VH, position: Int) {
...
}
}
class VH(val dailyTaskListView: DailyTaskListView) : ViewHolder(dailyTaskListView)
}

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
// 日程列表内层,包含一个上下滑动的日程列表和一个空页面
class DailyTaskListView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val recyclerView: RecyclerView
private val emptyView: TextView
init {
inflate(context, R.layout.daily_task_list_view, this)
recyclerView = findViewById(R.id.recyclerView)
emptyView = findViewById(R.id.emptyView)
emptyView.text = buildSpannedString {
append("暂无日程安排,")
color(ScheduleConfig.colorBlue1) {
append("点击创建")
}
}
emptyView.setOnClickListener {
...
}
}

inner class Adapter : RecyclerView.Adapter<ViewHolder>() {
...
}

class VH(itemView: View) : ViewHolder(itemView) {
...
}
}

然后,我们把日程列表DailyTaskListViewGroup直接添加到MonthView中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class MonthView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
private val dailyTaskListViewGroup: DailyTaskListViewGroup

init {
setWillNotDraw(false)
updatePadding(top = topPadding.roundToInt())
dailyTaskListViewGroup = DailyTaskListViewGroup(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
setBackgroundColor(ScheduleConfig.colorBlack6)
}
addView(dailyTaskListViewGroup)
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// 只移除DayView,保留DailyTaskListViewGroup
removeViews(1, childCount - 1)
}
}

至此,我们的布局&渲染框架就完成了。

日历框架

上一篇中我们已经定义好了日历框架,接下来,我们将给月视图绑定日历框架,并且让月视图和三日/日视图联系起来。

在上一篇文章中,我们已经演示过日历框架的应用方式了。和日视图中的周控件一样,我们也让月视图相关的组件去实现ICalendarRenderICalendarParent接口。由于相关控件比较多,这里就只贴一下MonthGroup的代码了,其他组件中的实现差不多。

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
class MonthGroup @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs), ICalendarRender, ICalendarParent {
override val parentRender: ICalendarRender? = null
override val calendar: Calendar = beginOfDay()
override var selectedDayTime: Long by setter(nowMillis) { _, time ->
if (!isVisible) return@setter
childRenders.forEach { it.selectedDayTime = time }
post {
val position = time.parseMonthIndex()
if (abs(lastPosition - position) < 5) {
smoothScrollToPosition(position)
} else {
scrollToPosition(position)
}
lastPosition = position
}
}
override var scheduleModels: List<IScheduleModel> by setter(emptyList()) { _, list ->
childRenders.forEach { it.getSchedulesFrom(list) }
}
override val beginTime: Long
get() = ScheduleConfig.scheduleBeginTime
override val endTime: Long
get() = ScheduleConfig.scheduleEndTime
override val childRenders: List<ICalendarRender>
get() = children.filterIsInstance<ICalendarRender>().toList()

}

注意一下,当ViewGroup实现了ICalendarParent时,可以直接利用filterIsInstance()方法,在ViewGroupchildren中遍历childRenders。不禁感叹,Kotlin特性可以大大简化我们的代码,并且能给我们编码提供更多想象空间。

至此,我们的框架部分就搭建完成了。接下来,介绍一下部分具体实现。

具体实现

添加和布局DayView

首先,我们需要计算MonthViewDayView的个数,这里我们可以利用Calendar计算出来。

咱们ICalendarRender不是实现了ITimeRangeHolder吗?

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

interface ICalendarRender : ITimeRangeHolder

只要确定(实现)了MonthView中的beginTimeendTime,那么(endTime - beginTime) / dayMillis就是DayView的个数了。

1
2
3
4
5
6
7
8
9
10
// 当月第一天所在周的周日
override val beginTime: Long
get() = beginOfDay(calendar.firstDayOfMonthTime).apply {
set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY)
}.timeInMillis
// 当月最后一天所在周的周六
override val endTime: Long
get() = beginOfDay(calendar.lastDayOfMonthTime).apply {
set(Calendar.DAY_OF_WEEK, Calendar.SATURDAY)
}.timeInMillis

这样,我们就可以在onAttachedToWindow()onLayout()中处理DayView的添加、初始化和布局了。

PS:这里的onLayout()和日视图中的WeekView中的代码完全一样,哈哈!

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
override fun onAttachedToWindow() {
super.onAttachedToWindow()
for (time in beginTime..endTime step dayMillis) {
DayView(context).let { child ->
child.calendar.timeInMillis = time
addView(child)
// 老样子,从parentRender中截取scheduleModels
if (scheduleModels.any()) {
child.getSchedulesFrom(scheduleModels)
}
}
}
}

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 = paddingTop + line * dayHeight
val right = left + dayWidth
val bottom = top + dayHeight
if (top.isNaN()) continue
child.layout(
left.roundToInt(),
top.roundToInt(),
right.roundToInt(),
bottom.roundToInt()
)
}
}

展开和收起

要实现展开和收起,根本上来说就是要改变DayViewMonthView中的位置(废话)。那么DayView的位置怎么改变呢,刚刚写的onLayout()方法中处理呗,让它的top可变就行。

1
2
3
4
5
6
7
8
9
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
for (index in 0 until childCount) {
...
var top = paddingTop + line * dayHeight
// TODO top = ...
...
}
}

仔细看,点击DayView展开时,DayView所在的那一周向上滑动到顶部,下一周向下滑动到底部。

我们可以这样理解:展开时,有一条中心线(collapseCenter),有一条线(collapseTop)从中心线开始向上挤,另一条线(collapseBottom)从中心线开始向下挤。

collapseCenter是根据展开的那一周的行数(collapseLine)确定的,而collapseTopcollapseBottom是基于collapseCenter在展开动画中动态变化到目标位置的。

另外别忘了哦,展开时中间的日程列表DailyTaskListViewGroup和其他ICalendarRender一样,展开时需要为DailyTaskListViewGroup设置calendarscheduleModels数据。

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
// > -1,展开; == -1,收起
private var collapseLine = -1
set(value) {
onCollapseLineChanged(field, value)
field = value
}
private var animatingCollapseLine = -1
private var collapseCenter: Float = -1f
private var collapseTop: Float = -1f
private var collapseBottom: Float = -1f

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
for (index in 0 until childCount) {
...
var top = paddingTop + line * dayHeight
if (animatingCollapseLine >= 0) {
if (line <= animatingCollapseLine) {
top -= collapseCenter - collapseTop
} else {
top += collapseBottom - collapseCenter
}
}
...
}
}

private fun onCollapseLineChanged(old: Int, new: Int, doOnCollapsed: () -> Unit = {}) {
if (old == -1 && new >= 0) { // 从收起到展开
// 展开时更新DailyTaskListViewGroup的日期和日程数据
dailyTaskListViewGroup.calendar.timeInMillis = beginTime + new * 7 * dayMillis
dailyTaskListViewGroup.getSchedulesFrom(scheduleModels)
collapseCenter = paddingTop + (new + 1) * dayHeight
val destTop = paddingTop + dayHeight
val destBottom = if (new < childCount / 7 - 1) {
paddingTop + (childCount / 7 - 1) * dayHeight
} else {
paddingTop + childCount / 7 * dayHeight
}
ValueAnimator.ofFloat(0f, 1f).apply {
doOnStart {
animatingCollapseLine = new
}
doOnEnd {
animatingCollapseLine = new
}
duration = 300
addUpdateListener {
collapseTop = collapseCenter + (destTop - collapseCenter) * it.animatedFraction
collapseBottom =
collapseCenter + (destBottom - collapseCenter) * it.animatedFraction
requestLayout()
}
}.start()
} else if (old >= 0 && new == -1) { // 从展开到收起
dailyTaskListViewGroup.calendar.timeInMillis = -1
dailyTaskListViewGroup.scheduleModels = emptyList()
collapseCenter = paddingTop + (old + 1) * dayHeight
val startTop = collapseTop
val startBottom = collapseBottom
ValueAnimator.ofFloat(0f, 1f).apply {
doOnStart {
animatingCollapseLine = old
}
doOnEnd {
animatingCollapseLine = new
doOnCollapsed.invoke()
}
duration = 300
addUpdateListener {
collapseTop = startTop + (collapseCenter - startTop) * it.animatedFraction
collapseBottom =
startBottom + (collapseCenter - startBottom) * it.animatedFraction
requestLayout()
}
}.start()
} else if (old != new && old >= 0 && new >= 0) { // 从展开第a行到展开第b行,连续调用两次
onCollapseLineChanged(old, -1) {
onCollapseLineChanged(-1, new)
}
}
}

滑动冲突

日程列表DailyTaskListViewGroup中是横向RecyclerView嵌套纵向RecyclerViewMonthGroup又是一个横向RecyclerView,所以我们需要处理一下滑动冲突。

简单来说,就是在我们左右滑动日程列表时,通过调用parent.requestDisallowInterceptTouchEvent(true),不让父布局拦截事件就行了。这边简单贴一下代码:

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
open class StableOrientationRecyclerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {
private var downX = 0f
private var downY = 0f
private var justDown = false
private val isHorizontal: Boolean
get() = (layoutManager as? LinearLayoutManager)?.orientation == HORIZONTAL


private val touchSlop = ViewConfiguration.getTouchSlop()

override fun dispatchTouchEvent(e: MotionEvent): Boolean {
when (e.action) {
MotionEvent.ACTION_DOWN -> {
downX = e.x
downY = e.y
justDown = true
}
MotionEvent.ACTION_MOVE -> {
if (justDown && (abs(downX - e.x) > touchSlop || abs(downY - e.y) > touchSlop)) {
val moveHorizontal = abs(downX - e.x) > abs(downY - e.y)
if (moveHorizontal == isHorizontal) {
parent.requestDisallowInterceptTouchEvent(true)
}
justDown = false
}
}
MotionEvent.ACTION_UP -> {
justDown = false
parent.requestDisallowInterceptTouchEvent(false)
}
}
return super.dispatchTouchEvent(e)
}
}

然后让DailyTaskListViewGroup继承StableOrientationRecyclerView就完事了。

杀割

本文介绍的月视图,相对于三日/日视图来说,更加接近平时我们在工作中接到的需求,做起来比较枯燥一点。好在有先前就定义好的日历框架加持,笔者在做实现时还比较顺畅。

后面笔者将介绍最后一个日程视图了,先贴一张效果图预告,感兴趣的朋友可以关注一下。

列表视图.gif

最后贴一下代码地址,欢迎star和issues。