前段时间,笔者陆续发布了“高仿飞书日历”系列的三篇文章:
【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赛高!