Android 自定义视图复刻 Apple 心率图表实现
Android 自定义 View 实现 Apple 风格心率图表。通过 Canvas saveLayer 裁剪窗口,结合 ObjectAnimator 实现平滑回滚动画。利用 MotionEvent 区分快速滑动与长按触摸,动态切换数据标注显示模式。核心在于坐标计算与绘制范围优化,确保滑动流畅且性能可控。

Android 自定义 View 实现 Apple 风格心率图表。通过 Canvas saveLayer 裁剪窗口,结合 ObjectAnimator 实现平滑回滚动画。利用 MotionEvent 区分快速滑动与长按触摸,动态切换数据标注显示模式。核心在于坐标计算与绘制范围优化,确保滑动流畅且性能可控。

在移动端 UI 设计中,Apple 产品的原生交互体验常被视为标杆,其动画的丝滑程度和细节处理尤为出色。本文旨在通过 Android 原生技术栈,复刻 Apple 健康应用中心率数据统计图表的核心交互效果。重点在于实现数据条的滑动、边界回滚动画以及触摸状态下的数据标注自适应。
Apple 原版交互特征:
Android 复刻目标:
在编写代码前,需对图表结构进行拆解,将其转化为具体的业务需求与布局逻辑。
图表本质上由以下三个核心组件构成:
数据条 (Data Bar)
坐标轴 (Axis)
数据标注 (Indicator Label)
isShowIndicator 控制切换,简化逻辑处理。基于上述分析,我们采用自定义 View 配合 Canvas 绘图的方式实现。工作流程如下:
onTouchEvent 处理点击、触摸触发标注及图表滑动逻辑。为确保页面规整,需满足以下等式:
(lineWidth + lineSpace) * n = chartWidth
其中 n 为最大可视数据条数量(如 24)。此设计保证了左右侧空白宽度均为 0.5 * lineSpace,使视觉居中。
定义 Kotlin 数据类存储单条心率记录:
data class HeartRateChartEntry(
val time: Date = Date(),
val minValue: Int = 66,
val maxValue: Int = 88
)
使用 ArrayList 批量存储数据,便于索引访问。
横向背景线与 Y 轴刻度全程静态,可直接计算起讫点坐标绘制。
(getWidth() - chartWidth) / 2startX + chartWidthunitDistance = chartHeight / (k - 1)绘制循环示例:
(0..mHorizontalLineSliceAmount).forEach { i ->
val currentLabel = getLabel(i)
val currentY = startY - i * mVerticalUnitDistance
canvas.drawLine(startX, currentY, endX, currentY, mAxisPaint)
canvas?.drawText("$currentLabel", endX + mTextSize / 3, currentY + mTextSize / 3, mLabePaint)
}
canvas.drawLine(startX, startY, startX, startY - mChartHeight, mAxisPaint)
为解决滑动闪烁问题,采用'窗口'机制而非直接修改数据索引。
leftRangeIndex, rightRangeIndex),并在遍历绘制时限制范围(如 leftRangeIndex - 3 到 rightRangeIndex + 3)。canvas.saveLayer() 和 canvas.restoreToCount() 截取窗口区域,确保数据条不溢出 Chart 容器。核心绘制逻辑:
val windowLayer = canvas?.saveLayer(
left = chartLeftMargin,
top = 0F,
right = chartRightBorner,
bottom = widthBottom
)
(0 until mValueArray.size).forEach { index ->
if (index > drawRangeRight || index < drawRangeLeft) return@forEach
// 计算横坐标:从右向左绘制
val currentX = mViewStartX - index * (mLineWidth + mLineSpace) - chartRightMargin
val startY = baseY - mChartHeight / mYAxisRange.second * mValueArray[index].maxValue
val endY = baseY - mChartHeight / mYAxisRange.second * mValueArray[index].minValue
if (mValueArray[index].maxValue != 0) {
canvas?.drawLine(currentX, startY, currentX, endY, mLinePaint)
}
}
canvas?.restoreToCount(windowLayer!!)
通过 boolean isShowIndicator 控制显示模式。维护 indexOnClicked 变量记录当前被点击的数据条索引,在 onTouchEvent 中动态更新并触发重绘。
我们需要区分两种主要状态:
isScrolling):用户快速滑动切换数据条,改变 viewStartX。isLongTouch):用户手指停留查看数据标注,此时切换标注下标但不移动数据条。利用时间差与距离阈值判断:
ACTION_DOWN 时刻时间戳。ACTION_MOVE 中计算时间间隔与位移。时间间隔 < 阈值 且 位移 > 阈值,判定为快速滑动。isScrolling = true,后续连续 MOVE 事件均执行滑动逻辑。使用协程延迟任务实现:
ACTION_DOWN 后启动延时任务(如 500ms)。核心事件处理:
override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
mLastX = event.x
currentMS = System.currentTimeMillis()
startIndicatorTimer() // 启动长按计时器
}
MotionEvent.ACTION_MOVE -> {
val moveX = mLastX - event.x
mLastX = event.x
// 快速滑动逻辑
if (((System.currentTimeMillis() - currentMS) < TOUCHMOVEDURATION && abs(moveX) > mLineWidth) || isScrolling) {
isScrolling = true
turnOffIndicatorTimer() // 取消长按
mViewStartX -= moveX
updateCurrentDrawRange()
} else if (isLongTouch) {
// 长按模式下切换标注
isShowIndicator = true
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
turnOffIndicatorTimer()
drawBackToBorder() // 触发回滚动画
}
}
return true
}
滑动结束后,需确保视窗对齐网格。使用 ObjectAnimator 实现平滑过渡:
fun drawBackToBorder() {
var endValue: Float = 0F
val step = mLineWidth + mLineSpace
if (mViewStartX < mInitialStartX) {
endValue = mInitialStartX
} else if (mViewStartX > mInitialStartX + (mValueArray.size - 24) * step) {
endValue = mInitialStartX + (mValueArray.size - 24) * step
} else {
endValue = mViewStartX - (mViewStartX - mInitialStartX).mod(step)
}
val anim = ObjectAnimator.ofFloat(this, "mViewStartX", endValue)
anim.interpolator = DecelerateInterpolator()
anim.addUpdateListener { mViewStartX = it.animatedValue as Float }
anim.start()
}
注意:需在 mViewStartX 的 Setter 方法中添加 invalidate() 以触发重绘。
在实际开发中,除了功能实现,还需关注渲染性能:
saveLayer 会消耗额外内存,仅在必要时使用。若数据量极大,可考虑预渲染静态部分。onDraw 中频繁创建 Paint 对象或 Path 对象,应在构造函数中初始化并复用。本文详细阐述了如何在 Android 平台上通过自定义 View 复刻 Apple 风格的心率统计图表。核心技术点包括:
通过合理的坐标计算与绘制范围优化,可在保证交互流畅度的同时维持良好的性能表现。开发者可根据实际业务需求,进一步扩展支持多系列数据对比或更复杂的动画曲线。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online