关键字:自定义组件、飞书日历、行程看板
背景:
产品:“你看下飞书这个日历,能不能做”
我:“???”
产品:“咱们肯定不用做的这么复杂,就一个看版,顶上能切换日期就行”
我:“???”
产品:“没问题就下个版本上了啊”
我:“???”
记得那年冬天,产品拿着手机开开心心的过来甩了一个需求,要求仿飞书来一个日历看板,咱们拿到手里体验了下,就感觉到他们产品对研发那满满的恶意了。
简单分析下这个日历的功能:
- 按照起止时间展示行程卡片
- 同一时间段存在多个行程卡片要错开展示
- 行程卡片时间跨度不一
- 行程卡片展示样式不一
- 行程卡片支持整体拖动修改时间,也支持拖动上下边框修改起止时间
- 点击空白区域支持快速创建
- …
这看板,把百度翻烂都难找开源案例,常用的 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. 这个代码是引入项目前的最后一个版本,拖动功能被删除了,基本实现方案就是:
- ScheduleCardLayoutParam 里头增加 oldWeight oldColumn oldStart oldEnd 几个变量,用于松手时处理动画效果(可省略)
- onTouch 判断长按、拖动时获取手指位置
- onDraw 中在手指位置绘制一个被拖动的 View 生成的 Bitmap
- 松手时,先让 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有兴趣可以告诉我,我上传完就更新过来
当然也欢迎各位朋友指出问题或提出意见,如果愿意一起再往下完善这个组件欢迎交流~