在上一篇【Android自界说View】高仿飞书日历(一) — 三日视图中,我们完成了飞书日历中的三日视图,今日抓住时机,介绍一下日视图的完成思路和细节,先上效果图。

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

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

需求确定

对比三日视图,日视图在烘托、滑动、拖曳等方面几乎完全一致,仅仅一天的显现宽度(dayWidth)是三日视图的三倍。

不同的点在于:

  • 三日视图中,左右滑动时,没有间隔约束,只需确保静止时坚持scrollX等于N * dayWidth就行;日视图中,左右滑动时,每次最多只能滑动dayWidth
  • 三日视图中,横坐标轴显现三天的日期,与坐标区同步左右滑动;日视图中,横坐标轴显现整周的日期,独立滑动,但与坐标区的滑动是互动的。
  • 三日视图中,当时选中的日期(selectedDayTime)这个数据(或者说概念)是含糊的;日视图中,selectedDayTime变得清晰。左右滑动完毕时,当时显现的那一天便是selectedDayTime,相应的,横坐标显现的也是这一天地点的一周,而且对selectedDayTime有高亮处理。

结构先行

烘托&滑动结构

已然日视图和三日视图的需求大致相同,那么烘托和滑动结构,我们也能够复用三日视图,乃至我们不需求去界说一个新的完成类,只需求让ScheduleView具有切换形式的能力就行了。由于ScheduleView的布局是由ScheduleWidget管理的,所以我们给IScheduleWidget增加切换视图的能力:

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
    // 切换视图
    val renderRange: RenderRange
    sealed interface RenderRange {
        object SingleDayRange : RenderRange
        object ThreeDayRange : RenderRange
    }
}

但还有一个问题,日视图的横轴是独立滑动的,我们能够像处理拖曳日程那样处理,但想来处理起来会很麻烦。《代码大全》里面说过,软件工程的中心是操控复杂度。 处理拖曳日程现已让我们的代码复杂度提高了一个等级,经(jing)验(chang)丰(jia)富(ban)的开发都有意识——在现已产生复杂度的代码上增加复杂度,会导致复杂度指数级提高。

组合ViewGroup

笔者的处理方式是,在ScheduleView外面加一层父布局,独自写一个周控件与ScheduleView进行组合,周控件暂且就用RecyclerView完成。

这时,有的朋友可能会问,github上那么多优异的开源日历项目,为啥还要重复造轮子呢?我先卖个关子。

class ScheduleGroup @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
    private val scheduleView: ScheduleView
    private val weekList: RecyclerView
    private val weekAdapter: WeekAdapter
    init {
        inflate(context, R.layout.schedule_group, this)
        scheduleView = findViewById(R.id.scheduleView)
        weekList = findViewById(R.id.weekList)
        PagerSnapHelper().attachToRecyclerView(weekList)
        weekAdapter = WeekAdapter(weekList)
    }
}

schedule_group布局很简略,便是一个FrameLayout中加了一个ScheduleView和一个RecyclerView

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:parentTag="android.widget.FrameLayout">
    <me.wxc.widget.schedule.ScheduleView
        android:id="@+id/scheduleView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/weekList"
        android:layout_width="match_parent"
        android:layout_height="60dp"
        android:layout_marginStart="56dp"
        android:orientation="horizontal"
        android:visibility="invisible"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
</merge>

之前我们需求在Activity中保护ScheduleWidget,现在有了父布局,我们就能够在父布局中保护ScheduleWidget了,这不正是署理形式的使用场景吗?我们让ScheduleGroup完成IScheduleWidget接口,并持有一个ScheduleWidget的实例,ScheduleGroup就能够作为ScheduleWidget署理了。

class ScheduleGroup @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs), IScheduleWidget {
    private val scheduleView: ScheduleView
    private val weekList: RecyclerView
    private val weekAdapter: WeekAdapter
    private val scheduleWidget: ScheduleWidget
    init {
        inflate(context, R.layout.schedule_group, this)
        scheduleView = findViewById(R.id.scheduleView)
        scheduleWidget = ScheduleWidget(scheduleView)
        weekList = findViewById(R.id.weekList)
        PagerSnapHelper().attachToRecyclerView(weekList)
        weekAdapter = WeekAdapter(weekList)
    }
    override val render: IScheduleRender
        get() = scheduleView
    override val renderRange: IScheduleWidget.RenderRange
        get() = scheduleWidget.renderRange
    override fun onScroll(x: Int, y: Int) {
        scheduleWidget.onScroll(x, y)
    }
    override fun scrollTo(x: Int, y: Int, duration: Int) {
        scheduleWidget.scrollTo(x, y, duration)
    }
    override fun isScrolling(): Boolean = scheduleWidget.isScrolling()
}

这么做还有一个优点,便是让我们的整个日历(包含三日、日、月、日程)有了树状结构的雏形,这部分立刻展开讲。

日历结构

在上一篇中,我们将三日视图上显现的各种组件,笼统成了IScheduleModel

interface IScheduleModel {
    val beginTime: Long
    val endTime: Long
}

认真看到现在的朋友,都能够看出来,笔者一向秉持着面向笼统编程的思想在构建代码。而我们现在需求新增一个周控件,而且新加了一个selectedDayTime的概念,它又怎样笼统呢?

仔细看日视图的效果图,我们需求考虑把日程数据日历控件区别开了。比如我们的周控件,它是日历控件,但不是日程数据;它有着beginTime-endTime的骨骼,但同时又是日程数据的承载者。

于是,我们需求将本来的IScheduleModel进一步笼统。

不具有数据特点的beginTime-endTime,就笼统为ITimeRangeHolder

interface ITimeRangeHolder {
    val beginTime: Long
    val endTime: Long
}

而具有数据特点的IScheduleModel,就去完成java.io.Serializable接口,这也是为以后数据存储和传输作准备。

interface IScheduleModel : ITimeRangeHolder, java.io.Serializable

有了这个笼统根底,我们总算能够对日历控件进行笼统了。日历控件需求一个Calendar特点来标定它的时刻,一个List<IScheduleModel>来保护日程数据,而且selectedDayTime这一概念也能够界说为它的特点。

interface ICalendarRender : ITimeRangeHolder {
    val calendar: Calendar
    var selectedDayTime: Long
    var scheduleModels: List<IScheduleModel>

另外,考虑到日历有年-月-周-日这样的层次,我们能够开始考虑层次结构的问题了。这里我们又仿照View的api,给我们的ICalendarRender增加一个parentRender。顶层的ICalendarRender是没有parentRender的,所以它是可空的。相似View中的parent,这个parentRender给我们供给了向上遍历的根底。

interface ICalendarRender : ITimeRangeHolder {
    ...
    val parentRender: ICalendarRender?
}

相应的,能够具有子ICalendarRendar的组件,也应该具有向下遍历的能力:

interface ICalendarParent {
    val childRenders: List<ICalendarRender>
}

这样,我们就有了上一节说到的树状结构的根底了!我们能够利用它做这样的事:

// 赋值的同时给childRenders赋值
override var selectedDayTime: Long
    set(value) {
        field = value
        childRenders.forEach { it.selectedDayTime = value }
    }
// 获取根ICalendarRender
val rootCalendarRender: ICalendarRender?
    get()  {
        if (parentRender != null) {
            return if (parentRender?.parentRender == null) {
                parentRender
            } else {
                parentRender?.rootCalendarRender
            }
        }
        return null
    }

这里无妨提前预告一下,这个依据ICalendarRender的日历结构有多重要:

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

能够说内聚度适当高了!

我们能够看到,IScheduleWidget也完成了ICalendarRender接口。后面完成周控件相关的WeekView/WeekDayView/WeekAdapter也是ICalendarRender的子类。我们能够看出,ICalendarRender是笼统的,它的完成不一定是View,而能够是任何对象。

至此,我们三日/日视图就统一到同一套结构下面了,乃至还为整个项目拟定了日历结构。

接下来我们就去完成其间的细节吧。

具体完成

处理切换三日/日视图

首先,三日视图中不显现周控件,而日视图中需求显现,简略啊,在renderRangesetter办法中处理就行了。

我们将renderRangeval常量改成var变量,增加一个setter办法:

override var renderRange: IScheduleWidget.RenderRange
    get() = scheduleView.widget.renderRange
    set(value) {
        calendarWidget.renderRange = value
        if (value is IScheduleWidget.RenderRange.ThreeDayRange) {
            weekList.visibility = GONE
        } else {
            weekList.visibility = VISIBLE
        }
    }

小思考:接口中界说为val常量,为啥完成中能够改为var变量呢?反过来行不行呢?

然后,切换视图后需求改写UI,自然要在ScheduleWidget中完成了:

override var renderRange: IScheduleWidget.RenderRange by setter(IScheduleWidget.RenderRange.ThreeDayRange) { _, value ->
    render.adapter.notifyModelsChanged()
    scrollTo((selectedDayTime.dDays * dayWidth).roundToInt(), scrollY, 0)
}
  • 这个by setter是我简略封装了一下Delegates.observable办法,代码整洁一丢丢,当然用一般的setter办法也是能够的。
inline fun <T> setter(
    default: T,
    crossinline onSet: (old: T, new: T) -> Unit = { old, new -> }
): ReadWriteProperty<Any?, T> = Delegates.observable(default) { _, old, new ->
    onSet(old, new)
}
  • notifyModelsChanged在上一篇里说到过,它的作用和RecyclerView.Adapter中的notifyDataSetChanged差不多,便是为了改写UI,将一些缓存删掉,这里简略贴一下代码不多展开了。
override fun notifyModelsChanged() {
    _taskComponentCache.clear()
    _modelsGroupByDay.clear()
    models.groupBy { it.beginTime.dDays.toInt() }
        .apply { _modelsGroupByDay.putAll(this) }
}
  • 这里的scrollTo((selectedDayTime.dDays * dayWidth).roundToInt(), scrollY, 0)是啥意思呢?实际上便是在切换形式后,将代表日期的scrollX依据最新的dayWidth改写一下,而dayWidth在切换形式后取不同的值即可。
val dayWidth: Float
    get() = if (ScheduleWidget.isThreeDay) {
        ((screenWidth - clockWidth) / 3).roundToInt().toFloat()
    } else {
        (screenWidth - clockWidth).roundToInt().toFloat()
    }

滑动操控

由于日视图中,左右滑动一次最多只能滑一天,所以需求特别处理一下,实际上便是修改一下Scroller的行为。我们更新一下在上一篇中说到的autoSnap()办法,依据当时滑动的间隔和松手后的速度,一起确定目标scrollX就好了:

private fun autoSnap() {
    velocityTracker.computeCurrentVelocity(1000)
    if (scrollHorizontal) {
        // 自适应滑动完毕方位
        if (isThreeDay) { // 三日视图,正常滑动间隔
            // ...
        } else { // 单日视图,滑动一页
            val velocity = velocityTracker.xVelocity.toInt()
            if (!scroller.isFinished) {
                scroller.abortAnimation()
            }
            val currentDDays = selectedDayTime.dDays.toInt()
            val destDDays = if (velocity < -1000) { // 左滑
                currentDDays + 1
            } else if (velocity > 1000) { // 右滑
                currentDDays - 1
            } else if (scrollX / dayWidth.roundToInt() == currentDDays && scrollX % dayWidth.roundToInt() > dayWidth / 2) {
                currentDDays + 1
            } else if (scrollX / dayWidth.roundToInt() == currentDDays - 1 && scrollX % dayWidth.roundToInt() < dayWidth / 2) {
                currentDDays - 1
            } else {
                currentDDays
            }
            val dx = (destDDays * dayWidth).roundToInt() - scrollX
            scroller.startScroll(
                scrollX,
                scrollY,
                dx,
                0,
                (abs(dx) - abs(velocity) / 100).coerceAtMost(400).coerceAtLeast(50)
            )
        }
    } else {
        // ...
    }
    callOnScrolling(true, true)
}

完成周控件

周控件的完成,便是依据我们的日历结构,Calendar和自界说控件的简略使用了。

前面有说到,我们是用WeekAdapterWeekViewWeekDayView这样的层次结构完成的,它们都是完成了ICalendarRender接口的。

  • WeekAdapter完成
class WeekAdapter(private val recyclerView: RecyclerView) : RecyclerView.Adapter<VH>(),
    ICalendarRender, ICalendarParent {
    override val parentRender: ICalendarRender?
        get() = recyclerView.parent as? ICalendarRender
    override val calendar: Calendar = beginOfDay()
    override var selectedDayTime: Long by setter(nowMillis) { oldTime, time ->
        // 滚动到selectedDayTime对应的那一周的方位
        if (!byDrag && abs(oldTime.dDays - time.dDays) < 30) {
            recyclerView.smoothScrollToPosition(time.parseWeekIndex())
        } else {
            recyclerView.scrollToPosition(time.parseWeekIndex())
        }
        recyclerView.post {
            childRenders.forEach { it.selectedDayTime = time }
        }
    }
    override var scheduleModels: List<IScheduleModel> = listOf()
    // 无限滑动,起止时刻尽量久远就行
    override val beginTime: Long
        get() = ScheduleConfig.scheduleBeginTime
    override val endTime: Long
        get() = ScheduleConfig.scheduleEndTime
    override val childRenders: List<ICalendarRender>
        get() = recyclerView.children.filterIsInstance<ICalendarRender>().toList()
    private val weekCount: Int by lazy {
        val startWeekDay = beginOfDay(beginTime).apply {
            timeInMillis -= (get(Calendar.DAY_OF_WEEK) - Calendar.SUNDAY) * dayMillis
        }.timeInMillis
        val result = ((endTime - startWeekDay) / (7 * dayMillis)).toInt()
        result.apply { Log.i(TAG, "week count = $result") }
    }
    init {
        recyclerView.run {
            adapter = this@WeekAdapter
            post {
                scrollToPosition(selectedDayTime.parseWeekIndex())
            }
        }
    }
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        return VH(parent.context)
    }
    override fun getItemCount() = weekCount
    override fun onBindViewHolder(holder: VH, position: Int) {
        val weekView = holder.itemView as WeekView
        // 为WeekView设置时刻
        weekView.calendar.timeInMillis =
            beginOfDay(ScheduleConfig.scheduleBeginTime).timeInMillis + position * 7 * dayMillis
        // WeekView依据自己的起止时刻获取日程数据
        weekView.reloadSchedulesFromProvider()
    }
}

TipsreloadSchedulesFromProvider()办法是我们为ICalendarRender接口新增的办法,默认完成为从外部获取scheduleModels以改写UI。

fun reloadSchedulesFromProvider(onReload: () -> Unit = {}) {
    ScheduleConfig.lifecycleScope.launch {
        scheduleModels = withContext(Dispatchers.IO) {
            ScheduleConfig.scheduleModelsProvider.invoke(
                beginTime,
                endTime
            )
        }
        onReload()
    }
}
  • WeekView完成

增加子View的简略的处理方式便是在onAttachedToWindow()办法中,增加7个WeekDayView,并别离给它们设置calendar/selectedDayTime/scheduleModels就行了。然后在onDetachedFromWindow()办法中removeAllViews()

PS:当然我们也能够做WeekDayView的复用,但因为笔者先写的月视图中的月控件,其间一个月的天数不固定,为了简略便是这样暴力处理了,周控件和月控件结构相同的所以就偷懒也这样处理了。影响不大就有空再优化吧~

我们也不用再自界说LayoutParams了,直接在onLayout()办法上钩算子View的方位就OK了,这里onLayout()中的处理也兼容月视图中的月控件哦(其实便是从月控件中拷过来的)。

class WeekView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr), ICalendarRender, ICalendarParent {
    override val parentRender: ICalendarRender?
        get() = (parent as? RecyclerView)?.adapter as? ICalendarRender
    override val calendar: Calendar = beginOfDay()
    // 起止时刻便是本周的周日零点到周六24点
    override val beginTime: Long
        get() = beginOfDay(calendar.firstDayOfWeekTime).timeInMillis
    override val endTime: Long
        get() = beginOfDay(calendar.lastDayOfWeekTime).timeInMillis + dayMillis
    override var selectedDayTime: Long by setter(-1L) { _, time ->
        childRenders.forEach { it.selectedDayTime = time }
    }
    override var scheduleModels: List<IScheduleModel> = listOf()
        set(value) {
            field = value
            childRenders.forEach { it.getSchedulesFrom(value) }
        }
    override val childRenders: List<ICalendarRender>
        get() = children.filterIsInstance<ICalendarRender>().toList()
    private val dayWidth: Float
        get() = measuredWidth / 7f
    private val dayHeight: Float
        get() = 1f * (measuredHeight - paddingTop) / (childCount / 7)
    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 = line * dayHeight
            val right = left + dayWidth
            val bottom = top + dayHeight
            if (top.isNaN()) continue
            child.layout(
                left.roundToInt(),
                top.roundToInt(),
                right.roundToInt(),
                bottom.roundToInt()
            )
        }
    }
    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        for (time in beginTime..endTime step dayMillis) {
            WeekDayView(context).let { child ->
                child.calendar.timeInMillis = time
                addView(child)
                child.setOnClickListener {
                    // 点击时经过rootCalendarRender设置顶层日历控件的selectedDayTime
                    if (selectedDayTime.dDays != child.beginTime.dDays) {
                        rootCalendarRender?.selectedDayTime = child.beginTime
                    }
                }
                if (scheduleModels.any()) {
                    child.getSchedulesFrom(scheduleModels)
                }
            }
        }
    }
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        removeAllViews()
    }
}

Tips:这里的getSchedulesFrom(scheduleModels)相似reloadSchedulesFromProvider()办法,也是获取数据用的,不同在于它不需求从外部获取,而是从parentRenderscheduleModels中截取就行了。

fun getSchedulesFrom(from: List<IScheduleModel>) {
    scheduleModels = from.filter { it.beginTime >= beginTime && it.endTime <= endTime }
        .sortedBy { it.beginTime }
}
  • WeekDayView完成

这是最小控件了,也就不需求完成ICalendarParent接口了,自己依据特点制作自己就好了。

class WeekDayView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), ICalendarRender {
    override val parentRender: ICalendarRender
        get() = parent as ICalendarRender
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    override val calendar: Calendar = beginOfDay()
    // 起止时刻便是当天的零点到24点
    override val beginTime: Long
        get() = calendar.timeInMillis
    override val endTime: Long
        get() = calendar.timeInMillis + dayMillis
    override var selectedDayTime: Long by setter(-1) { _, time ->
        invalidate()
    }
    override var scheduleModels: List<IScheduleModel> = listOf()
        set(value) {
            field = value
            invalidate()
        }
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        drawDate(canvas)
        // drawTasks(canvas)
    }
    private fun drawDate(canvas: Canvas) {
        // ...省掉掉大段的canvas制作代码
    }
}

杀割

本篇涉及的细节部分不多,最重要的便是对日历结构的构思,其间包含了笔者一些对代码结构的思考和实践。

此时,我也答复了为啥要重复造轮子的问题:因为有了一套日历结构后,项目中各个日历模块是有机的全体,而不是为了完成需求拼凑起来的一堆三方库;而且依据这个结构去开发其他日历视图就事半功倍了。

有问题或主张的朋友,欢迎谈论区交流。笔者接下来还会抽时刻介绍一下其他两种视图的完成思路和细节,感兴趣的朋友能够重视一下。

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