话不多说,先上一个代码完成效果。
动图好像录成横屏的了,也没找到调整反转 GIF 的位置,下面再补一张设计稿静态图吧
最近这几年音视频应用越来越广泛,随之而来的音视频相关的需求也越来越多,音视频的剪辑也是一些音频软件、视频软件必不可少的功能,假定你的公司需要你做一个音视频编辑功能。抛开下层的音视频录制、编解码等工作,上面的音视频编辑器你有思路吗?今天我们带着这个假设命题来一起夯实自定义控件相关的技能。
拆解与分析
根据设计稿我们将音视频编辑器拆解为:
- 左侧可移动拖拽的 Min Bar(左滑块)
- 右侧可移动拖拽的 Max Bar(右滑块)
- Min Bar 和 Max Bar 中间范围内可移动拖拽的游标
- 编辑器左部的播控按钮(支持 pause、resume)
- 背景与主体框
- Min Bar 和 Max Bar 顶部时间展示
- 提供给外部调用和播放器绑定的相关 API 与接口
大概就是拆解成这六个块,接下来我们进行难点分析
难点分析
- Min Bar 和 Max Bar以及中间的游标都是可以拖拽的,所以手势处理是其中的一个点
- Min Bar 和 Max Bar以及中间的游标存在很近的距离,这个时候如何判断 touch 目标的优先级
- 中间的游标单步移动的距离以及和播放器的 pts 如何绑定,游标在 Min Max 左右防过界处理
- 物理像素尺寸距离与总进度时间换算与逆换算
- 与左右滑块整体的可缩放上下黄色边框
手势事件与 touch 处理
物理像素居理与时间或百分比换算
- 从播放器播放时游标移动的单步距离需要把pts换算成物理像素 X 轴的移动距离
- Min Bar 和 Max Bar以及中间游标移动后需要把 物理像素 X 轴的移动距离换成时间
所以我们需要来定义正换算和逆换算的方法如下:
/**
* 进度值,从百分比到绝对值
*
* @return
*/
@SuppressWarnings("unchecked")
private float percentToAbsoluteValue(double normalized) {
return (float) (mAbsoluteMinValue + normalized * (mAbsoluteMaxValue - mAbsoluteMinValue));
}
/**
* 进度值,从绝对值到百分比
*/
private double absoluteValueToPercent(float value) {
if (0 == mAbsoluteMaxValue - mAbsoluteMinValue) {
// prevent division by zero, simply return 0.
return 0d;
}
return (value - mAbsoluteMinValue) / (mAbsoluteMaxValue - mAbsoluteMinValue);
}
/**
* 进度值,从百分比值转换到屏幕中坐标值
*/
private float percentToScreen(double percentValue) {
return (float) (mWidthPadding + percentValue * (getWidth() - 2 * mWidthPadding));
}
/**
* 进度值,转换屏幕像素值到百分比值
*/
private double screenToPercent(float screenCoord) {
int width = getWidth();
if (width <= 2 * mWidthPadding) {
// prevent division by zero, simply return 0.
return 0d;
} else {
double result = (screenCoord - mWidthPadding) / (width - 2 * mWidthPadding);
return Math.min(1d, Math.max(0d, result));
}
}
判断滑块与游标是否在选中范围
这里需要注意的点是,光标是近乎一根线但是滑块的宽度是较宽的,所以左右滑块是否选中我们需要取滑块 x 居中的点。
/**
* 根据touchX, 判断是哪一个thumb(Min or Max)
*
* @param touchX 触摸的x在屏幕中坐标(相对于容器)
*/
private Thumb evalPressedThumb(float touchX) {
Thumb result = null;
boolean minThumbPressed = isInThumbRange(touchX, mPercentSelectedMinValue, false);
boolean maxThumbPressed = isInThumbRange(touchX, mPercentSelectedMaxValue, true);
if (minThumbPressed && maxThumbPressed) {
// if both thumbs are pressed (they lie on top of each other), choose the one with more room to drag. this avoids "stalling" the thumbs in a corner, not being able to drag them apart anymore.
result = (touchX / getWidth() > 0.5f) ? Thumb.MIN : Thumb.MAX;
} else if (minThumbPressed) {
result = Thumb.MIN;
} else if (maxThumbPressed) {
result = Thumb.MAX;
}
return result;
}
/**
* 判断touchX是否在滑块点击范围内
*
* @param touchX 需要被检测的 屏幕中的x坐标(相对于容器)
* @param percentThumbValue 需要检测的滑块x坐标百分比值(滑块x坐标)
*/
private boolean isInThumbRange(float touchX, double percentThumbValue, boolean isMax) {
if (isMax) {
return Math.abs(touchX - mThumbHalfWidth - percentToScreen(percentThumbValue)) <= mThumbHalfWidth;
} else {
return Math.abs(touchX + mThumbHalfWidth - percentToScreen(percentThumbValue)) <= mThumbHalfWidth;
}
// return Math.abs(touchX - percentToScreen(percentThumbValue)) <= mThumbHalfWidth; //居中基准时
}
/**
* 判断用户是否触碰光标
*
* @param touchX 需要被检测的 屏幕中的x坐标(相对于容器)
* @param cursorX 光标x坐标(滑块x坐标)
* @return
*/
private boolean isInCursorRange(float touchX, float cursorX) {
return Math.abs(touchX - cursorX) <= mThumbHalfWidth;
}
onTouchEvent 事件处理
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!mIsEnable)
return true;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (isTouchPlayControl(event.getX())) {
isPlay = !isPlay;
playerControlListener.onPlayerControl(isPlay);
invalidate();
return true;
}
if (mPressedThumb == null && isInCursorRange(event.getX(), cur)) {
// if (mThumbListener != null){
// mThumbListener.onCursor(cur);
// }
} else {
mPressedThumb = evalPressedThumb(event.getX());
if (Thumb.MIN.equals(mPressedThumb)) {
if (mThumbListener != null)
mThumbListener.onClickMinThumb(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
}
if (Thumb.MAX.equals(mPressedThumb)) {
if (mThumbListener != null)
mThumbListener.onClickMaxThumb();
}
}
invalidate();
//Intercept parent TouchEvent
if (getParent() != null) {
getParent().requestDisallowInterceptTouchEvent(true);
}
break;
case MotionEvent.ACTION_MOVE:
if (mPressedThumb == null && isInCursorRange(event.getX(), cur)) {
isMoving = true;
float eventX = event.getX();
if (eventX >= percentToScreen(mPercentSelectedMaxValue)) {
eventX = percentToScreen(mPercentSelectedMaxValue);
} else if (eventX <= percentToScreen(mPercentSelectedMinValue)) {
eventX = percentToScreen(mPercentSelectedMinValue);
}
cur = eventX;
if (mThumbListener != null) {
mThumbListener.onCursorMove(percentToAbsoluteValue(screenToPercent(cur)));
}
invalidate();
} else if (mPressedThumb != null) {
float eventX = event.getX();
float maxValue = percentToAbsoluteValue(mPercentSelectedMaxValue);
float minValue = percentToAbsoluteValue(mPercentSelectedMinValue);
float eventValue = percentToAbsoluteValue(screenToPercent(eventX));
if (Thumb.MIN.equals(mPressedThumb)) {
minValue = eventValue;
if (mBetweenAbsoluteValue > 0 && maxValue - minValue <= mBetweenAbsoluteValue) {
minValue = new Float((maxValue - mBetweenAbsoluteValue));
}
// setPercentSelectedMinValue(screenToPercent(event.getX()));
if (isFixedMode()) {
mPercentSelectedMaxValue = Math.max(0d, Math.min(1d, Math.max(absoluteValueToPercent(eventValue + (maxValue - minValue)), mPercentSelectedMinValue)));
}
if (cur <= percentToScreen(mPercentSelectedMinValue)) {//防止光标静态越界
cur = percentToScreen(mPercentSelectedMinValue);
}
setPercentSelectedMinValue(absoluteValueToPercent(minValue));
if (mThumbListener != null)
mThumbListener.onMinMove(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
} else if (Thumb.MAX.equals(mPressedThumb)) {
maxValue = eventValue;
if (mBetweenAbsoluteValue > 0 && maxValue - minValue <= mBetweenAbsoluteValue) {
maxValue = new Float(minValue + mBetweenAbsoluteValue);
}
// setPercentSelectedMaxValue(screenToPercent(event.getX()));
if (isFixedMode()) {
mPercentSelectedMinValue = Math.max(0d, Math.min(1d, Math.min(absoluteValueToPercent(eventValue - (maxValue - minValue)), mPercentSelectedMaxValue)));
}
if (cur >= percentToScreen(mPercentSelectedMaxValue)) {//防止光标静态越界
cur = percentToScreen(mPercentSelectedMaxValue);
}
setPercentSelectedMaxValue(absoluteValueToPercent(maxValue));
if (mThumbListener != null)
mThumbListener.onMaxMove(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
}
}
//Intercept parent TouchEvent
if (getParent() != null) {
getParent().requestDisallowInterceptTouchEvent(true);
}
break;
case MotionEvent.ACTION_UP:
if (isMoving) {
if (mThumbListener != null) {
mThumbListener.onCursorUp(percentToAbsoluteValue(screenToPercent(cur)));
}
isMoving = false;
}
if (Thumb.MIN.equals(mPressedThumb)) {
if (mThumbListener != null)
mThumbListener.onUpMinThumb(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
}
if (Thumb.MAX.equals(mPressedThumb)) {
if (mThumbListener != null)
mThumbListener.onUpMaxThumb(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
}
//Intercept parent TouchEvent
if (getParent() != null) {
getParent().requestDisallowInterceptTouchEvent(true);
}
break;
case MotionEvent.ACTION_CANCEL:
if (Thumb.MIN.equals(mPressedThumb)) {
if (mThumbListener != null)
mThumbListener.onUpMinThumb(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
}
if (Thumb.MAX.equals(mPressedThumb)) {
if (mThumbListener != null)
mThumbListener.onUpMaxThumb(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
}
mPressedThumb = null;
//Intercept parent TouchEvent
if (getParent() != null) {
getParent().requestDisallowInterceptTouchEvent(true);
}
break;
}
return true;
}
onTouchEvent 的代码量比较大,我们来分别做下解析解读。主要通过 DOWN 事件我们确定了各个可响应事件的优先级,他们优先级如下:
事件响应优先级
播控按钮 > 游标 > 左右滑块
我们分析一下播控按钮(pause 和 resume) 在控件的最左侧,是不会和游标和左右滑块产生 touch 冲突的,所以这个判断顺序放在最前或者最末尾其实是没有影响的、可能产生事件冲突的只有左右滑块和游标,因为游标的起始位置挨着左侧滑块,游标的结束位置挨着右侧滑块。所以 DOWN 事件中我们明确了如果 X 轴方向 touch 把游标优先级给到前置。关于左右滑块的有上文 evalPressedThumb 方法进行判断,实际产品形态的逻辑中,也会限制最短编辑时长来限制左右滑块在物理像素中不会贴合到一起。
Move 分析
在 move 中主要实现的是左右滑块和光标的跟随手势移动,以及外部时间回传。举个栗子,我们 touch 游标移动的时候如果需要实现外部播放器的画面帧和音频和我的拖动位置同步,就需要拖动过程中实时把物理像素值换算成时间(pts)回传给播放器
游标越界处理
1 如果我们手指一直 touch 住游标想把游标带出左右滑块的边界这个如何处理
2 另外一种情况是光标不动,我们去移动左右滑块,如果不处理这种情况光标会停留在原地跑出到 Min 和 Max 之外
想清楚思路处理起来也比较简单,方案是在 Min 和 Max 方向加入如下逻辑
if (cur <= percentToScreen(mPercentSelectedMinValue)) {//防止光标静态越界
cur = percentToScreen(mPercentSelectedMinValue);
}
if (cur >= percentToScreen(mPercentSelectedMaxValue)) {//防止光标静态越界
cur = percentToScreen(mPercentSelectedMaxValue);
}
其他
滑块整体的实现方式
这里有个细节,我们看到的左右滑块和顶部底部是一个完整的框,这个怎么实现比较简单? 全部自己 draw ? 最简单的方式是左右滑块使用设计提供的 icon、上下两个闭合的黄色横梁则自己画。交接部位给个负差就行,这样看上去就是一整个浑然一体了,实现还简单。上下只需要 drawRect 就行
private void drawBorder(Canvas canvas) {
//top
float borderLeft = mProgressBarSelRect.left;
float borderRight = mProgressBarSelRect.right;
canvas.drawRect(borderLeft - 1, mProgressBarRect.top, borderRight + 1, mProgressBarRect.top + 10, borderPaint);
//bottom
canvas.drawRect(borderLeft - 1, mProgressBarRect.bottom, borderRight + 1, mProgressBarRect.bottom - 10, borderPaint);
}
完整源码
好了通过上文,相信没有其他难点能够阻塞到你,这边给出完整的源码,希望对你能够有一点点帮助,也欢迎给实现方式提供更优解和纠错。欢迎在下方进行评论和指点!
/**
* Created by zhouxuming on 2023/3/30
*
* @descr 音视频剪辑器
*/
public class AudioViewEditor extends View {
//进度文本显示格式-数字格式
public static final int HINT_FORMAT_NUMBER = 0;
//进度文本显示格式-时间格式
public static final int HINT_FORMAT_TIME = 1;
private final Paint mPaint = new Paint();
//空间最小宽度
private final int MIN_WIDTH = 200;
private final float playControlLeft = 10; //播控实际左边界
private final float playControlRight = 80; //播控实际右边界
//滑块bitmap
private Bitmap mThumbImage;
//progress bar 选中背景
// private Bitmap mProgressBarSelBg;
private Bitmap mMaxThumbImage;
private Bitmap mMinThumbImage;
//progress bar 背景
private Bitmap mProgressBarBg;
private float mThumbWidth;
private float mThumbHalfWidth; //触摸响应宽度的一半
private float mThumbHalfHeight;
//seekbar 进度条高度
private float mProgressBarHeight;
//宽度左右padding
private float mWidthPadding;
//最小值(绝对)
private float mAbsoluteMinValue;
//最大值(绝对)
private float mAbsoluteMaxValue;
//已选标准(占滑动条百分比)最小值
private double mPercentSelectedMinValue = 0d;
//已选标准(占滑动条百分比)最大值
private double mPercentSelectedMaxValue = 1d;
//当前事件处理的thumb滑块
private Thumb mPressedThumb = null;
//滑块事件
private ThumbListener mThumbListener;
private RectF mProgressBarRect;
private RectF mProgressBarSelRect;
//是否可以滑动
private boolean mIsEnable = true;
//最大值和最小值之间要求的最小范围绝对值
private float mBetweenAbsoluteValue;
//文字格式
private int mProgressTextFormat;
//文本高度
private int mWordHeight;
//文本字体大小
private float mWordSize;
private float mStartMinPercent;
private float mStartMaxPercent;
private boolean fixedMode; //固定模式
private Paint cursorPaint;
private Paint borderPaint;
//播控按钮部分逻辑
private Paint playControlPaint;
private boolean isPlay = true; //播控状态
private Bitmap playResumeBitmap;
private Bitmap playPauseBitmap;
private PlayerControlListener playerControlListener;
private float cur;// 光标坐标
private float pre;// 100 份每一份的偏移量
private float min;//起始坐标
private float max;//最大坐标
private boolean isFirst = true;
public AudioViewEditor(Context context) {
super(context);
}
public AudioViewEditor(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.AudioViewEditor, 0, 0);
mAbsoluteMinValue = a.getFloat(R.styleable.AudioViewEditor_absoluteMin, (float) 0.0);
mAbsoluteMaxValue = a.getFloat(R.styleable.AudioViewEditor_absolutemMax, (float) 100.0);
mStartMinPercent = a.getFloat(R.styleable.AudioViewEditor_startMinPercent, 0);
mStartMaxPercent = a.getFloat(R.styleable.AudioViewEditor_startMaxPercent, 1);
mThumbImage = BitmapFactory.decodeResource(getResources(), a.getResourceId(R.styleable.AudioViewEditor_thumbImage, R.drawable.drag_left_bar));
mMaxThumbImage = BitmapFactory.decodeResource(getResources(), R.drawable.drag_right_bar);
mProgressBarBg = BitmapFactory.decodeResource(getResources(), a.getResourceId(R.styleable.AudioViewEditor_progressBarBg, R.drawable.seekbar_bg));
playPauseBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.play_control_pause);
playResumeBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.play_control_resume);
// mProgressBarSelBg = BitmapFactory.decodeResource(getResources(), a.getResourceId(R.styleable.CustomRangeSeekBar_progressBarSelBg, R.mipmap.seekbar_sel_bg));
mBetweenAbsoluteValue = a.getFloat(R.styleable.AudioViewEditor_betweenAbsoluteValue, 0);
mProgressTextFormat = a.getInt(R.styleable.AudioViewEditor_progressTextFormat, HINT_FORMAT_NUMBER);
mWordSize = a.getDimension(R.styleable.AudioViewEditor_progressTextSize, dp2px(context, 16));
mPaint.setTextSize(mWordSize);
mThumbWidth = mThumbImage.getWidth();
mThumbHalfWidth = 0.5f * mThumbWidth;
mThumbHalfHeight = 0.5f * mThumbImage.getHeight();
// mProgressBarHeight = 0.3f * mThumbHalfHeight;
mProgressBarHeight = mThumbImage.getHeight();
//TOOD 提供定义attr
mWidthPadding = mThumbHalfHeight;
mWidthPadding += playControlRight;//为了加左右侧播控按钮, 特地添加出来的空间
Paint.FontMetrics metrics = mPaint.getFontMetrics();
mWordHeight = (int) (metrics.descent - metrics.ascent);
/*光标*/
cursorPaint = new Paint();
cursorPaint.setAntiAlias(true);
cursorPaint.setColor(Color.WHITE);
borderPaint = new Paint();
borderPaint.setAntiAlias(true);
borderPaint.setColor(Color.parseColor("#DBAE6A"));
playControlPaint = new Paint();
playControlPaint.setAntiAlias(true);
playControlPaint.setColor(Color.parseColor("#1E1F21"));
restorePercentSelectedMinValue();
restorePercentSelectedMaxValue();
a.recycle();
}
/**
* 格式化毫秒->00:00
*/
private static String formatSecondTime(int millisecond) {
if (millisecond == 0) {
return "00:00";
}
int second = millisecond / 1000;
int m = second / 60;
int s = second % 60;
if (m >= 60) {
int hour = m / 60;
int minute = m % 60;
return hour + ":" + (minute > 9 ? minute : "0" + minute) + ":" + (s > 9 ? s : "0" + s);
} else {
return (m > 9 ? m : "0" + m) + ":" + (s > 9 ? s : "0" + s);
}
}
/**
* 将dip或dp值转换为px值,保证尺寸大小不变
*
* @param dipValue (DisplayMetrics类中属性density)
* @return
*/
public static int dp2px(Context context, float dipValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dipValue * scale + 0.5f);
}
/**
* 还原min滑块到初始值
*/
public void restorePercentSelectedMinValue() {
setPercentSelectedMinValue(mStartMinPercent);
}
/**
* 还原max滑块到初始值
*/
public void restorePercentSelectedMaxValue() {
setPercentSelectedMaxValue(mStartMaxPercent);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mProgressBarRect = new RectF(mWidthPadding, mWordHeight + 0.5f * (h - mWordHeight - mProgressBarHeight),
w - mWidthPadding, mWordHeight + 0.5f * (h - mWordHeight + mProgressBarHeight));
mProgressBarSelRect = new RectF(mProgressBarRect);
}
/**
* 设置seekbar 是否接收事件
*
* @param enabled
*/
@Override
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
this.mIsEnable = enabled;
}
/**
* 返回被选择的最小值(绝对值)
*
* @return The currently selected min value.
*/
public float getSelectedAbsoluteMinValue() {
return percentToAbsoluteValue(mPercentSelectedMinValue);
}
/**
* 设置被选择的最小值(绝对值)
*
* @param value 最小值的绝对值
* return 如果最小值与最大值的最小间距达到阈值返回false,正常返回true
*/
public boolean setSelectedAbsoluteMinValue(float value) {
boolean status = true;
if (0 == (mAbsoluteMaxValue - mAbsoluteMinValue)) {
setPercentSelectedMinValue(0d);
} else {
float maxValue = percentToAbsoluteValue(mPercentSelectedMaxValue);
if (mBetweenAbsoluteValue > 0 && maxValue - value <= mBetweenAbsoluteValue) {
value = new Float(maxValue - mBetweenAbsoluteValue);
status = false;
}
if (maxValue - value <= 0) {
status = false;
value = maxValue;
}
setPercentSelectedMinValue(absoluteValueToPercent(value));
}
return status;
}
public float getAbsoluteMaxValue() {
return mAbsoluteMaxValue;
}
public void setAbsoluteMaxValue(double maxvalue) {
this.mAbsoluteMaxValue = new Float(maxvalue);
}
/**
* 返回被选择的最大值(绝对值).
*/
public float getSelectedAbsoluteMaxValue() {
return percentToAbsoluteValue(mPercentSelectedMaxValue);
}
/**
* 设置被选择的最大值(绝对值)
*
* @param value
*/
public boolean setSelectedAbsoluteMaxValue(float value) {
boolean status = true;
if (0 == (mAbsoluteMaxValue - mAbsoluteMinValue)) {
setPercentSelectedMaxValue(1d);
} else {
float minValue = percentToAbsoluteValue(mPercentSelectedMinValue);
if (mBetweenAbsoluteValue > 0 && value - minValue <= mBetweenAbsoluteValue) {
value = new Float(minValue + mBetweenAbsoluteValue);
status = false;
}
if (value - minValue <= 0) {
status = false;
value = minValue;
}
setPercentSelectedMaxValue(absoluteValueToPercent(value));
}
return status;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!mIsEnable)
return true;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (isTouchPlayControl(event.getX())) {
isPlay = !isPlay;
playerControlListener.onPlayerControl(isPlay);
invalidate();
return true;
}
if (mPressedThumb == null && isInCursorRange(event.getX(), cur)) {
// if (mThumbListener != null){
// mThumbListener.onCursor(cur);
// }
} else {
mPressedThumb = evalPressedThumb(event.getX());
if (Thumb.MIN.equals(mPressedThumb)) {
if (mThumbListener != null)
mThumbListener.onClickMinThumb(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
}
if (Thumb.MAX.equals(mPressedThumb)) {
if (mThumbListener != null)
mThumbListener.onClickMaxThumb();
}
}
invalidate();
//Intercept parent TouchEvent
if (getParent() != null) {
getParent().requestDisallowInterceptTouchEvent(true);
}
break;
case MotionEvent.ACTION_MOVE:
if (mPressedThumb == null && isInCursorRange(event.getX(), cur)) {
isMoving = true;
float eventX = event.getX();
if (eventX >= percentToScreen(mPercentSelectedMaxValue)) {
eventX = percentToScreen(mPercentSelectedMaxValue);
} else if (eventX <= percentToScreen(mPercentSelectedMinValue)) {
eventX = percentToScreen(mPercentSelectedMinValue);
}
cur = eventX;
if (mThumbListener != null) {
mThumbListener.onCursorMove(percentToAbsoluteValue(screenToPercent(cur)));
}
invalidate();
} else if (mPressedThumb != null) {
float eventX = event.getX();
float maxValue = percentToAbsoluteValue(mPercentSelectedMaxValue);
float minValue = percentToAbsoluteValue(mPercentSelectedMinValue);
float eventValue = percentToAbsoluteValue(screenToPercent(eventX));
if (Thumb.MIN.equals(mPressedThumb)) {
minValue = eventValue;
if (mBetweenAbsoluteValue > 0 && maxValue - minValue <= mBetweenAbsoluteValue) {
minValue = new Float((maxValue - mBetweenAbsoluteValue));
}
// setPercentSelectedMinValue(screenToPercent(event.getX()));
if (isFixedMode()) {
mPercentSelectedMaxValue = Math.max(0d, Math.min(1d, Math.max(absoluteValueToPercent(eventValue + (maxValue - minValue)), mPercentSelectedMinValue)));
}
if (cur <= percentToScreen(mPercentSelectedMinValue)) {//防止光标静态越界
cur = percentToScreen(mPercentSelectedMinValue);
}
setPercentSelectedMinValue(absoluteValueToPercent(minValue));
if (mThumbListener != null)
mThumbListener.onMinMove(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
} else if (Thumb.MAX.equals(mPressedThumb)) {
maxValue = eventValue;
if (mBetweenAbsoluteValue > 0 && maxValue - minValue <= mBetweenAbsoluteValue) {
maxValue = new Float(minValue + mBetweenAbsoluteValue);
}
// setPercentSelectedMaxValue(screenToPercent(event.getX()));
if (isFixedMode()) {
mPercentSelectedMinValue = Math.max(0d, Math.min(1d, Math.min(absoluteValueToPercent(eventValue - (maxValue - minValue)), mPercentSelectedMaxValue)));
}
if (cur >= percentToScreen(mPercentSelectedMaxValue)) {//防止光标静态越界
cur = percentToScreen(mPercentSelectedMaxValue);
}
setPercentSelectedMaxValue(absoluteValueToPercent(maxValue));
if (mThumbListener != null)
mThumbListener.onMaxMove(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
}
}
//Intercept parent TouchEvent
if (getParent() != null) {
getParent().requestDisallowInterceptTouchEvent(true);
}
break;
case MotionEvent.ACTION_UP:
if (isMoving) {
if (mThumbListener != null) {
mThumbListener.onCursorUp(percentToAbsoluteValue(screenToPercent(cur)));
}
isMoving = false;
}
if (Thumb.MIN.equals(mPressedThumb)) {
if (mThumbListener != null)
mThumbListener.onUpMinThumb(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
}
if (Thumb.MAX.equals(mPressedThumb)) {
if (mThumbListener != null)
mThumbListener.onUpMaxThumb(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
}
//Intercept parent TouchEvent
if (getParent() != null) {
getParent().requestDisallowInterceptTouchEvent(true);
}
break;
case MotionEvent.ACTION_CANCEL:
if (Thumb.MIN.equals(mPressedThumb)) {
if (mThumbListener != null)
mThumbListener.onUpMinThumb(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
}
if (Thumb.MAX.equals(mPressedThumb)) {
if (mThumbListener != null)
mThumbListener.onUpMaxThumb(getSelectedAbsoluteMaxValue(), getSelectedAbsoluteMinValue());
}
mPressedThumb = null;
//Intercept parent TouchEvent
if (getParent() != null) {
getParent().requestDisallowInterceptTouchEvent(true);
}
break;
}
return true;
}
private boolean isTouchPlayControl(float eventX) {
if (eventX > playControlLeft && eventX < playControlRight) {
return true;
}
return false;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int width = MIN_WIDTH;
if (MeasureSpec.UNSPECIFIED != MeasureSpec.getMode(widthMeasureSpec)) {
width = MeasureSpec.getSize(widthMeasureSpec);
}
int height = mThumbImage.getHeight() + mWordHeight * 2;
if (MeasureSpec.UNSPECIFIED != MeasureSpec.getMode(heightMeasureSpec)) {
height = Math.min(height, MeasureSpec.getSize(heightMeasureSpec));
}
setMeasuredDimension(width, height);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// draw seek bar background line
mPaint.setStyle(Paint.Style.FILL);
drawPlayControl(canvas);
canvas.drawBitmap(mProgressBarBg, null, mProgressBarRect, mPaint);
// draw seek bar active range line
mProgressBarSelRect.left = percentToScreen(mPercentSelectedMinValue);
mProgressBarSelRect.right = percentToScreen(mPercentSelectedMaxValue);
//canvas.drawBitmap(mProgressBarSelBg, mWidthPadding, 0.5f * (getHeight() - mProgressBarHeight), mPaint);
// canvas.drawBitmap(mProgressBarSelBg, null, mProgressBarSelRect, mPaint); //原中部选中进度
// draw minimum thumb
drawThumb(percentToScreen(mPercentSelectedMinValue), Thumb.MIN.equals(mPressedThumb), canvas, false);
// draw maximum thumb
drawThumb(percentToScreen(mPercentSelectedMaxValue), Thumb.MAX.equals(mPressedThumb), canvas, true);
mPaint.setColor(Color.rgb(255, 165, 0));
mPaint.setAntiAlias(true);
// mPaint.setTextSize(DensityUtils.dp2px(getContext(), 16));
drawThumbMinText(percentToScreen(mPercentSelectedMinValue), getSelectedAbsoluteMinValue(), canvas);
drawThumbMaxText(percentToScreen(mPercentSelectedMaxValue), getSelectedAbsoluteMaxValue(), canvas);
drawBorder(canvas);
drawCursor(canvas);
}
private void drawPlayControl(Canvas canvas) {
canvas.drawRoundRect(playControlLeft, mProgressBarRect.top, playControlRight + mThumbWidth + mThumbHalfWidth, mProgressBarRect.bottom, 5, 5, playControlPaint);
Bitmap targetBitmap = isPlay ? playPauseBitmap : playResumeBitmap;
//x轴距离未计算准确 y轴正确
canvas.drawBitmap(targetBitmap, (playControlLeft + (playControlRight - playControlLeft) / 2) - mThumbHalfWidth + (targetBitmap.getWidth() >> 1), mProgressBarRect.top + (mProgressBarRect.bottom - mProgressBarRect.top) / 2 - (targetBitmap.getHeight() >> 1), playControlPaint);
}
private void drawBorder(Canvas canvas) {
//top
float borderLeft = mProgressBarSelRect.left;
float borderRight = mProgressBarSelRect.right;
canvas.drawRect(borderLeft - 1, mProgressBarRect.top, borderRight + 1, mProgressBarRect.top + 10, borderPaint);
//bottom
canvas.drawRect(borderLeft - 1, mProgressBarRect.bottom, borderRight + 1, mProgressBarRect.bottom - 10, borderPaint);
}
private void drawCursor(Canvas canvas) {
min = percentToScreen(mPercentSelectedMinValue);//开始坐标
max = percentToScreen(mPercentSelectedMaxValue);//终点坐标
pre = (getWidth() - 2 * mWidthPadding) / 1000; //每一份的坐标
if (isFirst) {
cur = min;
isFirst = false;
}
canvas.drawRect(cur - 2, mProgressBarRect.top + 5, cur + 2, mProgressBarRect.bottom - 5, cursorPaint);
}
//启动播放线程检查 pts
public void startMove() {
new Thread(new Runnable() {
@Override
public void run() {
while (true) {
if (isPlay) {
long pts = playerCallback != null ? playerCallback.getCurrentPosition() : 0;
updatePTS(pts);
}
try {
Thread.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
}
/**
* 根据播放器 pts 控制游标进度
*
* @param pts
*/
public void updatePTS(float pts) {
if (isMoving) {
return;
}
if (pts > 0) {
double v = absoluteValueToPercent(pts);
cur = percentToScreen(v);
if (cur >= max || cur < min) {
cur = min;
}
invalidate();
}
}
public boolean isPlay() {
return isPlay;
}
public void setPlay(boolean play) {
isPlay = play;
}
private boolean isMoving = false;
@Override
protected Parcelable onSaveInstanceState() {
Bundle bundle = new Bundle();
bundle.putParcelable("SUPER", super.onSaveInstanceState());
bundle.putDouble("MIN", mPercentSelectedMinValue);
bundle.putDouble("MAX", mPercentSelectedMaxValue);
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable parcel) {
Bundle bundle = (Bundle) parcel;
super.onRestoreInstanceState(bundle.getParcelable("SUPER"));
mPercentSelectedMinValue = bundle.getDouble("MIN");
mPercentSelectedMaxValue = bundle.getDouble("MAX");
}
/**
* Draws the "normal" resp. "pressed" thumb image on specified x-coordinate.
*
* @param screenCoord The x-coordinate in screen space where to draw the image.
* @param pressed Is the thumb currently in "pressed" state?
* @param canvas The canvas to draw upon.
*/
private void drawThumb(float screenCoord, boolean pressed, Canvas canvas, boolean isMax) {
//基准点 bar 居中位置
// canvas.drawBitmap(isMax ? mMaxThumbImage : mThumbImage, screenCoord - mThumbHalfWidth, (mWordHeight + 0.5f * (getHeight() - mWordHeight) - mThumbHalfHeight), mPaint);
//基准点顶两边位置
canvas.drawBitmap(isMax ? mMaxThumbImage : mThumbImage, isMax ? screenCoord : screenCoord - mThumbHalfWidth * 2, (mWordHeight + 0.5f * (getHeight() - mWordHeight) - mThumbHalfHeight), mPaint);
}
/**
* 画min滑块值text
*
* @param screenCoord
* @param value
* @param canvas
*/
private void drawThumbMinText(float screenCoord, Number value, Canvas canvas) {
String progress = getProgressStr(value.intValue());
float progressWidth = mPaint.measureText(progress);
canvas.drawText(progress, (screenCoord - progressWidth / 2) - mThumbHalfWidth, mWordSize, mPaint);
}
/**
* 画max滑块值text
*
* @param screenCoord
* @param value
* @param canvas
*/
private void drawThumbMaxText(float screenCoord, Number value, Canvas canvas) {
String progress = getProgressStr(value.intValue());
float progressWidth = mPaint.measureText(progress);
canvas.drawText(progress, (screenCoord - progressWidth / 2) + mThumbHalfWidth, mWordSize
, mPaint);
}
/**
* 根据touchX, 判断是哪一个thumb(Min or Max)
*
* @param touchX 触摸的x在屏幕中坐标(相对于容器)
*/
private Thumb evalPressedThumb(float touchX) {
Thumb result = null;
boolean minThumbPressed = isInThumbRange(touchX, mPercentSelectedMinValue, false);
boolean maxThumbPressed = isInThumbRange(touchX, mPercentSelectedMaxValue, true);
if (minThumbPressed && maxThumbPressed) {
// if both thumbs are pressed (they lie on top of each other), choose the one with more room to drag. this avoids "stalling" the thumbs in a corner, not being able to drag them apart anymore.
result = (touchX / getWidth() > 0.5f) ? Thumb.MIN : Thumb.MAX;
} else if (minThumbPressed) {
result = Thumb.MIN;
} else if (maxThumbPressed) {
result = Thumb.MAX;
}
return result;
}
/**
* 判断touchX是否在滑块点击范围内
*
* @param touchX 需要被检测的 屏幕中的x坐标(相对于容器)
* @param percentThumbValue 需要检测的滑块x坐标百分比值(滑块x坐标)
*/
private boolean isInThumbRange(float touchX, double percentThumbValue, boolean isMax) {
if (isMax) {
return Math.abs(touchX - mThumbHalfWidth - percentToScreen(percentThumbValue)) <= mThumbHalfWidth;
} else {
return Math.abs(touchX + mThumbHalfWidth - percentToScreen(percentThumbValue)) <= mThumbHalfWidth;
}
// return Math.abs(touchX - percentToScreen(percentThumbValue)) <= mThumbHalfWidth; //居中基准时
}
/**
* 判断用户是否触碰光标
*
* @param touchX 需要被检测的 屏幕中的x坐标(相对于容器)
* @param cursorX 光标x坐标(滑块x坐标)
* @return
*/
private boolean isInCursorRange(float touchX, float cursorX) {
return Math.abs(touchX - cursorX) <= mThumbHalfWidth;
}
/**
* 设置已选择最小值的百分比值
*/
public void setPercentSelectedMinValue(double value) {
mPercentSelectedMinValue = Math.max(0d, Math.min(1d, Math.min(value, mPercentSelectedMaxValue)));
invalidate();
}
/**
* 设置已选择最大值的百分比值
*/
public void setPercentSelectedMaxValue(double value) {
mPercentSelectedMaxValue = Math.max(0d, Math.min(1d, Math.max(value, mPercentSelectedMinValue)));
invalidate();
}
/**
* 进度值,从百分比到绝对值
*
* @return
*/
@SuppressWarnings("unchecked")
private float percentToAbsoluteValue(double normalized) {
return (float) (mAbsoluteMinValue + normalized * (mAbsoluteMaxValue - mAbsoluteMinValue));
}
/**
* 进度值,从绝对值到百分比
*/
private double absoluteValueToPercent(float value) {
if (0 == mAbsoluteMaxValue - mAbsoluteMinValue) {
// prevent division by zero, simply return 0.
return 0d;
}
return (value - mAbsoluteMinValue) / (mAbsoluteMaxValue - mAbsoluteMinValue);
}
/**
* 进度值,从百分比值转换到屏幕中坐标值
*/
private float percentToScreen(double percentValue) {
return (float) (mWidthPadding + percentValue * (getWidth() - 2 * mWidthPadding));
}
/**
* 进度值,转换屏幕像素值到百分比值
*/
private double screenToPercent(float screenCoord) {
int width = getWidth();
if (width <= 2 * mWidthPadding) {
// prevent division by zero, simply return 0.
return 0d;
} else {
double result = (screenCoord - mWidthPadding) / (width - 2 * mWidthPadding);
return Math.min(1d, Math.max(0d, result));
}
}
public void setThumbListener(ThumbListener mThumbListener) {
this.mThumbListener = mThumbListener;
}
private String getProgressStr(int progress) {
String progressStr;
if (mProgressTextFormat == HINT_FORMAT_TIME) {
progressStr = formatSecondTime(progress);
} else {
progressStr = String.valueOf(progress);
}
return progressStr;
}
public boolean isFixedMode() {
return fixedMode;
}
public void setFixedMode(boolean fixedMode) {
this.fixedMode = fixedMode;
}
/**
* 重置总时长-单位秒
*
* @param totalSecond
*/
public void resetTotalSecond(int totalSecond) {
if (totalSecond > 0 && totalSecond < 12000) {
mAbsoluteMaxValue = totalSecond * 1000;
mAbsoluteMinValue = 0.0f;
mProgressTextFormat = HINT_FORMAT_TIME;
invalidate();
}
}
/**
* 重置总时长-单位毫秒
*
* @param totalMillisecond
*/
public void resetTotalMillisecond(float totalMillisecond) {
if (totalMillisecond > 0 && totalMillisecond < 1200000) {
mAbsoluteMaxValue = totalMillisecond;
mAbsoluteMinValue = 0.0f;
mProgressTextFormat = HINT_FORMAT_TIME;
invalidate();
}
}
public void setPlayerControlListener(PlayerControlListener playerControlListener) {
this.playerControlListener = playerControlListener;
}
/**
* Thumb枚举, 最大或最小
*/
private enum Thumb {
MIN, MAX
}
public interface PlayerControlListener {
void onPlayerControl(boolean isPlay);
}
/**
* 游标以及滑块事件
*/
public interface ThumbListener {
void onClickMinThumb(Number max, Number min);
void onClickMaxThumb();
void onUpMinThumb(Number max, Number min);
void onUpMaxThumb(Number max, Number min);
void onMinMove(Number max, Number min);
void onMaxMove(Number max, Number min);
void onCursorMove(Number cur);
void onCursorUp(Number cur);
}
public interface IPlayerCallback {
long getCurrentPosition();
}
private IPlayerCallback playerCallback = null;
public void setPlayerCallback(IPlayerCallback playerCallback) {
this.playerCallback = playerCallback;
}
public void release() {
isPlay = false;
}
}