前几天笔者陆续发布了“高仿飞书日历”系列的两篇文章:
【Android自定义View】高仿飞书日历(一) – 三日视图
【Android自定义View】高仿飞书日历(二) – 日视图
今天继续分享:月视图。先上效果图:
需求确定 月视图的显示和交互相对简单一点。
每页展示一个月的月历,左右滑动切换上/下月。
月历中每天的日期下展示当天的日程名称列表,如果展示不完整则在日期右侧展示剩余日程数。
点击选中某一天时,以这一天所在的周,上下展开。展开时,在中间显示详细的日程列表,如果没有日程则显示空页面。左右滑动切换上/下一天。
展开状态下,日程列表可以左右滑动。点击选中的日期可收起日程列表,点击其他日期可切换日程列表。
框架先行 布局&渲染框架 为便于理解,我们不妨将月视图的布局分为外层和内层。
外层 从效果图和需求中可以看出,月视图整体上就是一个左右翻页控件,那么ViewPager/ViewPager2/RecyclerView
都是可以考虑的。笔者对RecyclerView
比较熟悉,就选用了RecyclerView
+PagerSnapHelper
来构建了。
1 2 3 4 5 6 7 8 9 class MonthGroup @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RecyclerView(context, attrs) { init { layoutManager = LinearLayoutManager(context, HORIZONTAL, false) PagerSnapHelper().attachToRecyclerView(this) } }
目前在外层布局构建阶段,那就暂时不考虑内层的布局细节了。我们可以考虑组合GridView
和ViewPager
这样的方式来构建月历布局,但笔者喜欢自由发挥,就先用一个自定义ViewGroup
占坑吧。
1 2 3 4 5 6 7 8 class MonthView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ViewGroup(context, attrs, defStyleAttr) { override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { // TODO 布局 } }
为MonthGroup
定义一个adapter
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class MonthAdapter : RecyclerView.Adapter<VH>() { private val monthCount: Int = 0 // TODO 计算月份数 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { return VH(parent.context) } override fun getItemCount() = monthCount override fun onBindViewHolder(holder: VH, position: Int) { // TODO 为MonthView绑定日期和日程数据 } } class VH(context: Context) : ViewHolder(MonthView(context).apply { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) })
让MonthGroup
绑定一个adapter,为了拿到当前的月份,我们定义一个lastPosition
字段,并在OnScrollListener
中计算更新lastPosition
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class MonthGroup @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RecyclerView(context, attrs) { private var lastPosition = -1 init { layoutManager = LinearLayoutManager(context, HORIZONTAL, false) PagerSnapHelper().attachToRecyclerView(this) adapter = MonthAdapter() addOnScrollListener(object : OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (newState == SCROLL_STATE_IDLE) { val llm = recyclerView.layoutManager as LinearLayoutManager val position = llm.findFirstCompletelyVisibleItemPosition() if (position != -1 && lastPosition != position) { lastPosition = position // TODO 处理选中某月份的逻辑 } } } }) } }
这样月视图的外层布局&渲染框架就完成了。
内层 考虑一下,效果图中,显示“日 一 二 三..”那一行(header)是固定的,也不可交互,我们可以直接在onDraw()
方法中绘制它们。而日期Item和日程列表的布局需要处理动态更新,在onLayout()
中去处理更加直观。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class MonthView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ViewGroup(context, attrs, defStyleAttr) { private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textSize = 11f.dp } private val topPadding = 26f.dp init { setWillNotDraw(false) // topPadding以上用于绘制周header,以下用于布局子View updatePadding(top = topPadding.roundToInt()) } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) paint.color = ScheduleConfig.colorBlack3 // TODO 绘制header } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { // TODO 布局日期Item和日程列表 } }
日期Item中需要绘制日期、日程和选中状态,就简单地自定义一个View
来渲染吧。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class DayView @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) drawDate(canvas) drawTasks(canvas) drawArrow(canvas) } private fun drawDate(canvas: Canvas) { // TODO 绘制日期 } private fun drawTasks(canvas: Canvas) { // TODO 绘制日程 } private fun drawArrow(canvas: Canvas) { // TODO 绘制选中箭头 } }
这时,我们需要让每一个MonthView
去添加一些DayView
到它的布局中。简单处理,就在onAttachedToWindow()
中添加,在onDetachedFromWindow()
中清除好了。添加多少个呢?这就需要根据日历数据去计算了,这里暂时不处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class MonthView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ViewGroup(context, attrs, defStyleAttr) { ... override fun onAttachedToWindow() { super.onAttachedToWindow() // TODO addView(DayView(context)) } override fun onDetachedFromWindow() { super.onDetachedFromWindow() removeAllViews() } }
别忘了还有一个展开状态,我们需要再添加一个日程列表在MonthView
中,这个日程列表是可以左右滑动的,我们继承一个RecyclerView
来实现,它就是一个常规的RecyclerView
的使用,比较枯燥,这里就点到为止了哈。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 日程列表外层 class DailyTaskListViewGroup @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RecyclerView(context, attrs) { ... inner class Adapter() : RecyclerView.Adapter<VH>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { return VH(DailyTaskListView(parent.context).apply { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT,LayoutParams.MATCH_PARENT) }) } override fun getItemCount() = 7 override fun onBindViewHolder(holder: VH, position: Int) { ... } } class VH(val dailyTaskListView: DailyTaskListView) : ViewHolder(dailyTaskListView) }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 // 日程列表内层,包含一个上下滑动的日程列表和一个空页面 class DailyTaskListView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : FrameLayout(context, attrs) { private val recyclerView: RecyclerView private val emptyView: TextView init { inflate(context, R.layout.daily_task_list_view, this) recyclerView = findViewById(R.id.recyclerView) emptyView = findViewById(R.id.emptyView) emptyView.text = buildSpannedString { append("暂无日程安排,") color(ScheduleConfig.colorBlue1) { append("点击创建") } } emptyView.setOnClickListener { ... } } inner class Adapter : RecyclerView.Adapter<ViewHolder>() { ... } class VH(itemView: View) : ViewHolder(itemView) { ... } }
然后,我们把日程列表DailyTaskListViewGroup
直接添加到MonthView
中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class MonthView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ViewGroup(context, attrs, defStyleAttr) { private val dailyTaskListViewGroup: DailyTaskListViewGroup init { setWillNotDraw(false) updatePadding(top = topPadding.roundToInt()) dailyTaskListViewGroup = DailyTaskListViewGroup(context).apply { layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) setBackgroundColor(ScheduleConfig.colorBlack6) } addView(dailyTaskListViewGroup) } override fun onDetachedFromWindow() { super.onDetachedFromWindow() // 只移除DayView,保留DailyTaskListViewGroup removeViews(1, childCount - 1) } }
至此,我们的布局&渲染框架就完成了。
日历框架 在上一篇 中我们已经定义好了日历框架,接下来,我们将给月视图绑定日历框架,并且让月视图和三日/日视图联系起来。
在上一篇文章中,我们已经演示过日历框架的应用方式了。和日视图中的周控件一样,我们也让月视图相关的组件去实现ICalendarRender
和ICalendarParent
接口。由于相关控件比较多,这里就只贴一下MonthGroup
的代码了,其他组件中的实现差不多。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 class MonthGroup @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RecyclerView(context, attrs), ICalendarRender, ICalendarParent { override val parentRender: ICalendarRender? = null override val calendar: Calendar = beginOfDay() override var selectedDayTime: Long by setter(nowMillis) { _, time -> if (!isVisible) return@setter childRenders.forEach { it.selectedDayTime = time } post { val position = time.parseMonthIndex() if (abs(lastPosition - position) < 5) { smoothScrollToPosition(position) } else { scrollToPosition(position) } lastPosition = position } } override var scheduleModels: List<IScheduleModel> by setter(emptyList()) { _, list -> childRenders.forEach { it.getSchedulesFrom(list) } } override val beginTime: Long get() = ScheduleConfig.scheduleBeginTime override val endTime: Long get() = ScheduleConfig.scheduleEndTime override val childRenders: List<ICalendarRender> get() = children.filterIsInstance<ICalendarRender>().toList() }
注意一下,当ViewGroup
实现了ICalendarParent
时,可以直接利用filterIsInstance()
方法,在ViewGroup
的children
中遍历childRenders
。不禁感叹,Kotlin特性可以大大简化我们的代码,并且能给我们编码提供更多想象空间。
至此,我们的框架部分就搭建完成了。接下来,介绍一下部分具体实现。
具体实现 添加和布局DayView 首先,我们需要计算MonthView
中DayView
的个数,这里我们可以利用Calendar
计算出来。
咱们ICalendarRender
不是实现了ITimeRangeHolder
吗?
1 2 3 4 5 6 interface ITimeRangeHolder { val beginTime: Long val endTime: Long } interface ICalendarRender : ITimeRangeHolder
只要确定(实现)了MonthView
中的beginTime
和endTime
,那么(endTime - beginTime) / dayMillis
就是DayView
的个数了。
1 2 3 4 5 6 7 8 9 10 // 当月第一天所在周的周日 override val beginTime: Long get() = beginOfDay(calendar.firstDayOfMonthTime).apply { set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY) }.timeInMillis // 当月最后一天所在周的周六 override val endTime: Long get() = beginOfDay(calendar.lastDayOfMonthTime).apply { set(Calendar.DAY_OF_WEEK, Calendar.SATURDAY) }.timeInMillis
这样,我们就可以在onAttachedToWindow()
和onLayout()
中处理DayView
的添加、初始化和布局了。
PS:这里的onLayout()
和日视图中的WeekView
中的代码完全一样,哈哈!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 override fun onAttachedToWindow() { super.onAttachedToWindow() for (time in beginTime..endTime step dayMillis) { DayView(context).let { child -> child.calendar.timeInMillis = time addView(child) // 老样子,从parentRender中截取scheduleModels if (scheduleModels.any()) { child.getSchedulesFrom(scheduleModels) } } } } 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 = paddingTop + line * dayHeight val right = left + dayWidth val bottom = top + dayHeight if (top.isNaN()) continue child.layout( left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt() ) } }
展开和收起 要实现展开和收起,根本上来说就是要改变DayView
在MonthView
中的位置(废话)。那么DayView
的位置怎么改变呢,刚刚写的onLayout()
方法中处理呗,让它的top
可变就行。
1 2 3 4 5 6 7 8 9 override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { for (index in 0 until childCount) { ... var top = paddingTop + line * dayHeight // TODO top = ... ... } }
仔细看,点击DayView
展开时,DayView
所在的那一周向上滑动到顶部,下一周向下滑动到底部。
我们可以这样理解:展开时,有一条中心线(collapseCenter
),有一条线(collapseTop
)从中心线开始向上挤,另一条线(collapseBottom
)从中心线开始向下挤。
collapseCenter
是根据展开的那一周的行数(collapseLine
)确定的,而collapseTop
和collapseBottom
是基于collapseCenter
在展开动画中动态变化到目标位置的。
另外别忘了哦,展开时中间的日程列表DailyTaskListViewGroup
和其他ICalendarRender
一样,展开时需要为DailyTaskListViewGroup
设置calendar
和scheduleModels
数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 // > -1,展开; == -1,收起 private var collapseLine = -1 set(value) { onCollapseLineChanged(field, value) field = value } private var animatingCollapseLine = -1 private var collapseCenter: Float = -1f private var collapseTop: Float = -1f private var collapseBottom: Float = -1f override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { for (index in 0 until childCount) { ... var top = paddingTop + line * dayHeight if (animatingCollapseLine >= 0) { if (line <= animatingCollapseLine) { top -= collapseCenter - collapseTop } else { top += collapseBottom - collapseCenter } } ... } } private fun onCollapseLineChanged(old: Int, new: Int, doOnCollapsed: () -> Unit = {}) { if (old == -1 && new >= 0) { // 从收起到展开 // 展开时更新DailyTaskListViewGroup的日期和日程数据 dailyTaskListViewGroup.calendar.timeInMillis = beginTime + new * 7 * dayMillis dailyTaskListViewGroup.getSchedulesFrom(scheduleModels) collapseCenter = paddingTop + (new + 1) * dayHeight val destTop = paddingTop + dayHeight val destBottom = if (new < childCount / 7 - 1) { paddingTop + (childCount / 7 - 1) * dayHeight } else { paddingTop + childCount / 7 * dayHeight } ValueAnimator.ofFloat(0f, 1f).apply { doOnStart { animatingCollapseLine = new } doOnEnd { animatingCollapseLine = new } duration = 300 addUpdateListener { collapseTop = collapseCenter + (destTop - collapseCenter) * it.animatedFraction collapseBottom = collapseCenter + (destBottom - collapseCenter) * it.animatedFraction requestLayout() } }.start() } else if (old >= 0 && new == -1) { // 从展开到收起 dailyTaskListViewGroup.calendar.timeInMillis = -1 dailyTaskListViewGroup.scheduleModels = emptyList() collapseCenter = paddingTop + (old + 1) * dayHeight val startTop = collapseTop val startBottom = collapseBottom ValueAnimator.ofFloat(0f, 1f).apply { doOnStart { animatingCollapseLine = old } doOnEnd { animatingCollapseLine = new doOnCollapsed.invoke() } duration = 300 addUpdateListener { collapseTop = startTop + (collapseCenter - startTop) * it.animatedFraction collapseBottom = startBottom + (collapseCenter - startBottom) * it.animatedFraction requestLayout() } }.start() } else if (old != new && old >= 0 && new >= 0) { // 从展开第a行到展开第b行,连续调用两次 onCollapseLineChanged(old, -1) { onCollapseLineChanged(-1, new) } } }
滑动冲突 日程列表DailyTaskListViewGroup
中是横向RecyclerView
嵌套纵向RecyclerView
,MonthGroup
又是一个横向RecyclerView
,所以我们需要处理一下滑动冲突。
简单来说,就是在我们左右滑动日程列表时,通过调用parent.requestDisallowInterceptTouchEvent(true)
,不让父布局拦截事件就行了。这边简单贴一下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 open class StableOrientationRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RecyclerView(context, attrs) { private var downX = 0f private var downY = 0f private var justDown = false private val isHorizontal: Boolean get() = (layoutManager as? LinearLayoutManager)?.orientation == HORIZONTAL private val touchSlop = ViewConfiguration.getTouchSlop() override fun dispatchTouchEvent(e: MotionEvent): Boolean { when (e.action) { MotionEvent.ACTION_DOWN -> { downX = e.x downY = e.y justDown = true } MotionEvent.ACTION_MOVE -> { if (justDown && (abs(downX - e.x) > touchSlop || abs(downY - e.y) > touchSlop)) { val moveHorizontal = abs(downX - e.x) > abs(downY - e.y) if (moveHorizontal == isHorizontal) { parent.requestDisallowInterceptTouchEvent(true) } justDown = false } } MotionEvent.ACTION_UP -> { justDown = false parent.requestDisallowInterceptTouchEvent(false) } } return super.dispatchTouchEvent(e) } }
然后让DailyTaskListViewGroup
继承StableOrientationRecyclerView
就完事了。
杀割 本文介绍的月视图,相对于三日/日视图来说,更加接近平时我们在工作中接到的需求,做起来比较枯燥一点。好在有先前就定义好的日历框架加持,笔者在做实现时还比较顺畅。
后面笔者将介绍最后一个日程视图了,先贴一张效果图预告,感兴趣的朋友可以关注一下。
最后贴一下代码地址 ,欢迎star和issues。