仿知乎可拖动悬停按钮
效果如下:
实现的主要功能有:
1. 随手拖动
2. 响应点击事件
3. 全屏拖动,也可以限定位置
4. 可通过xml配置颜色和内部样式
快速使用
1.在工程根目录的build.gradle中添加依赖
并在工程目录的build.gradle中添加依赖(最新版本可查看TrackView)
implementation 'com.github.XiaogegeChen:TrackView:1.0'
2.在xml中配置属性
<com.xiaoegeg.arttest.TrackView
android:id="@+id/track_view"
android:layout_marginTop="50dp"
android:layout_width="50dp"
android:layout_height="100dp" />
3.通过findViewById()方法找到控件,如果需要响应点击事件,使用setOnClickListener()方法添加监听器即可。如:
final TrackView trackView = findViewById (R.id.track_view);
trackView.setOnClickListener (new View.OnClickListener () {
@Override
public void onClick(View v) {
Toast.makeText (MainActivity.this, "点击了拖动按钮", Toast.LENGTH_SHORT).show ();
}
});
可选属性
可以根据需要配置相应的属性
app:inner_distance
是两个箭头之间的间距
app:inner_length
是每个箭头的边长
app:inner_stroke_width
是两个箭头的线条宽
app:blank_bottom
是底部留白的高度
app:blank_left
是左侧留白的高度
app:blank_right
是右侧留白的高度
app:blank_top
是顶部留白的高度
app:inner_content_color
是圆形内部的填充色
app:inner_stroke_color
是两个箭头的线条颜色
app:out_stroke_color
是外圆线条的颜色
app:out_stroke_width
是外圆线条的线宽
至此, 就可以实现演示的功能了。有兴趣可以接着分析一下实现方法。
实现方法
主要是通过重写onTouchEvent()
方法,下面按照功能分步完成onTouchEvent()
方法。
1.随手拖动
在自定义view中,如果需要实现随手拖动功能,可以从Android的事件分发机制入手。 手指从接触屏幕到离开屏幕是一个事件序列,这个序列一定是从MotionEvent.ACTION_DOWN
开始,到MotionEvent.ACTION_UP
结束,如果是滑动,中间会有一系列的MotionEvent.ACTION_MOVE
事件,如果是点击或者长按事件,则不会有MotionEvent.ACTION_MOVE
事件。因此可以在MotionEvent.ACTION_MOVE
事件发生时候拿到手指点击位置的坐标,并将view移动到这个位置,即可实现随手拖动。
所以,通过重写onTouchEvent()
方法,在event为MotionEvent.ACTION_MOVE
时候移动view,就可以实现随手拖动,并返回true来截获并消费触摸事件序列,不再继续传递。伪代码就可以这样写:
@Override
public boolean onTouchEvent(MotionEvent event) {
// 获得触摸点的绝对坐标
int x = (int) event.getRawX ();
int y = (int) event.getRawY ();
switch(event.getAction ()){
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_UP:
break;
case MotionEvent.ACTION_MOVE:
int dx;
int dy;
// 拿到位置差
dx = x - mLastX;
dy = y - mLastY;
// 移动view
setTranslationX (getTranslationX () + dx);
setTranslationY (getTranslationY () + dy);
break;
}
// 更新位置
mLastX = x;
mLastY = y;
return true;
}
2.响应点击
如果按照上面的代码,可以实现随手拖动,但是不能响应点击事件。这时注意这个警告:
[图片上传失败...(image-762412-1557739293460)]
意思是说我们在调用onTouchEvent()
时要考虑在它的内部调用performClick()
方法,因为view的点击事件其实是在onTouchEvent()
调用的,如果我们在重写onTouchEvent()
时没有调用performClick()
,就会导致点击事件无法响应,从源码中也能印证这一点
这个方法在MotionEvent.ACTION_UP
,就是一个事件序列结束时候调用。因此要响应点击事件,需要在onTouchEvent()
中调用performClick()
方法。MotionEvent.ACTION_UP
代表一个事件序列的结束,因此需要在这时调用performClick()
方法如下:
@Override
public boolean onTouchEvent(MotionEvent event) {
// 获得触摸点的绝对坐标
int x = (int) event.getRawX ();
int y = (int) event.getRawY ();
switch(event.getAction ()){
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_UP:
performClick ();
break;
case MotionEvent.ACTION_MOVE:
int dx;
int dy;
// 拿到位置差
dx = x - mLastX;
dy = y - mLastY;
// 移动view
setTranslationX (getTranslationX () + dx);
setTranslationY (getTranslationY () + dy);
break;
}
// 更新位置
mLastX = x;
mLastY = y;
return true;
}
但是这样会出现滑动与点击冲突,滑动结束的MotionEvent.ACTION_UP
同样会触发点击事件。
点击时间与滑动事件的区别在于,点击事件的事件序列中无MotionEvent.ACTION_MOVE
事件,因此可以通过这个差异来进行区分(这里增加了一种情况,如果触摸着这个view超过500ms没有离开,将其判定为用户在犹豫,因此判定为取消,不判定为点击)。如下:
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getRawX ();
int y = (int) event.getRawY ();
switch(event.getAction ()){
case MotionEvent.ACTION_DOWN:
// 重置这个开始的时间
mDownTime = System.currentTimeMillis ();
break;
case MotionEvent.ACTION_UP:
// 重置这个结束的时间
mUpTime = System.currentTimeMillis ();
// 设置当前的模式
if(mMode != Mode.MOVE){
if(mUpTime - mDownTime >= CANCEL_INTERVAL_DEFAULT){
mMode = Mode.CANCEL;
}else{
mMode = Mode.CLICK;
}
}
// 根据当前的模式设置是否调用点击事件
if(mMode == Mode.CLICK){
performClick ();
}
// 这个事件序列结束,重置当前的模式
mMode = Mode.NONE;
break;
case MotionEvent.ACTION_MOVE:
// 只要触发了ACTION_MOVE就设置为move模式
mMode = Mode.MOVE;
int dx;
int dy;
dx = x - mLastX;
dy = y - mLastY;
setTranslationX (getTranslationX () + dx);
setTranslationY (getTranslationY () + dy);
break;
}
// 更新位置
mLastX = x;
mLastY = y;
return true;
}
/**
* 该控件的三种模式,只要触发了ACTION_MOVE就是MOVE模式
* 没有触发ACTION_MOVE但是从ACTION_DOWN开始超过了500ms就是CANCEL模式
* 未超过就是CLICK模式
*/
private enum Mode{
// 取消,不执行任何逻辑
CANCEL,
// 点击,执行点击事件
CLICK,
// 移动模式,随手移动
MOVE,
// 无模式,就是复位后的状态
NONE
}
记录事件序列开始,就是MotionEvent.ACTION_DOWN
的时间和事件序列结束,就是MotionEvent.ACTION_UP
的时间。如果有出现MotionEvent.ACTION_MOVE
,直接判定MOVE
模式,如果时间差超过500ms并且没MotionEvent.ACTION_MOVE
,判定为CANCEL
模式,剩下的就是CLICK
模式了。判定完模式之后,就可以根据模式来决定是否调用performClick()
以响应点击事件了。
3.限定位置
由于是全屏滑动,如果不设置限定,会出现view飞出视野的情况。
因此,在执行view的移动前预先判断一下不加限制将会到达的位置,如果位置在限定范围之外,就调整移动的距离即可,如下:
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getRawX ();
int y = (int) event.getRawY ();
switch(event.getAction ()){
case MotionEvent.ACTION_DOWN:
// 重置这个开始的时间
mDownTime = System.currentTimeMillis ();
// 拿到拖动点相对view的位置
mDisX = (int) event.getX ();
mDisY = (int) event.getY ();
break;
case MotionEvent.ACTION_UP:
// 重置这个结束的时间
mUpTime = System.currentTimeMillis ();
// 设置当前的模式
if(mMode != Mode.MOVE){
if(mUpTime - mDownTime >= CANCEL_INTERVAL_DEFAULT){
mMode = Mode.CANCEL;
}else{
mMode = Mode.CLICK;
}
}
// 根据当前的模式设置是否调用点击事件
if(mMode == Mode.CLICK){
performClick ();
}
// 这个事件序列结束,重置当前的模式
mMode = Mode.NONE;
break;
case MotionEvent.ACTION_MOVE:
// 只要触发了ACTION_MOVE就设置为move模式
mMode = Mode.MOVE;
int dx;
int dy;
// 预测量的边距
int preXLeft = x - mDisX;
int preXRight = mScreenWidthInPixel - (x + Math.min (mOutWidth, mOutHeight) - mDisX);
int preYUp = y - mDisY;
int preYDown = mScreenHeightInPixel - (y + Math.min (mOutWidth, mOutHeight) - mDisY);
// 处理X坐标
if(preXLeft <= mBlankLeft){
// 超出左边界
dx = x - mLastX + mBlankLeft - preXLeft;
x = x + mBlankLeft - preXLeft;
}else if(preXRight <= mBlankRight){
// 超出右边界
dx = x - mLastX - (mBlankRight - preXRight);
x = x - (mBlankRight - preXRight);
}else{
// 正常
dx = x - mLastX;
}
// 处理Y坐标
if (preYUp <= mBlankTop) {
// 超出上边界
dy = y - mLastY + mBlankTop - preYUp;
y = y + mBlankTop - preYUp;
}else if(preYDown <= mBlankBottom){
// 超出下边界
dy = y - mLastY - (mBlankBottom - preYDown);
y = y - (mBlankBottom - preYDown);
}else {
// 正常
dy = y - mLastY;
}
setTranslationX (getTranslationX () + dx);
setTranslationY (getTranslationY () + dy);
break;
}
// 更新位置
mLastX = x;
mLastY = y;
return true;
}
画个图来辅助理解
拿左边界来说。存在一个临界点,上一次未到达边界,下一次将会到达边界,因此预先计算一下,如果是这种情况,将多出的长度减掉即可。
4.自动吸附
知乎的悬浮按钮可以自动靠边,不然影响阅读。
在事件序列结束时,即MotionEvent.ACTION_UP
中判断当前view处于屏幕的左半部还是右半部,然后直接移动到边上即可。
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getRawX ();
int y = (int) event.getRawY ();
switch(event.getAction ()){
case MotionEvent.ACTION_DOWN:
// 重置这个开始的时间
mDownTime = System.currentTimeMillis ();
mDisX = (int) event.getX ();
mDisY = (int) event.getY ();
break;
case MotionEvent.ACTION_UP:
// 重置这个结束的时间
mUpTime = System.currentTimeMillis ();
// 设置当前的模式
if(mMode != Mode.MOVE){
if(mUpTime - mDownTime >= CANCEL_INTERVAL_DEFAULT){
mMode = Mode.CANCEL;
}else{
mMode = Mode.CLICK;
}
}
// 根据当前的模式设置是否调用点击事件
if(mMode == Mode.CLICK){
performClick ();
}
// 回到侧面
if(event.getRawX () < mScreenWidthInPixel / 2){
// 回到最左侧
setTranslationX (getTranslationX () + (-1 * (x - mDisX - mBlankLeft)));
x = x - (x - mDisX - mBlankLeft);
}else{
// 回到最右侧
setTranslationX (getTranslationX () + ((mScreenWidthInPixel - mBlankRight) - (Math.min (mOutWidth, mOutHeight) - mDisX + x)));
x = x + (mScreenWidthInPixel - mBlankRight) - (Math.min (mOutWidth, mOutHeight) - mDisX + x);
}
// 这个事件序列结束,重置当前的模式
mMode = Mode.NONE;
break;
case MotionEvent.ACTION_MOVE:
// 只要触发了ACTION_MOVE就设置为move模式
mMode = Mode.MOVE;
int dx;
int dy;
// 预测量的边距
int preXLeft = x - mDisX;
int preXRight = mScreenWidthInPixel - (x + Math.min (mOutWidth, mOutHeight) - mDisX);
int preYUp = y - mDisY;
int preYDown = mScreenHeightInPixel - (y + Math.min (mOutWidth, mOutHeight) - mDisY);
// 处理X坐标
if(preXLeft <= mBlankLeft){
// 超出左边界
dx = x - mLastX + mBlankLeft - preXLeft;
x = x + mBlankLeft - preXLeft;
}else if(preXRight <= mBlankRight){
// 超出右边界
dx = x - mLastX - (mBlankRight - preXRight);
x = x - (mBlankRight - preXRight);
}else{
// 正常
dx = x - mLastX;
}
// 处理Y坐标
if (preYUp <= mBlankTop) {
// 超出上边界
dy = y - mLastY + mBlankTop - preYUp;
y = y + mBlankTop - preYUp;
}else if(preYDown <= mBlankBottom){
// 超出下边界
dy = y - mLastY - (mBlankBottom - preYDown);
y = y - (mBlankBottom - preYDown);
}else {
// 正常
dy = y - mLastY;
}
setTranslationX (getTranslationX () + dx);
setTranslationY (getTranslationY () + dy);
break;
}
// 更新位置
mLastX = x;
mLastY = y;
return true;
}
至此,完整的onTouchEvent()
方法就完成了,其它的就是一些自定义view常用的方法了,完整代码可以参考TrackView.java。
项目托管在GitHub上,欢迎提出issue,共同探讨,共同进步。