Android 自定义 View 高仿飞书日历
作者:blackfrog
juejin.cn/post/7217425505925054521
前言
在笔者使用过的日历/日程类App中,飞书日程的效果和体验是比较优秀的。但又不能用飞书记录自己的私人日程,只能自己仿写一个了。
飞书上的日程有四种视图:日程、日、三日、月,今天我们先首先要讲的是三日视图(其实日视图和三日视图差不多,只需要处理一下一天的宽度就行了),先上效果图:
1. 需求确定
拆解一下这张效果图里的需求:
整个控件是一个坐标轴,横轴表示日期(yyyyMMdd),纵轴表示钟点(HHmm),交点表示具体时间(yyyyMMdd-HHmm)。 坐标内部绘制日程以及当前时间标线。 坐标轴可以上下左右自由滑动,日程和当前时间标线跟随滑动。左右滑动结束后,需要有类似SnapHelper的定位效果。 点击空白区域,可以在相应的时间点创建一个日程,日程可以设置主题、日期、开始/结束时间、重复、提醒。 日程可以通过拖曳修改开始时间和持续时间,拖曳修改时间钟点的最小单位是一刻钟(15min),如果拖曳日程超出控件范围,横、纵坐标自动相应滚动。
2. 框架先行
明确需求后,我们需要构思框架和准备工具了。
首先,时间相关处理,肯定要大量使用时间戳和java.util.Calendar
了,并且要做好封装一套工具的准备。
滑动控件,我们首先想到的是RecyclerView
、ScrollView
之类的。但是坐标需要上下左右自由滑动,而且横坐标还是无限滑动的,甚至日程还可以自由拖曳,作为一个老开发,肯定马上作好避坑的准备了,基于RecyclerView
或ScrollView
去实现肯定会处处受限制。想要自由发挥,就要把复杂的事情简单化,不外乎meausre
、layout
、draw
,自然而然就想到自定义控件了。至于点击、滑动、长按和拖曳,处理touch事件就好了。
是要覆写View
,还是ViewGroup
呢,其实已经不重要了。如果覆写View
,那么坐标轴、日程等组件,我们就用canvas
绘制;如果覆写ViewGroup
,我们可能要把各组件的测量和绘制交给子View
来处理。既然我们想要不受限制,那就自由到底,开撸!
绘制框架
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方法下写下如下方法了:
override fun onDraw(canvas: Canvas) {
// 绘制X轴坐标
drawDateLine(canvas)
// 绘制Y轴坐标
drawClockLine(canvas)
// 绘制日程
drawSchedules(canvas)
}
请先等一等!
数据驱动UI,是作为画UI程序页的基本思想。只要反复默念“数据驱动UI”一百次,满屏幕都变成了数据了。控件里的组件长着同样的骨骼:beginTime - endTime
。于是,马上写下我们的基本接口,它也将是整个业务的基本抽象。
interface IScheduleModel {
val beginTime: Long
val endTime: Long
}
考虑一下,每个可绘制的组件,都是(或有)一个IScheduleModel
,我们可以假想组件就是一个子View
,但是又不是真正的View
。为了实现与数据分离,先抽象一下“组件”这个家伙好了。我们可以直接模仿View
的api,为了让它有一点不一样,位置和大小我们用一个RectF
来表示,因为它既可以描述位置,也可以描述尺寸。更新rect的位置时,我们需要锚定当前的位置,那就定义一个带锚参数的updateDrawingRect
方法。
interface IScheduleComponent<T : IScheduleModel> {
val model: T
val drawingRect: RectF
fun updateDrawingRect(anchorPoint: Point)
fun onDraw(canvas: Canvas, paint: Paint)
}
这样一来,我们的onDraw
中的实现就跟着被抽象了。
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
,就可以抽象于此。暂且让它们俩双向依赖吧。
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
就成了这样:
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绘制自己。
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
方法好了。由于只有拖曳日程时需要单独处理,我们给它一个空实现。
interface IScheduleComponent<T : IScheduleModel> {
...
fun onTouchEvent(ev: TouchEvent): Boolean = false
}
至此框架已经完成了。接下来,我们终于可以开始写实现部分了。由于比较枯燥,我们只针对一些关键实现做一部分讲解,详细代码请参阅源码,地址附在文章最后。
3. 具体实现
怎样计算component位置
时间戳和坐标中的位置是一一对应的,上公式:
x = dayWidth * (days - todayDays)
y = dayHeight * hours / 24
今天零点的x和y就是初始值,任何组件都可以计算相对于今天零点的位置:
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
(滑动距离)共同决定的。
// 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轴。
// 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接口出来。
interface IScheduleRenderAdapter {
var models: MutableList<IScheduleModel>
val visibleComponents: List<IScheduleComponent<*>>
fun onCreateComponent(model: IScheduleModel): IScheduleComponent<*>?
fun notifyModelsChanged()
}
并且,让IScheduleRender
持有一个IScheduleRenderAdapter
,我们再通过实现visibleComponents
和onCreateComponent
方法来实现具体逻辑。
interface IScheduleRender {
...
val adapter: IScheduleRenderAdapter
}
相应的,ScheduleView
中也要有相应实现。
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
中需要同时处理坐标和日程的拖曳,所以大致是这样的:
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方法,以处理上下左右的滑动:
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
方法,大致如下:
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)
}
尾声
除此之外,还有不少实现细节,包括:日程的拖曳,批量创建/编辑,添加日历提醒,处理时间冲突的日程等,篇幅原因也就不展开介绍了,欢迎查阅源码:
https://github.com/blackfrogxxoo/ScheduleView/tree/master/widget/src/main/java/me/wxc/widget/schedule
-- END --
推荐阅读