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

在笔者使用过的日历/日程类App中,飞书日程的效果和体验是比较优秀的。但又不能用飞书记录自己的私人日程,只能自己仿写一个了。

飞书上的日程有四种视图:日程、日、三日、月,今天我们先首先要讲的是三日视图(其实日视图和三日视图差不多,只需要处理一下一天的宽度就行了),先上效果图:

三日.gif

需求确定

拆解一下这张效果图里的需求:

  • 整个控件是一个坐标轴,横轴表示日期(yyyyMMdd),纵轴表示钟点(HHmm),交点表示具体时间(yyyyMMdd-HHmm)。
  • 坐标内部绘制日程以及当前时间标线。
  • 坐标轴可以上下左右自由滑动,日程和当前时间标线跟随滑动。左右滑动结束后,需要有类似SnapHelper的定位效果。
  • 点击空白区域,可以在相应的时间点创建一个日程,日程可以设置主题、日期、开始/结束时间、重复、提醒。
  • 日程可以通过拖曳修改开始时间和持续时间,拖曳修改时间钟点的最小单位是一刻钟(15min),如果拖曳日程超出控件范围,横、纵坐标自动相应滚动。

框架先行

明确需求后,我们需要构思框架和准备工具了。

首先,时间相关处理,肯定要大量使用时间戳和java.util.Calendar了,并且要做好封装一套工具的准备。

滑动控件,我们首先想到的是RecyclerViewScrollView之类的。但是坐标需要上下左右自由滑动,而且横坐标还是无限滑动的,甚至日程还可以自由拖曳,作为一个老开发,肯定马上作好避坑的准备了,基于RecyclerViewScrollView去实现肯定会处处受限制。想要自由发挥,就要把复杂的事情简单化,不外乎meausrelayoutdraw,自然而然就想到自定义控件了。至于点击、滑动、长按和拖曳,处理touch事件就好了。

是要覆写View,还是ViewGroup呢,其实已经不重要了。如果覆写View,那么坐标轴、日程等组件,我们就用canvas绘制;如果覆写ViewGroup,我们可能要把各组件的测量和绘制交给子View来处理。既然我们想要不受限制,那就自由到底,开撸!

绘制框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ScheduleView @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)
}

override fun onTouchEvent(event: MotionEvent): Boolean {
return onTouchEvent(event)
}
}

我们定义了一张白纸,一支笔。

有的同学可能已经迫不及待地在onDraw方法下写下如下方法了:

1
2
3
4
5
6
7
8
override fun onDraw(canvas: Canvas) {
// 绘制X轴坐标
drawDateLine(canvas)
// 绘制Y轴坐标
drawClockLine(canvas)
// 绘制日程
drawSchedules(canvas)
}

请先等一等!

数据驱动UI,是作为画UI程序页的基本思想。只要反复默念“数据驱动UI”一百次,满屏幕都变成了数据了。控件里的组件长着同样的骨骼:beginTime - endTime。于是,马上写下我们的基本接口,它也将是整个业务的基本抽象。

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

考虑一下,每个可绘制的组件,都是(或有)一个IScheduleModel,我们可以假想组件就是一个子View,但是又不是真正的View。为了实现与数据分离,先抽象一下“组件”这个家伙好了。我们可以直接模仿View的api,为了让它有一点不一样,位置和大小我们用一个RectF来表示,因为它既可以描述位置,也可以描述尺寸。更新rect的位置时,我们需要锚定当前的位置,那就定义一个带锚参数的updateDrawingRect方法。

1
2
3
4
5
6
interface IScheduleComponent<T : IScheduleModel> {
val model: T
val drawingRect: RectF
fun updateDrawingRect(anchorPoint: Point)
fun onDraw(canvas: Canvas, paint: Paint)
}

这样一来,我们的onDraw中的实现就跟着被抽象了。

1
2
3
4
5
6
7
private val scrollPosition = Point()
override fun onDraw(canvas: Canvas) {
visibleComponents.forEach {
it.updateDrawingRect(scrollPosition)
it.onDraw(canvas, paint)
}
}

这段代码的意思是,在每次触发onDraw方法时,都遍历可见的IScheduleComponent,更新drawingRect,再绘制它就好了。这就是整个绘制(包括测量和定位)过程的基本框架了,每个component的细节可以分别实现了。

滑动框架

有的同学可能已经迫不及待地开始写绘制框架的具体实现了。

请先等一等。

我们还有滑动相关的框架没有写呢。既要上下左右自由滑动,又要snap效果,简直比RecyclerView还要复杂啊。RecyclerView都要定义一个LayoutManager来管理滑动和布局,我们也有理由把滑动逻辑抽象出来。我们定义一个IScheduleWidget,用以管理滑动事宜。相应的,ScheduleView负责绘制,也可以抽象成一个IScheduleRender,用以更新component位置的scrollPosition,就可以抽象于此。暂且让它们俩双向依赖吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
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
}

interface IScheduleRender {
var widget: IScheduleWidget
val scrollPosition: Point
fun render(x: Int, y: Int)
}

于是,ScheduleView就成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ScheduleView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), IScheduleRender {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
override lateinit var widget: IScheduleWidget
override val scrollPosition: Point = Point()

override fun render(x: Int, y: Int) {
scrollPosition.x = x
scrollPosition.y = y
invalidate()
}

override fun onDraw(canvas: Canvas) {
visibleComponents.forEach {
it.updateDrawingRect(scrollPosition)
it.onDraw(canvas, paint)
}
}

override fun onTouchEvent(event: MotionEvent): Boolean {
return widget.onTouchEvent(event)
}

然后实现IScheduleWidget,这样整个绘制、滑动框架就定义完成了。左右滑动控件时,都会自动更新坐标的位置,并触发ScheduleView下的各components绘制自己。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class ScheduleWidget(override val render: IScheduleRender) : IScheduleWidget {
private var scrollX: Int = 0 // 0代表今天
private var scrollY: Int = 0 // 0代表零点
override fun onTouchEvent(motionEvent: MotionEvent): Boolean {
// TODO 处理手势,计算scroolX/Y
// scrollX = handle(motionEvent)
// scrollY = handle(motionEvent)
return false
}
override fun onScroll(x: Int, y: Int) {
// 需要注意的是,滑动距离和坐标对应的x、y是相反的
render.render(-x, -y)
}
override fun scrollTo(x: Int, y: Int) {
// TODO 对外暴露的scroll方法
// scrollX = handle(x)
// scrollY = handle(y)
}
override fun isScrolling(): Boolean = false
}

此外,需求中需要拖曳日程,component就像一个View,当然也可以处理了touch事件了,复制一个onTouchEvent方法好了。由于只有拖曳日程时需要单独处理,我们给它一个空实现。

1
2
3
4
interface IScheduleComponent<T : IScheduleModel> {
...
fun onTouchEvent(ev: TouchEvent): Boolean = false
}

至此框架已经完成了。接下来,我们终于可以开始写实现部分了。由于比较枯燥,我们只针对一些关键实现做一部分讲解,详细代码请参阅源码

具体实现

怎样计算component位置

时间戳和坐标中的位置是一一对应的,上公式:

1
2
x = dayWidth * (days - todayDays)
y = dayHeight * hours / 24

今天零点的x和y就是初始值,任何组件都可以计算相对于今天零点的位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
fun IScheduleModel.originRect(): RectF {
// x轴: 与当天的间隔天数 * 一天的宽度
// y轴: 当前分钟数 / 一天的分钟数 * 一天的高度
val dDays = beginTime.dDays
val left = clockWidth + dDays * dayWidth
val right = left + dayWidth
val zeroClockTime = beginOfDay(beginTime).timeInMillis
val top = dateLineHeight + dayHeight * (beginTime - zeroClockTime) / dayMillis
val bottom = dateLineHeight + dayHeight * (endTime - zeroClockTime) / dayMillis
return RectF(left, top, right, bottom)
}

fun IScheduleComponent<*>.originRect(): RectF = model.originRect()

当前时间标线可以跟随坐标上下左右滑动,所以drawingRect(相对位置)是由originRect(绝对位置)和anchorPoint(滑动距离)共同决定的。

1
2
3
4
5
6
7
// NowLineComponent
override fun updateDrawingRect(anchorPoint: Point) {
drawingRect.left = originRect.left + anchorPoint.x
drawingRect.right = originRect.right + anchorPoint.x
drawingRect.top = originRect.top + anchorPoint.y
drawingRect.bottom = originRect.bottom + anchorPoint.y
}

而时刻表(y坐标轴)不跟随左右滑动,就是不需要处理x轴。

1
2
3
4
5
// ClockLineComponent
override fun updateDrawingRect(anchorPoint: Point) {
drawingRect.top = originRect.top + anchorPoint.y
drawingRect.bottom = originRect.bottom + anchorPoint.y
}

怎样维护visibleComponents

前面的框架代码中,我们在ScheduleViewonDraw方法中,遍历了visibleComponents。那么,这个visibleComponents怎么来的呢?总不能有一万个日程,都要生成一万个component来遍历和绘制吧。这里又可以参考RecyclerView了,我们也抽象一个adapter接口出来。

1
2
3
4
5
6
interface IScheduleRenderAdapter {
var models: MutableList<IScheduleModel>
val visibleComponents: List<IScheduleComponent<*>>
fun onCreateComponent(model: IScheduleModel): IScheduleComponent<*>?
fun notifyModelsChanged()
}

并且,让IScheduleRender持有一个IScheduleRenderAdapter,我们再通过实现visibleComponentsonCreateComponent方法来实现具体逻辑。

1
2
3
4
interface IScheduleRender {
...
val adapter: IScheduleRenderAdapter
}

相应的,ScheduleView中也要有相应实现。

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 ScheduleView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), IScheduleRender {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
override lateinit var widget: IScheduleWidget
override val scrollPosition: Point = Point()
override val adapter: IScheduleRenderAdapter = ScheduleAdapter()

override fun render(x: Int, y: Int) {
scrollPosition.x = x
scrollPosition.y = y
invalidate()
}

override fun onDraw(canvas: Canvas) {
adapter.visibleComponents.forEach {
it.updateDrawingRect(scrollPosition)
it.onDraw(canvas, paint)
}
}

override fun onTouchEvent(event: MotionEvent): Boolean {
return widget.onTouchEvent(event)
}

class ScheduleAdapter : IScheduleRenderAdapter {
// ...在adapter中处理model与component的转换过程,涉及分组、缓存和复用
}
}

怎样实现坐标滑动

简单来说,就是GestureDetectorVelocityTrackerScroller这三个工具的应用了。

ScheduleWidgetonTouchEvent中需要同时处理坐标和日程的拖曳,所以大致是这样的:

1
2
3
4
5
6
7
8
9
override fun onTouchEvent(motionEvent: MotionEvent): Boolean {
// 为了计算松手后的滑动速度,在这里把motionEvent添加到velocityTracker中
velocityTracker.addMovement(motionEvent)
// 日程拖曳相关
val downOnCreate = createTaskComponent?.onTouchEvent(motionEvent) ?: false
// 处理松手后的位置snap
if (motionEvent.action == MotionEvent.ACTION_UP) autoSnap()
// 坐标滑动相关处理交给guestureDetector
return downOnCreate || gestureDetector.onTouchEvent(motionEvent)

而在gestureDetector中,我们覆写onDownonScroll方法,以处理上下左右的滑动:

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
private var justDown = false
override fun onDown(e: MotionEvent): Boolean {
justDown = true
if (!scroller.isFinished) {
scroller.abortAnimation()
}
return true
}
override fun onScroll(
e1: MotionEvent,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
if (justDown) {
scrollHorizontal = abs(distanceX) > abs(distanceY)
}
if (scrollHorizontal) {
scrollX += distanceX.toInt()
scrollX = scrollX.coerceAtMost(MAX_SCROLL_X).coerceAtLeast(MIN_SCROLL_X)
onScroll(scrollX, scrollY)
} else if (!downOnDateLine) {
scrollY += distanceY.toInt()
scrollY = scrollY.coerceAtMost(MAX_SCROLL_Y).coerceAtLeast(MIN_SCROLL_Y)
onScroll(scrollX, scrollY)
}
justDown = false
return true
}

用于自动定位的autoSnap方法,依赖ScrollerflingsetFinalX方法,大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private fun autoSnap() {
// 自适应左右滑动结束位置
velocityTracker.computeCurrentVelocity(1000)
if (scrollHorizontal) {
// 只需要计算水平方向
scroller.fling(
scrollX,
0,
-velocityTracker.xVelocity.toInt(),
0,
Int.MIN_VALUE,
Int.MAX_VALUE,
0,
0
)
// 天数取整后乘以dayWidth
scroller.finalX =
((scroller.finalX / dayWidth).roundToInt() * dayWidth).roundToInt()
.coerceAtMost(MAX_SCROLL_X).coerceAtLeast(MIN_SCROLL_X)
}
callOnScrolling(true, true)
}

杀割

除此之外,还有不少实现细节,包括:日程的拖曳,批量创建/编辑,添加日历提醒,处理时间冲突的日程等,篇幅原因也就不展开介绍了,有问题评论区交流。

笔者接下来还会抽时间介绍一下其他三种视图的实现思路和细节,感兴趣的朋友可以关注一下。

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