查看原文
其他

Android 自定义 View 高仿飞书日历

AndroidPub 2024-02-16

作者:blackfrog
juejin.cn/post/7217425505925054521

前言

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

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

1. 需求确定

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

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

2. 框架先行

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

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

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

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

interface IScheduleRenderAdapter {
    var models: MutableList<IScheduleModel>
    val visibleComponents: List<IScheduleComponent<*>>
    fun onCreateComponent(model: IScheduleModel): IScheduleComponent<*>?
    fun notifyModelsChanged()
}

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

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的转换过程,涉及分组、缓存和复用
    }
}

怎样实现坐标滑动

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

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

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方法,依赖ScrollerflingsetFinalX方法,大致如下:

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(truetrue)
}

尾声

除此之外,还有不少实现细节,包括:日程的拖曳,批量创建/编辑,添加日历提醒,处理时间冲突的日程等,篇幅原因也就不展开介绍了,欢迎查阅源码:

https://github.com/blackfrogxxoo/ScheduleView/tree/master/widget/src/main/java/me/wxc/widget/schedule

-- END --


推荐阅读


继续滑动看下一个

Android 自定义 View 高仿飞书日历

向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存