前段时间,笔者陆续发布了“高仿飞书日历”系列的三篇文章:
【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改需求了。最惨的是,由于学习得很仓促、零碎,脑壳都是昏的,没有系统地理解清楚,即使这次把功能实现了,下次遇到类似的需求又得重新来一遍,心态崩了。。。
如果我精通CoordinatorLayout
和NestedScrollingParent/2/3
框架,那么我会毫不犹豫地选择它来实现这个需求,但是我明白自己并不熟悉它,可能我强行基于它们来构建代码,很可能会踩到坑里。
当笔者去选择实现方案时,大多是以这样的优先级:本人精通的轮子 > 最基础的API > 官方轮子 > 第三方轮子。
最基础的API虽然实现起来有点麻烦甚至枯燥,但它的优势在于稳定可靠。与其去选择本人不熟悉的CoordinatorLayout
和NestedScrollingParent/2/3
,还不如退而求其次,选择最笨的方法,用最基础的dispatchTouchEvent/onInterceptTouchEvent/onTouchEvent
来处理滑动事宜。
本系列的第一篇中,笔者在做渲染框架时,选择了继承View
的方式,而不是基于ScrollView
、RecyclerView
之类的滑动控件,也是在这个思路下作出的决定。
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
中的数据,只需要在我们的ViewModel
或Presenter
中构建数据集,然后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
}
这个beginTime
和endTime
就确定了日历控件的显示范围。那么对于日程列表来说,去更新beginTime
和endTime
,就能更新日程列表的前后长度,也就实现了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赛高!