【Android】手把手教你上滑解锁的效果

前情提要

最近,公司开发的APP中要实现类似上滑解锁效果的推荐页,捣腾了两天,基本实现了效果,附效果图如上。接下来和大家聊聊如何实现这样的效果。

实现思路

这个效果的实现思路主要围绕手指触屏事件展开,注意点如下:

  • ACTION_DOWNACTION_UP的Y轴距离差与自定义的滑动阈值作比较来判断是否上滑
  • 借助Scroller类,触发LinearLayout流畅滑动的效果
  • 使用GestureListener实现阻尼滑动效果
  • 未解锁状态禁止向下滑动

详细设计

基于上述几个注意点,考虑细节分别如下:

  • 有效上滑
    有效上滑
    如上如,锁屏状态下,定义有效滑动阈值standardH,若上滑高度差超过standardH,则判断为有效滑动,布局滑动至屏幕顶部(不可见);否则如向下滑动、向上滑动距离不够等,都作为无效滑动,此时布局恢复至原来位置。

  • 流畅滚动
    LinearLayout本身是没有smoothScrollTo方法的,仅有的滚动方法只有scrollTo和scrollBy,但是这种滚动方法是突变的,不是线性的,想要实现smoothScrollTo方法,需要借助Scroller类来实现。Scroller类中有computeScroll方法,它能实现流畅滚动的原因是,它将初始位置和目标滑动位置之间的距离分成N份依次调用scrollTo方法,通过postInvalidate在每次调用scrollTo方法后刷新视图,以此来达到流畅滑动的效果,其实ViewPager、ScrollView等控件都是通过Scroller来实现流畅滑动的。
    Scroller的简单实用参考这里

  • 阻尼滑动
    什么是阻尼滑动?我们先来看看这张图:
    阻尼滑动效果
    从图中可以看到鼠标原来的位置在“更多精彩”图标的顶部,随着向上拖动,鼠标开始偏离图标顶部,就好像一根橡皮筋,拉得越开,需要用更大的力,阻尼滑动就给我们这样的感觉。想实现这样的效果,需要借助GestureDetector.OnGestureListener接口的onScroll API方法的第四个参数distanceY,通过简单算法的计算让其实际滑动位置随distanceY变大,不容易滑动(也就是改变的越小)。

  • 锁屏状态禁止向下滑动
    通过重写onTouchListener方法,记录ACTION_DOWN的位置,然后记录ACTION_MOVE的位置,如果判断它有向下滑动的倾向,则在ACTION_MOVE里,将其复位,从而达到禁止下滑的效果。

(伪)代码实现

首先按自定义控件的套路来,new一个类,继承LinearLayout,填充写好的布局,重写onTouch方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class PagerLayout extends LinearLayout {
public PagerLayout(Context context) {
this(context, null);
}
public PagerLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public PagerLayout(final Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 填充视图
mContainer = LayoutInflater.from(context).inflate(R.layout.default_view, this, false);
// 添加视图
this.addView(mContainer);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_UP:
break;
case MotionEvent.ACTION_MOVE:
break;
}
return super.onTouchEvent(event);
}

禁止下拉并判断是否为有效上滑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 获取收按下时的y轴坐标
mDownY = event.getY();
break;
case MotionEvent.ACTION_UP:
// 获取视图容器滚动的y轴距离
int scrollY = this.getScrollY();
// 未超过制定距离,则返回原来位置
if (scrollY < 300) {
// 准备滚动到原来位置
} else { // 超过指定距离,则上滑隐藏
// 准备滚动到屏幕上方
}
break;
case MotionEvent.ACTION_MOVE:
// 获取当前滑动的y轴坐标
float curY = event.getY();
// 获取移动的y轴距离
float deltaY = curY - mDownY;
// 阻止视图在原来位置时向下滚动
if (deltaY < 0 || getScrollY() > 0) {
// 滚动至原始位置
} else {
return true;
}
}

流畅滑动实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private Scroller mScroller = new Scroller(context);
// 重写computeScroll
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
//必须执行postInvalidate()从而调用computeScroll()
//其实,在此调用invalidate();亦可
postInvalidate();
}
super.computeScroll();
}
//滚动到目标位置
private void prepareScroll(int fx, int fy) {
int dx = fx - mScroller.getFinalX();
int dy = fy - mScroller.getFinalY();
beginScroll(dx, dy);
}
//设置滚动的相对偏移
private void beginScroll(int dx, int dy) {
//第一,二个参数起始位置;第三,四个滚动的偏移量
mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), dx, dy);
//必须执行invalidate()从而调用computeScroll()
invalidate();
}

阻尼滑动实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private GestureDetector mGestureDetector = new GestureDetector(context, new GestureListenerImpl());
class GestureListenerImpl implements GestureDetector.OnGestureListener {
@Override
public boolean onDown(MotionEvent e) {
return true;
}
@Override
public void onShowPress(MotionEvent e) {
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
return false;
}
//控制拉动幅度:
//int disY=(int)((distanceY - 0.5)/2);
//亦可直接调用:
//smoothScrollBy(0, (int)distanceY);
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2,float distanceX, float distanceY) {
int disY = (int) ((distanceY - 0.5) / 2);
beginScroll(0, disY);
return false;
}
public void onLongPress(MotionEvent e) {
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,float velocityY) {
return false;
}
}

其他封装:
前面我们说到自定义控件的时候,填充布局,这里我们考虑到布局需要填充数据的情况,封装了常用的方法,大家可以根据自己的业务逻辑进行相应封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// 视图容器
private View mContainer;
/**
* 填充视图
* @param context
* @param layoutId
*/
public void setLayout(Context context, int layoutId) {
// 移除所有视图
this.removeAllViews();
// 填充视图
mContainer = LayoutInflater.from(context).inflate(layoutId, this, false);
// 添加视图
this.addView(mContainer);
// 初始化Scroller
if (mScroller == null) {
mScroller = new Scroller(context);
}
// 初始化手势检测器
if (mGestureDetector == null) {
mGestureDetector = new GestureDetector(context, new GestureListenerImpl());
}
invalidate();
}
/**
* 设置文本
* @param viewId
* @param charSequence
*/
public void setText(int viewId, CharSequence charSequence) {
TextView textView = (TextView) getView(viewId);
textView.setText(charSequence);
}
/**
* 设置文本颜色
* @param viewId
* @param color
*/
public void setTextColor(int viewId, int color) {
TextView textView = (TextView) getView(viewId);
textView.setTextColor(color);
}
/**
* 设置文本字体大小
* @param viewId
* @param textSize
*/
public void setTextSize(int viewId, int textSize) {
TextView textView = (TextView) getView(viewId);
textView.setTextSize(textSize);
}
/**
* 设置按钮点击事件
* @param viewId
* @param listener
*/
public void setButtonClickListener(int viewId, OnClickListener listener) {
Button button = (Button) getView(viewId);
button.setOnClickListener(listener);
}
/**
* 设置图片资源
* @param viewId
* @param resId
*/
public void setImageResource(int viewId, int resId) {
if (mContainer != null) {
ImageView imageView = (ImageView) getView(viewId);
imageView.setImageResource(resId);
}
}
/**
* 设置图片bitmap
* @param viewId
* @param bitmap
*/
public void setImageBitmap(int viewId, Bitmap bitmap) {
if (mContainer != null) {
ImageView imageView = (ImageView) getView(viewId);
imageView.setImageBitmap(bitmap);
}
}
/**
* 设置图片drawable
* @param viewId
* @param drawable
*/
public void setImageDrawable(int viewId, Drawable drawable) {
if (mContainer != null) {
ImageView imageView = (ImageView) getView(viewId);
imageView.setImageDrawable(drawable);
}
}
/**
* 设置图片缩放类型
* @param viewId
* @param type
*/
public void setImageScaleType(int viewId, ImageView.ScaleType type) {
if (mContainer != null) {
ImageView imageView = (ImageView) getView(viewId);
imageView.setScaleType(type);
}
}
/**
* 设置背景颜色
* @param color
*/
public void setBackgroundColor(int color) {
mContainer.setBackgroundColor(color);
}
/**
* 设置背景图片
* @param background
*/
public void setBackground(Drawable background) {
mContainer.setBackground(background);
}
/**
* 设置背景图片资源id
* @param resId
*/
public void setBackgroundResource(int resId) {
mContainer.setBackgroundResource(resId);
}
/**
* 获取视图控件
* @param viewId
* @return
*/
public View getView(int viewId) {
return mContainer.findViewById(viewId);
}

扩展

效果图

基于公司的需求,需要实现上图的效果,除了上滑隐藏推荐页外,列表用力下拉需要实现让推荐页重新出现。这里有一个难点就是刷新与推荐页显示的区分,我想到的是重写列表控件的onTouchEvent方法,通过判断其下拉的距离来区分。

使用到的控件有:

  • XRecyclerView
  • 自定义控件引导页控件PagerLayout(上述实现的控件)

封装PagerLayout的show和hide方法:

1
2
3
4
5
6
7
8
9
10
11
// 显示视图
public void show() {
isHidden = false;
prepareScroll(0, 0);
}
// 隐藏视图
public void hide() {
isHidden = true;
prepareScroll(0, mViewHeight);
}

重写XRecyclerView的onTouchEvent事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
mRecyclerView.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
final float[] downY = {0};
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
downY[0] = event.getY();
break;
case MotionEvent.ACTION_UP:
float curY = event.getY();
float delta = curY - downY[0];
int screen = DensityUtil.getWindowHeight(MainActivity.this);
if (delta > screen - DensityUtil.dip2px(MainActivity.this, 240)) {
myLinearLayout.show();
}
break;
}
return false;
}
});

如此,效果基本实现。PS:这里说的刷新与显示推荐页的区分实则是对是否显示推荐页的区分,因能力有限,没有对XrecyclerView源码就是否刷新进行修改。

问题与改进

  • 问题出现
    基于上述的扩展,在RecyclerView的item里的控件添加点击事件后,发现推荐页无法按预期显示隐藏:无论滑动多短的距离甚至是向上滑动,只要是在屏幕下方滑动,推荐页总是会自己显示出来。通过打印了Log,发现原因出在onTouchEvent的ACTION_DOWN里面,即:ACTION_DOWN没有触发,但是ACTION_UP触发了,导致上述的downY[0]值为0,而curY很大,因此得到了下滑距离很大的假象。

  • 问题解决
    知其然知其所以然,通过百度得知,RecyclerView的item里的控件设置onClick方法,会抢占onTouchEvent,在ACTION_DOWN动作发生的时候,所以解决办法就是将那个点击控件重写onTouchEvent返回false,从而让touch事件继续向外传递到RecyclerView。
    但是若item里面有N多个点击控件,每一个都写过去的话,这肯定不是解决办法。经公司里带我的师父点播,发现XRecyclerView类里面有这样一个东西:
    mRefreshHeader.getVisibleHeight()
    于是我想到通过判断XRecyclerView刷新头部可见高度来决定是否显示推荐页,在XRecyclerView源码(导入第三方源码方法详见这里)里面写了这样一个方法:

    1
    2
    3
    4
    5
    6
    // 获取刷新头部可见高度
    public int getHeaderVisibleHeight() {
    if (mRefreshHeader == null) {
    return 0;
    }
    return mRefreshHeader.getVisibleHeight();}

如此一来,onTouchEvent里面的代码量大大减少:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mXrvLive.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_UP:
// 获取刷新头可见范围的高度
int visibleHeight = mXrvLive.getHeaderVisibleHeight();
// 如果可见高度大于133dp
if (visibleHeight >= DensityUtil.dip2px(getActivity(), 133)) {
// 显示推荐页
mRecommendPage.show();
}
break;
}
return false;
}
});

参考

Android Scroller简单用法
Android学习Scroller(四)——实现拉动后回弹的布局

以上就是上滑解锁效果的所有内容,代码已上传Github,欢迎访问指导!
手打不容易,请支持原创,转载时请注明链接:http://www.jianshu.com/p/826238318551

坚持原创技术分享,您的支持将鼓励我继续创作