美文网首页Android自定义控件Android知识Android自定义View
【Android】手把手教你上滑解锁的效果

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

作者: 代码咖啡 | 来源:发表于2016-11-18 16:03 被阅读1866次

    前情提要

    最近,公司开发的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

    相关文章

      网友评论

        本文标题:【Android】手把手教你上滑解锁的效果

        本文链接:https://www.haomeiwen.com/subject/erhlpttx.html