Android 自定义 View 实践:一个可缩放、可滚动、可拖拽的日程看板

81 阅读5分钟

🚀 打造可交互日程看板:Android 自定义 View 的拖拽、缩放与惯性滚动实现

在 Android 应用开发中,当涉及到复杂的日程管理、排班或时间轴展示时,原生的组件往往难以满足需求。本文将解析如何通过自定义 View 来实现一个功能完备的日程看板:AppointmentBoardView。它不仅支持基础的网格绘制、横向滚动和双指缩放,还实现了复杂的日程拖拽、占位符显示以及拖拽回调拦截。

🌟效果一览

appointment.gif

🌟 核心功能

我们实现的 AppointmentBoardView 具备以下关键特性:

  1. 网格与固定列绘制:绘制日期表头、固定的“上午/下午”时间列,以及可滚动的日期网格。
  2. 双指缩放:通过 ScaleGestureDetector 动态调整可见日期数量,实现平滑的缩放体验。
  3. 横向滚动与惯性:通过 GestureDetectorOverScroller 实现流畅的横向滚动和惯性滑动效果。
  4. 日程拖拽与重排:支持长按日程并拖动到新的日期和时段,实时显示占位符,并在放下时完成数据更新。
  5. 拖拽回调与拦截:提供回调接口,允许业务层在数据变更前进行拦截(如权限检查),并在成功后进行数据同步。

💻 关键实现细节分析

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 中频繁创建。
  • 尺寸单位转换:使用 dpToPxspToPx 辅助方法,确保所有尺寸(如 headerHeightleftColumnWidth)都已转换为像素,实现适配。
  • 裁剪 (withClip) :在绘制可滚动的日程项时,使用 canvas.withClip() 限制绘制区域,避免绘制到左侧固定列上。
  • 局部绘制判断:在 drawGriddrawAppointments 中,判断当前列是否在可见区域内,不在则直接跳过,减少绘制工作量。

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 实现惯性滚动:

  1. 滚动 (onScroll) :直接根据滑动距离更新 offsetX
  2. 惯性 (onFling) :计算惯性速度,启动 scroller.fling()
  3. 驱动重绘 (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)

onTouchEventACTION_MOVE 中,我们调用 updatePlaceholder(x, y)

  1. 确定目标单元格 (Cell) :通过手指的 xy 坐标,结合当前的 offsetXcolWidth,计算出新的 (newDay, newPeriod)
  2. 确定插入索引 (Index) :通过手指在 Cell 中的相对 Y 坐标,结合每个 Item 的高度 itemH,计算出新的 placeholderIndex
  3. 约束索引placeholderIndex 不能超过当前目标 Cell 中非拖动中日程的数量。
c. 绘制占位符

drawAppointments 逻辑中,我们遍历 Cell 内的日程列表。如果检测到当前索引与 placeholderIndex 相等,则先绘制一个半透明的矩形作为占位符 (drawPlaceholder),然后继续绘制正常的日程,从而实现占位符实时插入的效果。

d. 拖拽放下 (handleDrop)

ACTION_UP 时调用 handleDrop()

  1. 拦截:调用 onBeforeAppointmentDropListener 检查是否允许放下。
  2. 数据变更:如果允许,首先从列表中删除被拖动日程,然后根据 placeholderIndex 将其插入到目标 Cell 中正确的位置,并更新 appt.dayOffsetappt.period
  3. 回调:成功后,调用 onAppointmentMovedListener 通知业务层进行数据同步。
  4. 取消:如果拦截返回 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 类型的支持。