在新能源车载APP中,收音机频率调节是一个常见需求。
本文将带你实现一个高度定制化的收音机刻度滑动控件,支持流畅滑动、惯性滚动、边界回弹等特性。
1.效果图
2.功能需求
显示87.5MHz到108.0MHz的频率刻度,并将当前频率指示器居中显示
- 显示87.5MHz到108.0MHz的频率范围
- 支持手势滑动调节频率
- 当前频率指示器始终居中
- 支持惯性滑动
- 边界回弹效果
- 自定义刻度样式
3.实现思路
3.1 分析思路,分为5步,实现4个版本
第一步: 测量
第二步: 绘制逻辑
通过上面实现第一个版本MINI
第三步: 触摸事件处理
通过上面实现第而个版本,局部正常功能的 Normal
第四步 : 添加惯性滑动
有了第3个版本 Plus
第五步: 边界回弹效果
有了第4个版本 Utral
3.2 具体的分析
3.2.1 测量
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 获取父容器建议的宽度值(不包含模式信息)
int width = MeasureSpec.getSize(widthMeasureSpec);
// 将默认高度80dp转换为像素值
int height = dpToPx(80); // 默认高度80dp
/**
* 处理高度测量规格:
*
* MeasureSpec.AT_MOST 对应 XML 中的 wrap_content 模式
* 当父容器指定高度为 wrap_content 时,我们需要确保视图高度不会超过父容器允许的最大高度
*
* 这里取默认高度和父容器允许的最大高度中的较小值
*/
if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.AT_MOST) {
// 获取父容器允许的最大高度值
int maxAllowedHeight = MeasureSpec.getSize(heightMeasureSpec);
// 使用默认高度和允许高度的较小值
height = Math.min(height, maxAllowedHeight);
}
/**
* 为什么不处理 EXACTLY 模式?
*
* 当测量模式是 MeasureSpec.EXACTLY 时(对应 match_parent 或具体数值)
* 父容器已经明确指定了视图高度,我们不需要额外处理
*
* 例如:layout_height="100dp" 或 layout_height="match_parent"
* 系统会自动使用 MeasureSpec.getSize() 的值
*/
/**
* 设置最终测量的视图尺寸
*
* 宽度:使用父容器建议的宽度(match_parent 或具体数值)
* 高度:根据上述逻辑计算出的高度值
*/
setMeasuredDimension(width, height);
}
-
默认高度:80dp 转换为像素值
-
AT_MOST 模式(对应
wrap_content):- 获取父容器允许的最大高度
- 取默认高度和允许高度的较小值
-
EXACTLY 模式(
match_parent或具体数值):- 不需要额外处理,系统会使用
MeasureSpec.getSize()的值
- 不需要额外处理,系统会使用
3.2.2 绘制逻辑
通过上面实现第一个版本MINI
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 获取视图尺寸
int width = getWidth();
int height = getHeight();
float centerY = height / 2f; // 垂直中心线位置
// 1. 背景绘制
canvas.drawColor(Color.WHITE); // 设置白色背景
// 2. 计算基础偏移量(实现频率滑动效果)
float frequencyRange = MAX_FREQUENCY - MIN_FREQUENCY;
// 计算当前频率在总范围内的比例(0.0~1.0)
float frequencyRatio = (currentFrequency - MIN_FREQUENCY) / frequencyRange;
// 核心计算:通过频率比例计算画布偏移量,使当前频率点位于屏幕中央
float baseOffset = width / 2f - frequencyRatio * width;
// 3. 处理回弹效果
float totalOffset = baseOffset; // 最终使用的总偏移量
if (overscrollDistance != 0) {
// 将回弹距离转换为像素偏移(保持与频率比例一致)
totalOffset += overscrollDistance / frequencyRange * width;
// 绘制回弹边界指示条
if (overscrollDistance < 0) {
// 左边界回弹:在左侧绘制10dp宽度的提示条
canvas.drawRect(0, 0, dpToPx(10), height, overscrollPaint);
} else {
// 右边界回弹:在右侧绘制提示条
canvas.drawRect(width - dpToPx(10), 0, width, height, overscrollPaint);
}
}
// 4. 绘制主刻度线(1MHz间隔)
float majorStep = 1.0f;
for (float freq = MIN_FREQUENCY; freq <= MAX_FREQUENCY; freq += majorStep) {
// 计算刻度线X坐标:基于总偏移量 + 频率位置比例
float x = totalOffset + (freq - MIN_FREQUENCY) / frequencyRange * width;
// 仅绘制可见区域内的刻度(左右各扩展100px缓冲区)
if (x >= -100 && x <= width + 100) {
// 绘制主刻度线(中心线上下对称延伸)
canvas.drawLine(x, centerY - dpToPx(majorLineHeight/2),
x, centerY + dpToPx(majorLineHeight/2), linePaint);
// 绘制主刻度标签(位于刻度线下方)
String label = String.format("%.0f", freq); // 整数MHz
canvas.drawText(label, x, centerY + dpToPx(majorLineHeight/2 + textMargin), textPaint);
}
}
// 5. 绘制次刻度线(0.1MHz间隔)
float minorStep = 0.1f;
for (float freq = MIN_FREQUENCY; freq <= MAX_FREQUENCY; freq += minorStep) {
// 跳过主刻度位置(避免重复绘制)
if (freq % majorStep == 0) continue;
float x = totalOffset + (freq - MIN_FREQUENCY) / frequencyRange * width;
if (x >= -100 && x <= width + 100) {
// 绘制更短的次刻度线
canvas.drawLine(x, centerY - dpToPx(minorLineHeight/2),
x, centerY + dpToPx(minorLineHeight/2), linePaint);
}
}
// 6. 绘制中央指示器(始终位于屏幕中心)
float indicatorX = width / 2f;
// 绘制比主刻度更长的指示线
canvas.drawLine(indicatorX, centerY - dpToPx(indicatorHeight/2),
indicatorX, centerY + dpToPx(indicatorHeight/2), indicatorPaint);
// 7. 绘制当前频率值(位于指示器上方)
String currentText = String.format("%.1f MHz", currentFrequency); // 带一位小数的MHz
canvas.drawText(currentText, indicatorX, centerY - dpToPx(indicatorHeight/2 + textMargin), textPaint);
}
核心计算逻辑解析
1).频率-位置映射原理:
x = totalOffset + (freq - MIN_FREQUENCY) / frequencyRange * width
- 将频率值
freq转换为画布上的X坐标 -(freq - MIN_FREQUENCY) / frequencyRange计算当前频率在总范围内的归一化位置(0.0~1.0) - 乘以width得到在画布上的理论位置 -totalOffset实现整体滑动效果
2).滑动偏移计算,核心原理:(为了当前频率能够居中显示)
baseOffset = width/2f - frequencyRatio * width
baseOffset 决定了这根标尺的哪一部分显示在屏幕上
- 核心算法:通过负偏移使当前频率点
frequencyRatio始终位于屏幕中心 - 当frequencyRatio=0.5(中点)时,偏移量为0 - 当frequencyRatio=0(最小值)时,偏移量为width/2,将最小频率点推到屏幕中心 frequencyRatio * width:
表示当前频率在标尺上的位置(从标尺起点开始计算width / 2f:
表示屏幕中心位置(我们希望当前频率显示在这里)
总结:baseOffset 实现了频率标尺的动态定位,使当前选中的频率始终保持在视图中心,同时允许用户滑动浏览整个频率范围。
为什么这样设计?
这种设计实现了:
中心锁定:当前频率始终在屏幕中心
自然滑动:滑动时频率刻度从两侧进出屏幕
比例一致:不同频率范围使用相同逻辑
边界处理:轻松实现回弹效果
3).frequencyRatio:
是一个归一化的位置比例值,表示当前频率在总频率范围内的相对位置。它的计算方式和取值范围如下:
float frequencyRatio = (currentFrequency - MIN_FREQUENCY) / (MAX_FREQUENCY - MIN_FREQUENCY);
3.2.3. 触摸事件处理
@Override
public boolean onTouchEvent(MotionEvent event) {
// 处理触摸事件
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 记录触摸起始点X坐标
lastTouchX = event.getX();
// 计算当前频率在视图中的位置(将频率映射到视图宽度比例)
scaleStartPos = (currentFrequency - MIN_FREQUENCY) / (MAX_FREQUENCY - MIN_FREQUENCY) * getWidth();
// 标记开始缩放/滚动操作
isScaling = true;
return true;
case MotionEvent.ACTION_MOVE:
if (isScaling) {
// 计算手指水平移动距离(当前X坐标 - 起始X坐标)
float dx = event.getX() - lastTouchX;
/**
* 实现滚动效果的核心逻辑:
* 1. 根据移动距离调整频率位置:scaleStartPos - dx
* 2. 将新位置转换为频率值:
* newPos / getWidth() = 在视图中的比例位置
* (比例位置) * 频率范围 = 相对于最小频率的偏移量
* MIN_FREQUENCY + 偏移量 = 实际频率值
*/
float newPos = scaleStartPos - dx;
float newFrequency = MIN_FREQUENCY + (newPos / getWidth()) * (MAX_FREQUENCY - MIN_FREQUENCY);
// 确保频率在合法范围内(87.5-108.0MHz)
newFrequency = Math.max(MIN_FREQUENCY, Math.min(MAX_FREQUENCY, newFrequency));
// 更新频率并重绘视图
setCurrentFrequency(newFrequency);
}
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
// 结束滚动操作
isScaling = false;
return true;
}
return super.onTouchEvent(event);
}
/**
* 设置当前频率并刷新视图
* @param frequency 要设置的频率值(MHz),范围必须在87.5到108.0之间
*/
public void setCurrentFrequency(float frequency) {
// 确保频率在有效范围内
if (frequency < MIN_FREQUENCY) {
frequency = MIN_FREQUENCY;
} else if (frequency > MAX_FREQUENCY) {
frequency = MAX_FREQUENCY;
}
// 更新当前频率
this.currentFrequency = frequency;
// 请求重绘视图(触发onDraw调用)
invalidate();
}
滚动效果实现原理
滑动的时候,会把当前的频率改变,要得到当前的频率! 然后进行重绘制!
1). 当前的坐标:坐标映射机制:X的坐标
scaleStartPos = (currentFrequency - MIN_FREQUENCY) / (MAX_FREQUENCY - MIN_FREQUENCY) * getWidth();
- 将整个频率范围(87.5-108.0MHz)映射到视图宽度
- 公式:
屏幕位置 = (频率 - MIN) / (MAX - MIN) * 视图宽度 - 示例:当视图宽度=300px时,87.5MHz→0px,108.0MHz→300px
2). 当前的频率:滚动计算过程:
float newFrequency = MIN + (newPos / width) * 频率范围;
float newPos = scaleStartPos - dx; // 核心滚动公式
float newFrequency = MIN + (newPos / width) * 频率范围;
dx:手指水平移动距离(正→右移,负→左移)- 右滑动:dx为正 → newPos减小 → 频率减小
- 左滑动:dx为负 → newPos增大 → 频率增大
3). 视觉滚动效果:
- 每次ACTION_MOVE都根据手指位移重新计算频率
- 通过
invalidate()触发视图重绘 - 在
onDraw()中根据新频率更新UI元素位置
3.2.4 添加惯性滑动
@Override
public boolean onTouchEvent(MotionEvent event) {
// 初始化速度跟踪器(用于计算滑动速度)
if (velocityTracker == null) {
velocityTracker = VelocityTracker.obtain();
}
velocityTracker.addMovement(event); // 添加当前触摸事件
// 处理不同类型的触摸事件
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: // 手指按下
handleActionDown(event);
return true; // 表示消耗此事件
case MotionEvent.ACTION_MOVE: // 手指移动
handleActionMove(event);
return true;
case MotionEvent.ACTION_UP: // 手指抬起
case MotionEvent.ACTION_CANCEL: // 事件取消
handleActionUp();
return true;
}
return super.onTouchEvent(event);
}
private void handleActionDown(MotionEvent event) {
// 停止当前的惯性滑动或回弹动画
removeCallbacks(flingRunnable);
// 记录触摸点X坐标(用于计算移动距离)
lastTouchX = event.getX();
// 设置滑动标志(表示正在滑动)
isScaling = true;
// 重置回弹标志
isOverscrolling = false;
}
private void handleActionMove(MotionEvent event) {
if (isScaling) {
// 计算X方向上的位移(当前触摸点 - 上次触摸点)
float dx = event.getX() - lastTouchX;
// 更新最后触摸位置
lastTouchX = event.getX();
// 将像素位移转换为频率变化量
// 核心公式:频率变化量 = -位移/视图宽度 * 频率范围
// 负号表示:手指向右滑动(dx为正)时,频率减小;手指向左滑动(dx为负)时,频率增加
float deltaFreq = -dx / getWidth() * (MAX_FREQUENCY - MIN_FREQUENCY);
// 应用频率变化(包含边界回弹处理)
adjustFrequencyWithOverscroll(deltaFreq);
}
}
private void handleActionUp() {
// 结束滑动状态
isScaling = false;
// 计算当前滑动速度(单位:像素/秒)
// 参数1000表示时间单位毫秒(即计算每秒移动多少像素)
velocityTracker.computeCurrentVelocity(1000);
// 获取X方向的速度(水平滑动速度)
lastVelocity = velocityTracker.getXVelocity();
// 如果存在回弹距离或速度超过阈值,则启动惯性滑动或回弹动画
if (overscrollDistance != 0 || Math.abs(lastVelocity) > FLING_THRESHOLD) {
post(flingRunnable); // 启动动画循环
}
// 回收速度跟踪器资源
if (velocityTracker != null) {
velocityTracker.recycle();
velocityTracker = null;
}
}
private void adjustFrequencyWithOverscroll(float deltaFrequency) {
// 计算新的频率值
float newFrequency = currentFrequency + deltaFrequency;
// 检查是否超出边界
if (newFrequency < MIN_FREQUENCY) {
// 超出左边界(最小值):计算回弹距离
// OVERSCROLL_RATIO 控制回弹强度(例如0.5表示回弹距离是超出距离的一半)
overscrollDistance = (newFrequency - MIN_FREQUENCY) * OVERSCROLL_RATIO;
isOverscrolling = true; // 设置回弹状态
} else if (newFrequency > MAX_FREQUENCY) {
// 超出右边界(最大值)
overscrollDistance = (newFrequency - MAX_FREQUENCY) * OVERSCROLL_RATIO;
isOverscrolling = true;
} else {
// 在正常范围内:重置回弹状态
overscrollDistance = 0;
isOverscrolling = false;
}
// 更新当前频率:
// 1. 如果在回弹状态,频率保持在边界值 + 回弹距离
// 2. 否则使用计算的新频率
currentFrequency = isOverscrolling ?
(newFrequency < MIN_FREQUENCY ? MIN_FREQUENCY : MAX_FREQUENCY) + overscrollDistance :
newFrequency;
// 请求重绘视图(触发onDraw)
invalidate();
}
// 惯性滑动和回弹动画的执行器
private Runnable flingRunnable = new Runnable() {
@Override
public void run() {
// 1. 优先处理回弹效果
if (overscrollDistance != 0) {
// 应用阻尼效果:每次减少回弹距离
overscrollDistance *= OVERSCROLL_DAMPING; // 例如0.8
// 当回弹距离足够小时停止回弹
if (Math.abs(overscrollDistance) < 0.1f) {
overscrollDistance = 0;
}
// 更新频率(保持在边界值 + 回弹距离)
currentFrequency = currentFrequency < MIN_FREQUENCY ?
MIN_FREQUENCY + overscrollDistance :
MAX_FREQUENCY + overscrollDistance;
// 请求重绘
invalidate();
// 如果还有回弹距离,继续动画
if (overscrollDistance != 0) {
postDelayed(this, 16); // 约60帧/秒(16ms/帧)
}
return;
}
// 2. 处理惯性滑动
if (Math.abs(lastVelocity) < 50) { // 速度过小则停止
return;
}
// 将速度转换为频率变化量
// 公式:频率变化量 = -速度 / 60 / 视图宽度 * 频率范围
// 解释:
// 1. 除以60:将每秒速度转换为每帧速度(假设60帧/秒)
// 2. 除以视图宽度:将像素速度转换为比例速度
// 3. 乘以频率范围:将比例转换为实际频率变化
// 4. 负号:速度方向与频率变化方向相反
adjustFrequencyWithOverscroll(-lastVelocity / 60 / getWidth() * (MAX_FREQUENCY - MIN_FREQUENCY));
// 应用减速效果:每次减少速度
lastVelocity *= DECELERATION_RATE; // 例如0.95(5%的减速)
// 继续下一帧动画
postDelayed(this, 16);
}
};
惯性滑动(fling)实现原理
- 将当前触摸事件添加到速度跟踪器中
velocityTracker.addMovement(event);
2. 速度跟踪:
在 ACTION_UP 时计算出手指离开时的速度(像素/秒)
* 使用 `VelocityTracker` 记录触摸事件
// 在ACTION_UP事件中
velocityTracker.computeCurrentVelocity(1000); // 计算每1000ms(1秒)的速度
lastVelocity = velocityTracker.getXVelocity(); // 获取X轴方向速度(像素/秒)
3.启动条件判断
if (overscrollDistance != 0 || Math.abs(lastVelocity) > FLING_THRESHOLD) {
post(flingRunnable);
}
4. 速度转换, 速度转换(核心算法):
-
物理意义:将像素速度转换为频率变化量 float deltaFreq = -lastVelocity / 60 / getWidth() * (MAX_FREQUENCY - MIN_FREQUENCY)
-
分解计算:
1). lastVelocity / 60`:将每秒速度转换为每帧速度(假设60FPS)
2). / getWidth():将像素速度转换为屏幕比例速度
3). * (MAX_FREQUENCY - MIN_FREQUENCY):将比例速度转换为实际频率变化量
4). -负号:反转方向(手指向右滑动→频率减小)
- 动画循环:
private Runnable flingRunnable = new Runnable() {
@Override
public void run() {
// 1. 处理回弹(优先级更高)
if (overscrollDistance != 0) {
// ... 回弹处理逻辑
postDelayed(this, 16); // 约60FPS
return;
}
// 2. 处理惯性滑动
if (Math.abs(lastVelocity) < 50) return; // 停止条件
// 应用频率变化
adjustFrequencyWithOverscroll(-lastVelocity / 60 / getWidth() * freqRange);
// 速度衰减
lastVelocity *= DECELERATION_RATE; // 例如0.95
// 继续下一帧
postDelayed(this, 16); // 约60FPS
}
};
- 使用
postDelayed(this, 16)实现60fps动画 - 每帧应用频率变化并重绘视图
- 应用减速效果:
lastVelocity *= DECELERATION_RATE
5. 速度衰减模型
lastVelocity *= DECELERATION_RATE; // 0.95表示每帧减速5%
- 物理模型:模拟摩擦力作用
- 数学原理:等比数列衰减
v = v0 * (decay)^n
- 停止条件:
if (Math.abs(lastVelocity) < 50) return;
- 速度衰减到阈值以下(
Math.abs(lastVelocity) < 50) - 或遇到边界转为回弹状态
3.2.5 边界回弹效果
1. 边界检测
// 在 adjustFrequencyWithOverscroll 方法中
if (newFrequency < MIN_FREQUENCY) {
// 左边界处理
overscrollDistance = (newFrequency - MIN_FREQUENCY) * OVERSCROLL_RATIO;
isOverscrolling = true;
} else if (newFrequency > MAX_FREQUENCY) {
// 右边界处理
overscrollDistance = (newFrequency - MAX_FREQUENCY) * OVERSCROLL_RATIO;
isOverscrolling = true;
} else {
// 正常范围内
overscrollDistance = 0;
isOverscrolling = false;
}
这里 OVERSCROLL_RATIO 控制回弹强度(如0.5表示回弹距离是超出距离的一半)
2. 边界状态管理
- MIN_FREQUENCY:频率范围最小值
- MAX_FREQUENCY:频率范围最大值
- isOverscrolling:标识当前是否处于回弹状态
- overscrollDistance:记录超出边界的距离(负值表示左边界,正值表示右边界)
3. 边界更新逻辑
currentFrequency = isOverscrolling ?
(newFrequency < MIN_FREQUENCY ?
MIN_FREQUENCY + overscrollDistance :
MAX_FREQUENCY + overscrollDistance) :
newFrequency;
4. 边界更新UI逻辑
// 应用回弹偏移
float totalOffset = baseOffset;
if (overscrollDistance != 0) {
totalOffset += overscrollDistance / frequencyRange * width;
// 绘制回弹指示
if (overscrollDistance < 0) {
// 左边界回弹
canvas.drawRect(0, 0, dpToPx(10), height, overscrollPaint);
} else {
// 右边界回弹
canvas.drawRect(width - dpToPx(10), 0, width, height, overscrollPaint);
}
}
4.整体的架构图
5.总结:
5.1 VelocityTracker:
VelocityTracker 是 Android SDK 中的一个工具类,专门用于跟踪触摸事件的速度(包括水平和垂直方向的速度)。它在实现流畅的交互效果(特别是滚动和惯性滑动)时非常关键。
主要功能和用途:
| 功能 | 说明 |
|---|---|
| 速度检测 | 计算手指在屏幕上的移动速度(像素/秒) |
| 方向判断 | 区分水平(X轴)和垂直(Y轴)方向的速度 |
| 惯性滚动支持 | 为 Fling 手势提供速度数据 |
| 手势识别 | 帮助区分单击、长按和滑动等手势 |
关键方法详解:
-
obtain()
获取 VelocityTracker 实例(优先从对象池复用) -
addMovement(MotionEvent)
添加触摸事件用于速度计算 -
computeCurrentVelocity(int units)
计算当前速度:- 参数
units= 时间单位(毫秒),常用 1000 表示"像素/秒" - 必须在获取速度值前调用
- 参数
-
getXVelocity()/getYVelocity()
获取 X/Y 轴速度(单位由 computeCurrentVelocity 决定) -
recycle()
释放实例回对象池(API 21+ 已废弃,改用clear()+ 复用)
5.2 惯性滑动的优化 Scroller
主要的差异点:flingRunnable,多了个computeScroll()方法,需要是实现 启动惯性滑动或回弹动画
private void handleActionUp() {
// 计算速度并启动fling
if (Math.abs(velocityX) > FLING_THRESHOLD) {
float frequencyRange = MAX_FREQUENCY - MIN_FREQUENCY;
int scrollRange = (int) (frequencyRange * getWidth());
scroller.fling(
currentScroll, 0,
(int) -velocityX, 0,
-scrollRange, scrollRange,
0, 0,
OVER_SCROLL_DISTANCE, 0
);
postInvalidateOnAnimation();
}
}
实现computeScroll方法
@Override
public void computeScroll() {
if (scroller.computeScrollOffset()) {
int scrollX = scroller.getCurrX();
float frequencyRange = MAX_FREQUENCY - MIN_FREQUENCY;
float deltaFrequency = (float) scrollX / getWidth();
float newFrequency = currentFrequency + deltaFrequency;
// 边界检查
if (newFrequency < MIN_FREQUENCY) {
newFrequency = MIN_FREQUENCY;
} else if (newFrequency > MAX_FREQUENCY) {
newFrequency = MAX_FREQUENCY;
}
currentFrequency = newFrequency;
postInvalidateOnAnimation();
}
}
优势与对比
-
更流畅的滚动体验:
- 使用Android内置的Scroller实现物理滚动
- 支持硬件加速的流畅动画
-
更自然的回弹效果:
- OverScroller提供内置的回弹效果
- 支持自定义回弹距离和阻尼
-
简化的代码结构:
- 移除了手动实现的flingRunnable
- 简化了边界处理和回弹逻辑
- 减少了约40行代码
-
更好的性能:
- 利用系统级动画优化
- 减少对象创建和GC压力
-
更准确的物理模拟:
- 精确的速度传递和衰减模型
- 与Android系统滚动行为一致
6.源码
手写的VIew项目地址: github.com/pengcaihua1…