【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方法:

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很大,因此得到了下滑距离很大的假象。

  • 问题解决
    知其然知其所以然,通过百度得知,RecyclerView的item里的控件设置onClick方法,会抢占onTouchEvent,在ACTION_DOWN动作发生的时候,所以解决办法就是将那个点击控件重写onTouchEvent返回false,从而让touch事件继续向外传递到RecyclerView。
    但是若item里面有N多个点击控件,每一个都写过去的话,这肯定不是解决办法。经公司里带我的师父点播,发现XRecyclerView类里面有这样一个东西:

    mRefreshHeader.getVisibleHeight()

    于是我想到通过判断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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容