前几天笔者接连发布了“高仿飞书日历”系列的两篇文章:

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

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

今天继续分享:月视图。先上效果图:

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

需求确认

月视图的显现和交互相对简略一点。

  • 每页展现一个月的月历,左右滑动切换上/下月。
  • 月历中每天的日期下展现当天的日程称号列表,如果展现不完整则在日期右侧展现剩余日程数。
  • 点击选中某一地利,以这一天地点的周,上下打开。打开时,在中间显现详细的日程列表,如果没有日程则显现空页面。左右滑动切换上/下一天。
  • 打开状态下,日程列表能够左右滑动。点击选中的日期可收起日程列表,点击其他日期可切换日程列表。

结构先行

布局&烘托结构

为便于理解,咱们不妨将月视图的布局分为外层和内层。

外层

从效果图和需求中能够看出,月视图全体上便是一个左右翻页控件,那么ViewPager/ViewPager2/RecyclerView都是能够考虑的。笔者对RecyclerView比较熟悉,就选用了RecyclerView+PagerSnapHelper来构建了。

class MonthGroup @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {
    init {
        layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
        PagerSnapHelper().attachToRecyclerView(this)
    }
}

现在在外层布局构建阶段,那就暂时不考虑内层的布局细节了。咱们能够考虑组合GridViewViewPager这样的方法来构建月历布局,但笔者喜爱自由发挥,就先用一个自界说ViewGroup占坑吧。

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

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

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()中去处理愈加直观。

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来烘托吧。

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()中铲除好了。增加多少个呢?这就需求依据日历数据去核算了,这儿暂时不处理。

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的运用,比较单调,这儿就点到为止了哈。

// 日程列表外层
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)
}
// 日程列表内层,包括一个上下滑动的日程列表和一个空页面
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中:

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)
    }
}

至此,咱们的布局&烘托结构就完成了。

日历结构

在上一篇中咱们已经界说好了日历结构,接下来,咱们将给月视图绑定日历结构,并且让月视图和三日/日视图联系起来。

在上一篇文章中,咱们已经演示过日历结构的使用方法了。和日视图中的周控件相同,咱们也让月视图相关的组件去完成ICalendarRenderICalendarParent接口。因为相关控件比较多,这儿就只贴一下MonthGroup的代码了,其他组件中的完成差不多。

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()方法,在ViewGroupchildren遍历childRenders。不禁感叹,Kotlin特性能够大大简化咱们的代码,并且能给咱们编码供给更多想象空间。

至此,咱们的结构部分就建立完成了。接下来,介绍一下部分详细完成。

详细完成

增加和布局DayView

首要,咱们需求核算MonthViewDayView的个数,这儿咱们能够使用Calendar核算出来。

咱们ICalendarRender不是完成了ITimeRangeHolder吗?

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

只要确认(完成)了MonthView中的beginTimeendTime,那么(endTime - beginTime) / dayMillis便是DayView的个数了。

// 当月第一天地点周的周日
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中的代码彻底相同,哈哈!

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()
        )
    }
}

打开和收起

要完成打开和收起,根本上来说便是要改动DayViewMonthView中的方位(废话)。那么DayView的方位怎么改动呢,刚刚写的onLayout()方法中处理呗,让它的top可变就行。

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)确认的,而collapseTopcollapseBottom是根据collapseCenter在打开动画中动态改变到目标方位的。

另外别忘了哦,打开时中间的日程列表DailyTaskListViewGroup和其他ICalendarRender相同,打开时需求为DailyTaskListViewGroup设置calendarscheduleModels数据。

// > -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嵌套纵向RecyclerViewMonthGroup又是一个横向RecyclerView,所以咱们需求处理一下滑动抵触。

简略来说,便是在咱们左右滑动日程列表时,经过调用parent.requestDisallowInterceptTouchEvent(true),不让父布局阻拦事情就行了。这边简略贴一下代码:

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就完事了。

杀割

本文介绍的月视图,相对于三日/日视图来说,愈加挨近平时咱们在工作中接到的需求,做起来比较单调一点。好在有先前就界说好的日历结构加持,笔者在做完成时还比较顺利。

后面笔者将介绍最终一个日程视图了,先贴一张效果图预告,感兴趣的朋友能够重视一下。

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

最终贴一下代码地址,欢迎star和issues。