前段时间,笔者陆续发布了“高仿飞书日历”系列的三篇文章:

【Android自定义View】高仿飞书日历(一) — 三日视图

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

【Android自定义View】高仿飞书日历(三) — 月视图

今天持续分享终究一个视图:列表视图。先上作用图:

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

需求确认

相对来说,这个视图中的交互逻辑要略微杂乱一点。

  • 列表视图包含两个部分:顶部周/月控件,底部日程列表。周/月控件显现日期和是否有日程(圆点表明);日程列表各个Item显现月份、周数、日程等信息。
  • 周/月控件能够左右滑动切换周/月;能够通过手势或点击箭头来切换周/月形式。
  • 在周形式下,日程列表可上下滑动;在月形式下,日程列表向上滑动时切换周形式,制止向下滑动。
  • 日程列表滑动时选中列表顶部的日期,假如当天有多个日程,滑动过程中日期固定(pin)在列表顶部。
  • 周/月控件中能够通过点击选中某一天,选中时,日程列表主动定位到当天的日程;假如被选中的某天没有日程,则显现“暂无日程安排,点击创立”。
  • 在周形式下,假如当时选中了某一天(比方:周三),那么左/右滑动后选中上/下周的周三。
  • 在月形式下,左/右滑动后选中上/下月的一号。

结构先行

布局&烘托结构

从作用图和需求来看,控件全体上是一个Header+List的形式,它们之间存在滑动交互。要完成它,咱们很直观地想到CoordinatorLayout(和谐布局)。许多同学(包含前两年的笔者)在这时,或许就不管三七二十一,开端翻阅CoordinatorLayout的相关博客,Copy/Paste代码了。

请先等一等。

笔者想和大家聊聊一个或许比较重要的问题。

怎样挑选技术完成计划?

当咱们接到需求时,依据经验去挑选了一个完成计划,假如这个计划咱们并不是十分了解,需求临时去查阅材料,那么这个完成计划很或许不是合适咱们的计划。比方当时这个需求,咱们假如挑选了不太了解的CoordinatorLayout,希望Copy代码就能够帮咱们快速完成需求时,或许实际操作起来会让咱们失望,乃至让咱们堕入进退维谷的泥淖。CoordinatorLayout是Google官方针对Material Design,依据NestedScrollingParent/2/3完成的一套UI结构,当然它供给了一些常见的UI作用的快速完成,但这些作用本来便是服务于Material Design的,尽管看起来像,但或许和咱们的需求差一点点,这种时分咱们只能持续去找解决计划,比方怎样自定义Behavior,乃至需求去了解和调试NestedScrollingParent/2/3的各个办法是怎样和谐工作。

一边学习一边调试一边开发需求,渐渐地,咱们发现估时不行用了,只能加班、延期或者找产品Battle改需求了。最惨的是,因为学习得很仓促、琐细,脑壳都是昏的,没有系统地理解清楚,即使这次把功用完成了,下次遇到相似的需求又得重新来一遍,心态崩了。。。

假如我通晓CoordinatorLayoutNestedScrollingParent/2/3结构,那么我会毫不犹豫地挑选它来完成这个需求,但是我明白自己并不了解它,或许我强行依据它们来构建代码,很或许会踩到坑里。

当笔者去挑选完成计划时,大多是以这样的优先级:自己通晓的轮子 > 最基础的API > 官方轮子 > 第三方轮子。

最基础的API尽管完成起来有点麻烦乃至单调,但它的优势在于安稳可靠。与其去挑选自己不了解的CoordinatorLayoutNestedScrollingParent/2/3,还不如退而求其次,挑选最笨的办法,用最基础的dispatchTouchEvent/onInterceptTouchEvent/onTouchEvent来处理滑动事宜。

本系列的第一篇中,笔者在做烘托结构时,挑选了继承View的办法,而不是依据ScrollViewRecyclerView之类的滑动控件,也是在这个思路下作出的决定。

PS:工作中尽量选用这样的思路去进步工作效率和质量,私底下仍是需求花时间学习,补齐短板哟~

言归正传。

和上一篇的月视图相同,咱们挑选RecyclerView来完成周/月控件;同样的,挑选RecyclerView来完成日程列表;然后,将它们组合到一个LinearLayout中。

// 周/月控件
class FlowHeaderGroup @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {
    ...
}
// 日程列表
class ScheduleFlowView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {
    ...
}
class FlowContainer @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : LinearLayout(context, attrs) {
    private val flowHeader: FlowHeaderGroup
    private val flowHeaderArrow: ImageView
    private val scheduleList: ScheduleFlowView
    ...
}

布局&烘托结构至此就建立完成了。

日历结构

在本系列的第二篇中,咱们现已定义好了依据ICalenderRender的日历结构了,这儿的完成仍是老样子:每个控件都去完成ICalendarRender,假如有子render就完成ICalendarParent。以FlowHeaderGroup为例,它和日视图、月视图中的日历控件完成上几乎是相同的,

class FlowHeaderGroup @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs), ICalendarRender, ICalendarParent {
    ...
}

不过也有一点不相同,列表视图中的日历控件,是支撑周/月形式切换的。简单啊,按照惯例,笼统一个接口为其赋能:


interface ICalendarModeHolder {
    var calendarMode: CalendarMode
}
sealed interface CalendarMode {
    data class MonthMode(
        val expandFraction: Float = 0f,
    ) : CalendarMode
    object WeekMode : CalendarMode
}

笔者定义了一个ICalendarModeHolder接口,以及一个密封接口:CalendarMode。为啥要用密封接口而不用枚举呢?因为笔者需求用数据驱动UI。周/月形式,被我笼统为CalendarMode;而切换的进展,被我笼统为MonthMode下的expandFraction。这样一来,咱们进行滑动操作时,对calendarMode赋值就行了。

// 日历收起时
calendarMode = WeekMode
// 日历打开一半时
calendarMode = MonthMode(0.5f)
// 日历完全打开时
calendarMode = MonthMode(1.0f)

相应的,FlowHeaderGroup去完成ICalendarModeHolder

class FlowHeaderGroup @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs), ICalendarRender, ICalendarParent, ICalendarModeHolder {
    ...
    override var calendarMode: CalendarMode by setter(CalendarMode.WeekMode) { oldMode, mode ->
        if (oldMode is CalendarMode.MonthMode && mode is CalendarMode.MonthMode) {
            onExpandFraction(mode.expandFraction)
        }
        onCalendarModeSet(mode)
    }
    private fun onExpandFraction(fraction: Float) {
        // TODO 更新布局
    }
    private fun onCalendarModeSet(mode: CalendarMode) {
        // 周/月形式下,子render的样式也会改动
        childRenders.filterIsInstance<ICalendarModeHolder>().forEach {
            it.calendarMode = mode
        }
        // 周/月形式切换时,更新recyclerView的数据源
        if (mode is CalendarMode.WeekMode || (mode as? CalendarMode.MonthMode)?.expandFraction == 0f) {
            adapter?.notifyDataSetChanged()
            scrollToPosition(selectedDayTime.parseIndex())
        }
    }
}

至此,日历结构也建立完成了。

具体完成

滑动手势处理

有的同学对滑动手势处理望而生畏,其实只需一点一点地拆解开,手势处理并不困难,无非是在阻拦(onInterceptTouchEvent)和消费(onTouchEvent)这两个过程中,判别和处理咱们的滑动手势逻辑。

前面咱们现已说到,手势是在父布局(FlowContainer)中处理的。咱们要处理的手势状况,首要包含ACTION_DOWN/ACTION_MOVE/ACTION_UP,在这儿它们各自的用处是什么呢?

  • ACTION_DOWN:重置按下状况(justDown),并记载按下的方位(downX/downY);
  • ACTION_MOVE:判别滑动方向和方向,从而判别是否阻拦;更改周/月形式(即:核算并设置calendarMode);
  • ACTION_UP:核算松手后的速度和方位,从而确认终究的calendarMode

代码如下:

class FlowContainer @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : LinearLayout(context, attrs), ICalendarRender, ICalendarParent {
    private val flowHeader: FlowHeaderGroup
    private val flowHeaderArrow: ImageView
    private val scheduleList: ScheduleFlowView
    // ... 省掉掉ICalendarRender的完成
    private var downX: Float = 0f
    private var downY: Float = 0f
    private var justDown: Boolean = false
    private val touchSlop = ViewConfiguration.getTouchSlop()
    private var intercept = false
    private var fromMonthMode = false
    private val velocityTracker by lazy {
        VelocityTracker.obtain()
    }
    // Header(日历控件)底部
    private val headerBottom: Int
        get() = (flowHeaderArrow.parent as View).bottom
    override fun onTouchEvent(event: MotionEvent): Boolean {
        // 在消费工作时,假如不阻拦,则调用默认的super.onTouchEvent(event)
        return performInterceptTouchEvent(event) || super.onTouchEvent(event)
    }
    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        // 在阻拦工作时,假如不阻拦,则调用默认的super.onInterceptTouchEvent(ev)
        val intercept = performInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev)
        return intercept
    }
    private fun performInterceptTouchEvent(ev: MotionEvent): Boolean {
        velocityTracker.addMovement(ev)
        return when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                downX = ev.x
                downY = ev.y
                justDown = true
                false
            }
            MotionEvent.ACTION_MOVE -> {
                // 假如justDown为true,就要先判别是否阻拦工作
                if (justDown) {
                    fromMonthMode = flowHeader.calendarMode is CalendarMode.MonthMode
                }
                if (justDown && (abs(downX - ev.x) > touchSlop || abs(downY - ev.y) > touchSlop)) {
                    val moveUp = abs(downX - ev.x) < abs(downY - ev.y) && ev.y < downY
                    val moveDown = abs(downX - ev.x) < abs(downY - ev.y) && ev.y > downY
                    // 依据按下方位,滑动方向和当时的calendarMode来判别是否阻拦工作
                    intercept = (moveUp && flowHeader.calendarMode is CalendarMode.MonthMode)
                            || (moveDown && downY < headerBottom && flowHeader.calendarMode is CalendarMode.WeekMode)
                            || (moveDown && downY > headerBottom && flowHeader.calendarMode is CalendarMode.MonthMode)
                    justDown = false
                }
                if (intercept) { 
                    // 在阻拦工作时,calendarMode就在MonthMode(0.0f~1.0f)规模内变化了
                    if (!fromMonthMode && flowHeader.calendarMode is CalendarMode.WeekMode) {
                        flowHeader.calendarMode = CalendarMode.MonthMode(0f)
                    }
                    val maxHeight = (6 * flowHeaderDayHeight)
                    if (fromMonthMode) {
                        flowHeader.calendarMode = CalendarMode.MonthMode(
                            expandFraction = ((maxHeight - downY + ev.y) / maxHeight).coerceAtLeast(0f).coerceAtMost(1f),
                        )
                    } else {
                        flowHeader.calendarMode = CalendarMode.MonthMode(
                            expandFraction = ((flowHeaderDayHeight - downY + ev.y) / maxHeight).coerceAtLeast(
                                0f
                            ).coerceAtMost(1f),
                        )
                    }
                    true
                } else {
                    false
                }
            }
            MotionEvent.ACTION_UP -> {
                velocityTracker.computeCurrentVelocity(1000)
                val velocity = velocityTracker.yVelocity
                // 当速度绝对值大于1000时,终究方位以速度方向为准;不然,以当时方位为准
                if (intercept && flowHeader.calendarMode is CalendarMode.MonthMode) {
                    val target = if (velocity < -1000) {
                        CalendarMode.WeekMode
                    } else if (velocity > 1000) {
                        CalendarMode.MonthMode(1f)
                    } else if ((flowHeader.calendarMode as CalendarMode.MonthMode).expandFraction < 0.5f) {
                        CalendarMode.WeekMode
                    } else {
                        CalendarMode.MonthMode(1f)
                    }
                    flowHeader.autoSwitchMode(target.apply {
                        flowHeaderArrow.rotation = if (this is CalendarMode.MonthMode) {
                            0f
                        } else {
                            180f
                        }
                    })
                }
                intercept = false
                false
            } 
            else -> {
                false
            }
        }
    }
}

只需咱们明确每一个手势状况下需求做的工作,那么其实手势处理并不困难吧。

日程列表

这儿的日程列表首要有两个特点:需求显现月、周以及每天的日程数据,即多类型Item;需求上下无限滑动,即需求处理前后的LoadMore

许多同学或许都预备引入第三方的RecyclerView轮子了,但前面笔者现已说到官方轮子>第三方轮子了,这儿咱们选用androidx.recyclerview.widget.ListAdapter来完成。

ListAdapter的中心思维便是数据驱动UI,无论列表中的逻辑再杂乱,咱们也不需求去手动操作adapter中的数据,只需求在咱们的ViewModelPresenter中构建数据集,然后submitList就完事了。而且,Kotlin给咱们供给的丰富而强壮的集合扩展办法,大大地简化了咱们的数据处理,乃至还进步了功能。

多类型Item

为了完成多类型,咱们先定义一下咱们的数据模型(IFlowModel),它也是依据IScheduleModel的,因为咱们需求处理排序(Month->Week->Day),咱们给它增加一个sortValue特点。

然后三种Item类型分别用MonthText/WeekText/FlowDailySchedules来表明。


interface IFlowModel : IScheduleModel {
    val sortValue: Long
}
data class MonthText(
    override val beginTime: Long,
) : IFlowModel {
    override val sortValue: Long = beginTime
    override val endTime: Long = beginTime.calendar.lastDayOfMonthTime
}
data class WeekText(
    override val beginTime: Long,
) : IFlowModel {
    override val sortValue: Long = beginTime + 1
    override val endTime: Long = beginTime + 7 * dayMillis
}
data class FlowDailySchedules(
    override val beginTime: Long,
    val schedules: List<IScheduleModel>
) : IFlowModel {
    override val sortValue: Long = beginTime + 2
    override val endTime: Long = beginTime + dayMillis
}

相应的, adapter的完成如下:

class ScheduleFlowAdapter : ListAdapter<IFlowModel, VH>(
    object : DiffUtil.ItemCallback<IFlowModel>() {
        override fun areItemsTheSame(
            oldItem: IFlowModel,
            newItem: IFlowModel
        ) = oldItem == newItem
        override fun areContentsTheSame(
            oldItem: IFlowModel,
            newItem: IFlowModel
        ): Boolean {
            if (oldItem is MonthText && newItem is MonthText) {
                return oldItem.beginTime == newItem.beginTime
            } else if (oldItem is WeekText && newItem is WeekText) {
                return oldItem.beginTime == newItem.beginTime
            } else if (oldItem is FlowDailySchedules && newItem is FlowDailySchedules) {
                return oldItem.beginTime == newItem.beginTime && oldItem.schedules == newItem.schedules
            }
            return false
        }
    }
) {
    private val MONTH_TEXT = 1
    private val WEEK_TEXT = 2
    private val DAILY_TASK = 3
    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is MonthText -> MONTH_TEXT
            is WeekText -> WEEK_TEXT
            else -> DAILY_TASK
        }
    }
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        return when (viewType) {
            MONTH_TEXT -> MonthTextVH(parent.context)
            WEEK_TEXT -> WeekTextVH(parent.context)
            else -> DailyTaskVH(
                LayoutInflater.from(parent.context)
                    .inflate(R.layout.flow_daily_item, parent, false)
            )
        }
    }
    override fun onBindViewHolder(holder: VH, position: Int) {
        holder.onBind(getItem(position))
    }
}
abstract class VH(view: View) : RecyclerView.ViewHolder(view) {
    abstract fun onBind(scheduleModel: IScheduleModel)
}
class MonthTextVH(context: Context) : VH(TextView(context)) {
    // ...
}
class WeekTextVH(context: Context) : VH(TextView(context)) {
    // ...
}
class DailyTaskVH(itemView: View) : VH(itemView) {
    // ...
}

构建IFlowModel的逻辑看似杂乱,其实在数据驱动UI思维和Kotlin语法糖的加持下,能够变得如此简单:

override var scheduleModels: List<IScheduleModel> by setter(emptyList()) { _, list ->
    generateViewModels(list)
}
private fun generateViewModels(list: List<IScheduleModel>) {
    // 将日程数据按天分组,然后map为FlowDailySchedules
    list.groupBy { it.beginTime.dDays }.values.map {
        FlowDailySchedules(
            beginTime = beginOfDay(it[0].beginTime).timeInMillis,
            schedules = it.sortedBy { model -> model.beginTime }
        )
    }.toMutableList<IFlowModel>().apply {
        // 然后在列表中插入月(MonthText)和周(WeekText)
        val days = map { it.beginTime.dDays }
        for (time in beginTime..endTime step dayMillis) {
            if ((time.dDays == nowMillis.dDays || time.dDays == focusedDayTime.dDays) && !days.contains(
                    time.dDays
                )
            ) {
                add(
                    FlowDailySchedules(
                        beginTime = time,
                        schedules = emptyList()
                    )
                )
            }
            if (time.dayOfMonth == 1) {
                add(MonthText(time))
            }
            if (time.dayOfWeek == Calendar.SUNDAY) {
                add(WeekText(time))
            }
        }
    }.sortedBy { it.sortValue }.apply { // 终究排序后submitList
        flowAdapter.submitList(this)
    }
}

LoadMore

看过本系列前面几篇的同学应该记住,咱们的日历结构是依据ITimeRangeHolder的。

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

这个beginTimeendTime就确认了日历控件的显现规模。那么关于日程列表来说,去更新beginTimeendTime,就能更新日程列表的前后长度,也就完成了LoadMore的作用了。这儿仍然是数据驱动UI的体现。

在以下代码中,咱们通过监听RecyclerView的滑动,得到当时是否快要滑动到顶/底部,然后减小beginTime/增大endTime就能够了。

addOnScrollListener(object : OnScrollListener() {
    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        super.onScrolled(recyclerView, dx, dy)
        val llm = recyclerView.layoutManager as LinearLayoutManager
        val firstVisible = llm.findFirstVisibleItemPosition()
        val lastVisible = llm.findLastVisibleItemPosition()
        checkLoadMore(firstVisible, lastVisible)
    }
})
private fun checkLoadMore(firstVisible: Int, lastVisible: Int) {
    if (firstVisible < 10) {
        beginTime = beginTime.calendar.apply {
            add(Calendar.YEAR, -1)
        }.timeInMillis
        if (!loadingMore) {
            loadingMore = true
            reloadSchedulesFromProvider()
        }
    } else if (lastVisible > ((adapter?.itemCount ?: 0) - 10).coerceAtLeast(0)) {
        endTime = endTime.calendar.apply {
            add(Calendar.YEAR, 1)
        }.timeInMillis
        if (!loadingMore) {
            loadingMore = true
            reloadSchedulesFromProvider()
        }
    }
}

更新beginTime/endTime后,咱们调用reloadSchedulesFromProvider()办法更新数据(scheduleModels),然后调用前面的generateViewModels办法就行了。

杀割

更多的完成细节,这儿就不打开介绍了,想要详细了解请移步源码

整个“高仿飞书日历”项目的构思和完成心得,其实总结起来就这么几点:

  • 坚持数据驱动UI思维
  • 面向笼统构建代码
  • 掌握最基本的布局、制作、滑动手势处理
  • 培养挑选技术完成计划的思路
  • Kotlin赛高!