Android 仿飞书日历做的行程看板组件

关键字:自定义组件、飞书日历、行程看板


背景:

产品:“你看下飞书这个日历,能不能做”

我:“???”

产品:“咱们肯定不用做的这么复杂,就一个看版,顶上能切换日期就行”

我:“???”

产品:“没问题就下个版本上了啊”

我:“???”


飞书日历截图

Demo截图

记得那年冬天,产品拿着手机开开心心的过来甩了一个需求,要求仿飞书来一个日历看板,咱们拿到手里体验了下,就感觉到他们产品对研发那满满的恶意了。

简单分析下这个日历的功能:

  1. 按照起止时间展示行程卡片
  2. 同一时间段存在多个行程卡片要错开展示
  3. 行程卡片时间跨度不一
  4. 行程卡片展示样式不一
  5. 行程卡片支持整体拖动修改时间,也支持拖动上下边框修改起止时间
  6. 点击空白区域支持快速创建

这看板,把百度翻烂都难找开源案例,常用的 RecyclerView 嵌套方案也做不出来这种效果,为了达成这个效果,咱们只能自定义布局去手动 onLayout 了。


看板背景:

首先,这个灰色的时间数字和线条基本上固定展示,与业务数据无关,咱们就直接抽离出来,自定义个 Drawable去实现

package com.elee.uidemo.schedule

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
import android.util.TypedValue

class ScheduleTimeBackgroundDrawable(context: Context) : Drawable() {

    /**
     * 起止时间
     */
    var startHour: Int = 0
    var endHour: Int = 24

//    /**
//     * 小时高度
//     */
//    var hourHeight: Int = dp2px(context, 40F)
//
    /**
     * 小时分割线高度
     */
    var dividerHeight: Int = dp2px(context, 0.5F)

    /**
     * 分割线颜色
     */
    var dividerColor: Int = Color.argb(14, 0, 0, 0)

    /**
     * 时间颜色
     */
    var timeTextColor: Int = Color.argb(70, 0, 0, 0)

    /**
     * 时间文案宽度
     */
    var timeWidth: Int = dp2px(context, 42F)

    /**
     * 时间字体大小
     */
    var timeTextSize: Int = dp2px(context, 12F)

    override fun draw(canvas: Canvas) {
        var linePaint = Paint().apply {
            strokeWidth = dividerHeight.toFloat()
            style = Paint.Style.FILL
            color = dividerColor
            flags = Paint.ANTI_ALIAS_FLAG or Paint.DITHER_FLAG
        }
        var timePaint = Paint().apply {
            textSize = timeTextSize.toFloat()
            color = timeTextColor
            style = Paint.Style.FILL
            flags = Paint.ANTI_ALIAS_FLAG or Paint.DITHER_FLAG
        }
        val height = bounds.height()
        val width = bounds.width()

        val hourHeight = height / (endHour - startHour)

        for (i in startHour until endHour) {
            val top = (i - startHour) * hourHeight
            canvas.drawLine(
                0F,
                top.toFloat(),
                width.toFloat(),
                top.toFloat() + dividerHeight.toFloat(),
                linePaint
            )
            canvas.drawText(
                "${if (i < 10) "0$i" else i}:00",
                0F,
                top + (timeTextSize + hourHeight) / 2F,
                timePaint
            )
        }
        canvas.drawLine(
            0F,
            height - dividerHeight.toFloat(),
            width.toFloat(),
            height.toFloat(),
            linePaint
        )
    }

    override fun setAlpha(alpha: Int) {
    }

    override fun setColorFilter(colorFilter: ColorFilter?) {
    }

    /**
     * 透明度样式
     */
    override fun getOpacity(): Int = PixelFormat.TRANSLUCENT

    private fun dp2px(context: Context, dpVal: Float): Int {
        return TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP,
            dpVal, context.resources.displayMetrics
        ).toInt()
    }
}

p.s. 这个 draw 里头存在问题,Paint不应该在这里头初始化,或者说 draw 里头不应该 new 任何对象,在做动画等刷新频率较高的场景,频繁调用 draw 导致大量对象创建回收会导致内存抖动。自定义 View 的 measure 和 layout 同理。

p.p.s. 这个 Drawable 没有红色展示当前时间,如果需要就在 draw 里头实现就行。记得增加当前时间和整点时间过近时不展示整点时间数字的判断,还有每分钟刷新的一个延时通知。


自定义看板布局:

package com.elee.uidemo.schedule

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.children
import java.util.Calendar
import java.util.Date

/**
 * 行程卡片
 */
class ScheduleCardGroupView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    companion object {
        const val MINUTE: Long = 60 * 1000L
        const val HOUR: Long = 60 * 60 * 1000L
    }

    /**
     * 起止时间
     */
    var startHour: Int = 0
    var endHour: Int = 24

    /**
     * 小时高度
     */
    var hourHeight: Int = dp2px(context, 40F)

    /**
     * 小时间隔
     */
    var dividerHeight: Int = dp2px(context, 8F)

    /**
     * 适配器
     */
    var adapter: ScheduleCardAdapter? = null
        set(value) {
            field = value
            field?.refreshListener = {
                notifyDataSetChanged()
            }
        }

    /**
     * 更新全部行程卡片
     */
    fun notifyDataSetChanged() {
        updateAllView()
    }

    /**
     * 更新全部行程卡片
     */
    fun updateAllView() {
        removeAllViews()

        val childList = mutableListOf<Child>()
        val timeList = mutableListOf<TimeInfo>()

        // 初始化所有卡片信息 及 行程数量变动信息
        adapter?.let {
            for (position in 0 until it.getScheduleCount()) {
                Child(
                    position = position,
                    start = it.getStartTime(position),
                    end = it.getEndTime(position)
                ).let { child ->
                    childList.add(child)
                    timeList.add(TimeInfo(time = child.start))
                    timeList.add(TimeInfo(time = child.end))
                }
            }
        }

        // 对卡片信息进行排序
        childList.sortWith { o1, o2 ->
            return@sortWith (o1.start - o2.start).toInt()
        }

        // 对时间节点进行 去重+排序
        timeList.distinctBy { it.time }
        timeList.sortWith { o1, o2 ->
            return@sortWith (o1.time - o2.time).toInt()
        }
        // 确定每个时间段最大并行数量
        var count = 0
        timeList.forEach {
            count = 0
            childList.forEach { child ->
                if (it.time < child.end && it.time >= child.start) {
                    count += 1
                }
            }
            it.num = count
        }
        childList.forEach {
            count = 0
            timeList.forEach { t ->
                if (t.time >= it.start && t.time < it.end) {
                    it.weight = it.weight.coerceAtLeast(t.num)
                }
            }
        }

        if (childList.size > 0) {
            var endTime = 0L
            var start = 0
            var position = 1
            var maxWeight = 0
            while (start < childList.size) {
                maxWeight = childList[start].weight
                endTime = childList[start].end
                while (position < childList.size && childList[position].start < endTime) {
                    // 找到最大权重
                    maxWeight = childList[position].weight.coerceAtLeast(maxWeight)
                    endTime = endTime.coerceAtLeast(childList[position].end)
                    position += 1
                }
                for (i in start until position) {
                    childList[i].weight = maxWeight
                }
                start = position
                position += 1
            }
        }

        adapter?.let {
            childList.forEachIndexed { index, child ->
                var columnIndex = -1
                var columnValid = false
                // 查找可用column
                while (!columnValid) {
                    columnIndex += 1
                    columnValid = true
                    children.forEach {
                        // 如果不可用,那么判断下一个
                        Calendar.getInstance().time = Date(child.start)
                        if (!(it.layoutParams as ScheduleCardLayoutParam).checkColumnValid(
                                start = child.start,
                                end = child.end,
                                index = columnIndex
                            )
                        ) {
                            columnValid = false
                        }
                    }
                }
                // 添加view
                addView(it.getView(child.position, this), ScheduleCardLayoutParam(
                    startTime = Calendar.getInstance().apply { time = Date(child.start) },
                    endTime = Calendar.getInstance().apply { time = Date(child.end) }
                ).apply {
                    weight = child.weight
                    column = columnIndex
                })
            }
        }
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val maxWidth = MeasureSpec.getSize(widthMeasureSpec)
        val height = paddingTop + paddingBottom +
                (endHour - startHour) * (hourHeight + dividerHeight)

        children.forEach {
            measureChild(
                it,
                MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
            )
        }

        setMeasuredDimension(
            MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.EXACTLY),
            MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
        )
    }

    override fun measureChild(
        child: View?,
        parentWidthMeasureSpec: Int,
        parentHeightMeasureSpec: Int
    ) {
        val lineWidth = MeasureSpec.getSize(parentWidthMeasureSpec) - paddingLeft - paddingRight
        child?.let {
            val params = it.layoutParams as ScheduleCardLayoutParam
            params.width = lineWidth / params.weight
            params.height =
                ((params.endTime.get(Calendar.HOUR_OF_DAY) - params.startTime.get(Calendar.HOUR_OF_DAY) - if (params.endTime.get(
                        Calendar.MINUTE
                    ) == 0
                ) 1 else 0) * dividerHeight +
                        hourHeight * (params.endTime.timeInMillis - params.startTime.timeInMillis) / HOUR).toInt()
            it.measure(
                MeasureSpec.makeMeasureSpec(params.width, MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(params.height, MeasureSpec.EXACTLY)
            )
        }
    }

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
//        super.onLayout(changed, left, top, right, bottom)
        children.forEach {
            val param = it.layoutParams as ScheduleCardLayoutParam
            val childLeft = paddingLeft + param.column * param.width
            val childTop = paddingTop +
                    dividerHeight / 2 +
                    ((hourHeight + dividerHeight) * (param.startTime.get(Calendar.HOUR_OF_DAY) - startHour)) +
                    hourHeight * (param.startTime.get(Calendar.MINUTE)) / 60
            it.layout(childLeft, childTop, childLeft + param.width, childTop + param.height)
        }
    }

    override fun dispatchDraw(canvas: Canvas?) {
        super.dispatchDraw(canvas)
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
    }

    private fun dp2px(context: Context, dpVal: Float): Int {
        return TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP,
            dpVal, context.resources.displayMetrics
        ).toInt()
    }
}

abstract class ScheduleCardAdapter {

    var refreshListener: () -> Unit = {}

    /**
     * 获取行程数量
     */
    abstract fun getScheduleCount(): Int

    /**
     * 根据position获取开始时间
     */
    abstract fun getStartTime(position: Int): Long

    /**
     * 根据position获取结束时间
     */
    abstract fun getEndTime(position: Int): Long

    /**
     * 获取具体展示用的view
     */
    abstract fun getView(position: Int, parent: ViewGroup): View

    fun notifyDataSetChanged() {
        refreshListener.invoke()
    }
}

private class ScheduleCardLayoutParam(
    val startTime: Calendar,
    val endTime: Calendar,
) : FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) {

    var weight: Int = 1

    var column: Int = 0

    /**
     * 判断 index 是否与 layout param 的column 重复
     * return true 没问题 false 不可用
     */
    fun checkColumnValid(start: Long, end: Long, index: Int): Boolean {
        return startTime.timeInMillis >= end || endTime.timeInMillis <= start || column != index
    }
}

private data class Child(
    var position: Int,
    var weight: Int = 1,
    var start: Long,
    var end: Long,
    var view: View? = null
)

private data class TimeInfo(
    val time: Long,
    var num: Int = 0
)

p.s. 如果给业务做自定义 ViewGroup,能用 adapter 就要设计 adapter,好处极多。

p.p.s. 如果自定义 ViewGroup 需要让每一个元素记录布局信息,最佳方案就是自定义一个 LayoutParam,然后在 generateLayoutParam 方法中进行初始化。在这个项目由于数据间关联太多所以我放到 updateAllView 方法内处理了。

p.p.p.s 点击卡片肯定放到 adapter 中处理,如果点击空白时间需要增加就需要在自定义 ViewGroup里头处理了(代码实装到项目后增加的功能,这个Demo版本没有)

p.p.p.p.s. 这个代码是引入项目前的最后一个版本,拖动功能被删除了,基本实现方案就是:

  1. ScheduleCardLayoutParam 里头增加 oldWeight oldColumn oldStart oldEnd 几个变量,用于松手时处理动画效果(可省略)
  2. onTouch 判断长按、拖动时获取手指位置
  3. onDraw 中在手指位置绘制一个被拖动的 View 生成的 Bitmap
  4. 松手时,先让 LayoutParam 里头的 oldXXX 记录当前 XXX 的信息,然后把对应拖动的卡片时间进行修改,再然后 updateAllView 计算修改完位置,最后 onMeasure 和 onLayout 在计算时多算一步动画效果(如果没有动画就直接updateAllView)

p.p.p.p.p.s 这条附言没意义,我就是想看附附附附附言能有多长~


实战案例:

package com.elee.uidemo.schedule

import android.app.Activity
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.elee.uidemo.R
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale

class ScheduleBoardDemoActivity : Activity() {

    private val layoutContent: View by lazy { findViewById(R.id.layout_content) }
    private val viewScheduleBoard: ScheduleCardGroupView by lazy { findViewById(R.id.view_schedule_board) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_schedule_board_demo)

        val hour: Long = 60 * 60 * 1000L
        val currentDay: Long =
            SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).parse("2023-07-05")?.time ?: 0L
        val list = mutableListOf(
            ScheduleCardBean(
                start = currentDay + hour * 7,
                end = currentDay + hour * 8,
                text = "第一件事"
            ),
            ScheduleCardBean(
                start = currentDay + hour * 7,
                end = (currentDay + hour * 8.75).toLong(),
                text = "第2件事"
            ),
            ScheduleCardBean(
                start = currentDay + hour * 10,
                end = currentDay + hour * 11,
                text = "第3件事"
            ),
            ScheduleCardBean(
                start = (currentDay + hour * 9).toLong(),
                end = currentDay + hour * 11,
                text = "第4件事"
            ),
            ScheduleCardBean(
                start = currentDay + hour * 9,
                end = currentDay + hour * 12,
                text = "第5件事"
            ),
            ScheduleCardBean(
                start = currentDay + hour * 11,
                end = currentDay + hour * 12,
                text = "第6件事"
            ),
            ScheduleCardBean(
                start = currentDay + hour * 11,
                end = currentDay + hour * 12,
                text = "第7件事"
            ),
            ScheduleCardBean(
                start = currentDay + hour * 11,
                end = currentDay + hour * 12,
                text = "第8件事"
            ),
            ScheduleCardBean(
                start = currentDay + hour * 13 + hour / 6,
                end = currentDay + hour * 14,
                text = "第9件事"
            ),
            ScheduleCardBean(
                start = (currentDay + hour * 14.25).toLong(),
                end = (currentDay + hour * 15.75).toLong(),
                text = "第10件事"
            )
        )

        val start = 6

        layoutContent.background = ScheduleTimeBackgroundDrawable(this).apply {
            this.startHour = start
        }
        viewScheduleBoard.startHour = start
        viewScheduleBoard.adapter = object : ScheduleCardAdapter() {
            override fun getScheduleCount(): Int = list.size

            override fun getStartTime(position: Int): Long = list[position].start ?: 0L

            override fun getEndTime(position: Int): Long = list[position].end ?: 0L

            override fun getView(position: Int, parent: ViewGroup): View {
                val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.layout_schedule_card_item, parent, false)
                view.findViewById<TextView>(R.id.tv_schedule).apply {
                    text = list[position].text
                }
                return view
            }

        }
        viewScheduleBoard.notifyDataSetChanged()
    }
}

通过这个案例可以看出,这个看板在使用的时候只需要实现 ScheduleCardAdapter 这个抽象类,就能将数据转化成看板中的卡片,这样不同的页面可以自己写一套 adapter,极大的提高复用性


源码暂时没有上传 Github,如果有人对这个Demo有兴趣可以告诉我,我上传完就更新过来

当然也欢迎各位朋友指出问题或提出意见,如果愿意一起再往下完善这个组件欢迎交流~

  • 21
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值