前情提要
最近,公司开发的APP中要实现类似上滑解锁效果的推荐页,捣腾了两天,基本实现了效果,附效果图如上。接下来和大家聊聊如何实现这样的效果。
实现思路
这个效果的实现思路主要围绕手指触屏事件展开,注意点如下:
- 以ACTION_DOWN和ACTION_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方法:
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);
}
禁止下拉并判断是否为有效上滑:
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;
}
}
流畅滑动实现:
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();
}
阻尼滑动实现:
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;
}
}
其他封装:
前面我们说到自定义控件的时候,填充布局,这里我们考虑到布局需要填充数据的情况,封装了常用的方法,大家可以根据自己的业务逻辑进行相应封装。
// 视图容器
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方法:
// 显示视图
public void show() {
isHidden = false;
prepareScroll(0, 0);
}
// 隐藏视图
public void hide() {
isHidden = true;
prepareScroll(0, mViewHeight);
}
重写XRecyclerView的onTouchEvent事件:
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很大,因此得到了下滑距离很大的假象。 -
问题解决
mRefreshHeader.getVisibleHeight()
知其然知其所以然,通过百度得知,RecyclerView的item里的控件设置onClick方法,会抢占onTouchEvent,在ACTION_DOWN动作发生的时候,所以解决办法就是将那个点击控件重写onTouchEvent返回false,从而让touch事件继续向外传递到RecyclerView。
但是若item里面有N多个点击控件,每一个都写过去的话,这肯定不是解决办法。经公司里带我的师父点播,发现XRecyclerView类里面有这样一个东西:
于是我想到通过判断XRecyclerView刷新头部可见高度来决定是否显示推荐页,在XRecyclerView源码(导入第三方源码方法详见这里)里面写了这样一个方法:
// 获取刷新头部可见高度
public int getHeaderVisibleHeight() {
if (mRefreshHeader == null) {
return 0;
}
return mRefreshHeader.getVisibleHeight();}
如此一来,onTouchEvent里面的代码量大大减少:
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
网友评论