在笔者使用过的日历/日程类App中,飞书日程的效果和体验是比较优秀的。但又不能用飞书记录自己的私人日程,只能自己仿写一个了。
飞书上的日程有四种视图:日程、日、三日、月,今天我们先首先要讲的是三日视图(其实日视图和三日视图差不多,只需要处理一下一天的宽度就行了),先上效果图:
需求确定 拆解一下这张效果图里的需求:
整个控件是一个坐标轴,横轴表示日期(yyyyMMdd),纵轴表示钟点(HHmm),交点表示具体时间(yyyyMMdd-HHmm)。
坐标内部绘制日程以及当前时间标线。
坐标轴可以上下左右自由滑动,日程和当前时间标线跟随滑动。左右滑动结束后,需要有类似SnapHelper
的定位效果。
点击空白区域,可以在相应的时间点创建一个日程,日程可以设置主题、日期、开始/结束时间、重复、提醒。
日程可以通过拖曳修改开始时间和持续时间,拖曳修改时间钟点的最小单位是一刻钟(15min),如果拖曳日程超出控件范围,横、纵坐标自动相应滚动。
框架先行 明确需求后,我们需要构思框架和准备工具了。
首先,时间相关处理,肯定要大量使用时间戳和java.util.Calendar
了,并且要做好封装一套工具的准备。
滑动控件,我们首先想到的是RecyclerView
、ScrollView
之类的。但是坐标需要上下左右自由滑动,而且横坐标还是无限滑动的,甚至日程还可以自由拖曳,作为一个老开发,肯定马上作好避坑的准备了,基于RecyclerView
或ScrollView
去实现肯定会处处受限制。想要自由发挥,就要把复杂的事情简单化,不外乎meausre
、layout
、draw
,自然而然就想到自定义控件了。至于点击、滑动、长按和拖曳,处理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 前面的框架代码中,我们在ScheduleView
的onDraw
方法中,遍历了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
,我们再通过实现visibleComponents
和onCreateComponent
方法来实现具体逻辑。
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的转换过程,涉及分组、缓存和复用 } }
怎样实现坐标滑动 简单来说,就是GestureDetector
、VelocityTracker
、Scroller
这三个工具的应用了。
在ScheduleWidget
的onTouchEvent
中需要同时处理坐标和日程的拖曳,所以大致是这样的:
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
中,我们覆写onDown
和onScroll
方法,以处理上下左右的滑动:
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
方法,依赖Scroller
的fling
和setFinalX
方法,大致如下:
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。