🚀 打造可交互日程看板:Android 自定义 View 的拖拽、缩放与惯性滚动实现
在 Android 应用开发中,当涉及到复杂的日程管理、排班或时间轴展示时,原生的组件往往难以满足需求。本文将解析如何通过自定义 View 来实现一个功能完备的日程看板:AppointmentBoardView。它不仅支持基础的网格绘制、横向滚动和双指缩放,还实现了复杂的日程拖拽、占位符显示以及拖拽回调拦截。
🌟效果一览
🌟 核心功能
我们实现的 AppointmentBoardView 具备以下关键特性:
- 网格与固定列绘制:绘制日期表头、固定的“上午/下午”时间列,以及可滚动的日期网格。
- 双指缩放:通过
ScaleGestureDetector动态调整可见日期数量,实现平滑的缩放体验。 - 横向滚动与惯性:通过
GestureDetector和OverScroller实现流畅的横向滚动和惯性滑动效果。 - 日程拖拽与重排:支持长按日程并拖动到新的日期和时段,实时显示占位符,并在放下时完成数据更新。
- 拖拽回调与拦截:提供回调接口,允许业务层在数据变更前进行拦截(如权限检查),并在成功后进行数据同步。
💻 关键实现细节分析
1. 数据结构 (Appointment)
我们用一个简单的数据类来承载日程信息:
data class Appointment(
val id: Int,
var dayOffset: Int, // 日期偏移量 (0..N)
var period: Int, // 0: 上午, 1: 下午
val name: String,
var color: Int
)
2. 绘制优化 (onDraw)
为了提升性能,我们采取了以下优化措施:
- 预创建
Paint对象:将所有Paint对象的初始化放入init块中,避免在onDraw中频繁创建。 - 尺寸单位转换:使用
dpToPx和spToPx辅助方法,确保所有尺寸(如headerHeight、leftColumnWidth)都已转换为像素,实现适配。 - 裁剪 (
withClip) :在绘制可滚动的日程项时,使用canvas.withClip()限制绘制区域,避免绘制到左侧固定列上。 - 局部绘制判断:在
drawGrid和drawAppointments中,判断当前列是否在可见区域内,不在则直接跳过,减少绘制工作量。
3. 双指缩放 (ScaleGestureDetector)
缩放逻辑的核心在于如何根据缩放因子调整可见天数 (daysShownFloat),并重新计算列宽 (colWidth)。
// AppointmentBoardView.kt (部分代码)
private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
// ... 减缓缩放因子
var factor = 1 + (detector.scaleFactor - 1) * 0.5f
// 反比例调整可见天数 (factor越大,daysShownFloat越小)
daysShownFloat = (daysShownFloat / factor).coerceIn(minDays.toFloat(), maxDays.toFloat())
updateColWidth()
// 保持缩放焦点不变:重新计算 offsetX
val focusX = detector.focusX
val relFocusX = focusX - leftColumnWidth
offsetX = ((offsetX + relFocusX) * (colWidth / oldColWidth) - relFocusX)
// ... 约束 offsetX
postInvalidateOnAnimation()
return true
}
}
4. 滚动与惯性 (GestureDetector & OverScroller)
我们使用 GestureDetector.SimpleOnGestureListener 来处理滑动事件,并结合 OverScroller 实现惯性滚动:
- 滚动 (
onScroll) :直接根据滑动距离更新offsetX。 - 惯性 (
onFling) :计算惯性速度,启动scroller.fling()。 - 驱动重绘 (
computeScroll) :在每一帧调用,从scroller中取出新的offsetX,并请求下一帧重绘。
// AppointmentBoardView.kt (onFling & computeScroll)
override fun onFling(...) {
// ... 启动 OverScroller 计算惯性数据
scroller.fling(
offsetX.roundToInt(), 0, (-velocityX).roundToInt(), 0,
0, maxX.roundToInt(), 0, 0
)
postInvalidateOnAnimation() // 启动动画循环
return true
}
override fun computeScroll() {
if (scroller.computeScrollOffset()) {
offsetX = scroller.currX.toFloat().coerceIn(0f, reCalculateMaxX())
postInvalidateOnAnimation() // 持续驱动重绘
}
}
5. 日程拖拽与占位符
日程拖拽是本视图最复杂的部分。
a. 启动拖拽 (onLongPress)
通过 findAppointmentAt(e.x, e.y) 找到被长按的日程,并记录:
draggedAppointment:被拖动的日程对象。originalDayOffset,originalPeriod:原始位置,用于取消或数据回滚。dragOffsetX,dragOffsetY:Item 左上角到点击点的偏移量,确保拖动时 Item 随着手指移动,而不是 Item 的中心。
b. 实时更新占位符 (updatePlaceholder / ACTION_MOVE)
在 onTouchEvent 的 ACTION_MOVE 中,我们调用 updatePlaceholder(x, y):
- 确定目标单元格 (Cell) :通过手指的
x和y坐标,结合当前的offsetX和colWidth,计算出新的(newDay, newPeriod)。 - 确定插入索引 (Index) :通过手指在 Cell 中的相对 Y 坐标,结合每个 Item 的高度
itemH,计算出新的placeholderIndex。 - 约束索引:
placeholderIndex不能超过当前目标 Cell 中非拖动中日程的数量。
c. 绘制占位符
在 drawAppointments 逻辑中,我们遍历 Cell 内的日程列表。如果检测到当前索引与 placeholderIndex 相等,则先绘制一个半透明的矩形作为占位符 (drawPlaceholder),然后继续绘制正常的日程,从而实现占位符实时插入的效果。
d. 拖拽放下 (handleDrop)
在 ACTION_UP 时调用 handleDrop():
- 拦截:调用
onBeforeAppointmentDropListener检查是否允许放下。 - 数据变更:如果允许,首先从列表中删除被拖动日程,然后根据
placeholderIndex将其插入到目标 Cell 中正确的位置,并更新appt.dayOffset和appt.period。 - 回调:成功后,调用
onAppointmentMovedListener通知业务层进行数据同步。 - 取消:如果拦截返回
false或目标位置无效/相同,则不进行任何数据变更,日程自动回到原位。
6. 扩展性:拖拽回调与拦截
通过新增两个 Lambda 接口,我们极大地增强了组件的扩展性和健壮性:
// AppointmentBoardView.kt (回调接口)
var onBeforeAppointmentDropListener: ((
appointment: Appointment,
newDayOffset: Int,
newPeriod: Int,
newIndex: Int
) -> Boolean)? = null
var onAppointmentMovedListener: ((
appointment: Appointment,
oldDayOffset: Int,
oldPeriod: Int
) -> Unit)? = null
🛠️ 总结与展望
通过将绘制、手势识别、惯性动画和拖拽逻辑进行清晰的分层,我们成功实现了一个高度可定制且功能强大的日程看板视图。
| 核心组件 | 作用 | 负责功能 |
|---|---|---|
onDraw | 绘制 | 网格、表头、左侧固定列、日程项、拖动阴影、占位符 |
GestureDetector | 手势识别 | onScroll (平移), onFling (惯性), onLongPress (启动拖拽) |
ScaleGestureDetector | 缩放 | onScale (双指缩放,调整列宽) |
OverScroller | 惯性动画 | onFling 启动,computeScroll 驱动 |
这个自定义 View 为处理复杂日程排布提供了坚实的基础,您可以基于此进一步扩展,例如增加日程点击事件、多选拖拽、或不同 Item 类型的支持。