仿探探卡片滑动选择

作者: hewking | 来源:发表于2019-01-05 18:22 被阅读29次

    探探的滑动选择妹子的功能,算是一个很经典的交互方式。自从出来以后可以说是备受关注,渐渐地很多类似功能的app也都有尝试。实现也是具有综合性的挑战,所以说网上也是有不少例子的,在这里我通过自定义ViewGroup的方式来实现。

    需要达到的效果

    实现的过程中,当然我们需要参考探探。这里实现最核心的功能,如下:

    • 卡片的层叠显示
    • 拖动选择卡片
    • 加载数据
    怎么实现呢?

    当第一眼看到,察觉到的难点当然是拖动的实现。拖动的过程中会旋转,同时层叠中的view 会改变位置。如果松手还会返回原位置或者移除卡片。在自定义viewGroup中拖动事件算是很麻烦的实现。但是呢官方给我们提供一一大神器ViewDragHelper。有了它我们实现起来就事半功倍了,在这里之前也有文章介绍。如果不太明白使用,参考资料会列出来。既然拖动现在好说了。那么层叠的效果呢?这里不得不说算是核心了。在这里我也走过弯路,因为之前的实现我是想的让onlayout的时候,让子view在不同位置,并且缩放的宽高也用onLayout变更left,top,right,bottom实现。但是实践过程中会变得很复杂,不好实现。后面果断改变思路。在onLayout中对每一个view都根据它自身的已测量宽高居中显示,然后通过设置setScale,setTranslationY改变y轴防线的偏移量实现。可以看到我们是居中layout,我们事先的效果是y轴方向的偏移,所以主要看y轴的layout.这里需要琢磨一下滑动的过程中的显示,卡片的总量是固定值,我们默认设置为4,当然是可以改变的。我们可以看到探探滑动的时候,最底层的view,跟倒数第二层初始状态是叠在一起的。我们定义从最顶层为第一层,一次递增。并且每一层都有一个固定的offset,每一层都有固定的缩放scale。因为缩放也会造成y轴方向的偏移变化,这里记缩放引起的偏移scaleYOffset.所以总的totalOffset = offset + scaleYOffset.可以看到offset,scaleYOffset都跟子view所在的层次有关。接下来结合代码分析
    先定义一些常量

        private static final float DEFAULT_SCALE = 0.05f;//默认缩放的级别
        private static final int DEFAULT_OFFSET = 10;//dp
        private static final int DEFAULT_MARGIN = 10;//dp
        private static final int DEFAULT_DEGRESS = 20;//旋转的度数
        private static final int DEFAULT_SHOW_COUNT = 4;//默认显示数量
    
    layout 实现
      protected void onLayout(boolean changed, int l, int t, int r, int b) {
            float scale = 1f;
            int level = 0;
            for (int i = getChildCount() - 1; i >= 0; i--) {
                View child = getChildAt(i);
                float scaleValue = scale - DEFAULT_SCALE * (level);
    
                int offset = ViewExKt.dp2px(this, DEFAULT_OFFSET);
                int offsetValue = offset * (level);
    
                child.layout(mCenterX - child.getMeasuredWidth() / 2
                        , mCenterY - child.getMeasuredHeight() / 2
                        , mCenterX + child.getMeasuredWidth() / 2
                        , mCenterY + child.getMeasuredHeight() / 2);
    
                float yOffset = child.getMeasuredHeight() * DEFAULT_SCALE * (level) / 2;
    
                child.setTranslationY(yOffset + offsetValue);
                child.setScaleX(scaleValue);
                child.setScaleY(scaleValue);
    
                // i > 1 是因为确保最后两个view是重叠在一起
                if (i > 1 || getChildCount() < showCount) {
                    level++;
                }
            }
        }
    

    可以看到以上代码对没个子view进行遍历,同时根据每个子view的level,最顶部为0.根据level 算出拨通的offsetValue,yOffset,最终相加计算出总偏移量,scaleValue 也根据level 计算。最终判断i>1 是为了,不计算最底部level增加,让最底部view跟倒数第二个子view缩放级别一致。在layout之前肯定要先measure,这里实现比较简单,仅仅是对自view进行测量,WRAP_CONTENT状态下没有根据子view宽高,定义自身宽高,还需要改进根据子view最大宽高。

       protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            measureChildren(widthMeasureSpec, heightMeasureSpec);
        }
    

    当我们测量,和布局之后。显示出来就已经是层叠的效果了,接下来则需要通过ViewDragHelper 对子view进行拖动及触摸反馈了。还有对数据加载的处理。

    拖动的处理

    可以看到使用ViewDraghelpr处理是非常方便的,每个回调方法都很清晰,方法也很实用。接下来是ViewDragHelper标准操作如下:

    //接管onTneterceptTouchEvent
      @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
            return mDragHelper.shouldInterceptTouchEvent(ev);
        }
        
        //处理onTouchEvent,核心方法,处理事件的封装都在这里了
           @Override
        public boolean onTouchEvent(MotionEvent event) {
            mDragHelper.processTouchEvent(event);
            return true;
        }
        //vdh的滑动采用的OverScroll 当然需要实现computeScroll
           @Override
        public void computeScroll() {
            super.computeScroll();
            if (mDragHelper.continueSettling(true)) {
                postInvalidate();
            }
        }
    

    回调方法,这里所有重要的操作都在这些方法里面了,特别是
    tryCcaptureView,onViewReleased,onViewPositionChanged.
    在拖动的过程中,始终拖动的是最顶部的view,这里怎么实现呢?,很简单,tryCaptureView指定某个view可以被拖动

      public boolean tryCaptureView(View child, int pointerId) {
                    // 最top 的view 可滑动
                    return indexOfChild(child) == getChildCount() - 1;
                }
    

    现在已经可以拖动最顶部的view了,如果我们松手会停留在拖动到的位置,这里只需要调用settleCaptureViewAt,结合computeScroll 可以滑动到指定位置

    if (isDraging) {
                        mDragHelper.settleCapturedViewAt(mCenterX - releasedChild.getMeasuredWidth() / 2
                                , mCenterY - releasedChild.getMeasuredHeight() / 2);
                        invalidate();
                    }
    
    

    好了,现在我们具有层叠效果,并且可以拖动顶部view,并且松手会返回原位了。接下来就该拖动的时候剩下子view的变化。在拖动的过程中onViewPositionChanged会始终被调用,这里根据拖动的位置left,top,dx,dy的变化,判断出子view的变化。那么子view需要什么变化呢。通过之前onLayout的分析,可知道子view是分level的,比如倒数的二层在onlayout level是1,设定的缩放是0.9f,在这里我们需要根据顶部view的拖动使其它子view,变大或变小,也就是缩放和translationY的变化,都要结合起onLayout的时候来做。这都需要有一个变化率在[0,1]之前,这里我们通过

    float rate = left * 1.0f / (getMeasuredWidth() / 3);
                        float a = Math.min(1, Math.max(0, Math.abs(rate)));
    

    以上代码可以算出我们想要的比例,为什么是宽除以3,这里是我选择的当然也可以选择其他值。因为我觉得3正好。当然越大rate越大。

          int offset = ViewExKt.dp2px(TinderStackLayout.this, DEFAULT_OFFSET);
                        // 这里为什么会有判断 i = 0,i= 1,是因为如果释放了会把view remove
                        // 所以这里会做判断保证布局底部的显示,从1开始最底部view 不会有变化
                        for (int i = getChildCount() < showCount ? 0 : 1; i < getChildCount() - 1; i++) {
                            View child = getChildAt(i);
                            // ds 代表缩放,分为两部分计算 + 号前面是布局的时候应该缩放多少,后段是跟随滑动
                            // 缩放的变化量
                            float ds = 1 - DEFAULT_SCALE * (getChildCount() - 1 - i) + DEFAULT_SCALE * a;
                            // 同根据布局时固定的的偏移量 - 变化量
                            float doffset = (getChildCount() - 1 - i) * offset - offset * a;
                            // 同布局时缩放的偏移量 - 变化量
                            float yOffset = child.getMeasuredHeight() * DEFAULT_SCALE * (getChildCount() - 1 - i - a) / 2;
                            child.setScaleY(ds);
                            child.setScaleX(ds);
                            child.setTranslationY(doffset + yOffset);
    
                            L.d(TAG, "ds : " + ds + " doffset : " + doffset + " a : " + a);
                        }
    

    以上代码,根据onlayout的数据,和rate值的变化设置child的scale,和 translationy的变化。这里就不多解释了,代码注释相信可以理解。就是onLayout的值加上 rate的相关变化率。通过这里代码的实现我们已经可以拖动的时候实现其他子view的缩放平移变化了。会发现,可以一直拖动但是我们需要,超过一个限定值就会触发选择事件,移除view,并滑向远方。这里使用两个值判断,a.是否left超过width的三分之一,b.斜率是否超过0.15。

    //斜率,有方向
                    float sloap = top * 1.0f / left;
    

    斜率的计算。
    判断是否是继续拖动还是触发事件

    // top view 滑动的距离超过 宽度的三分之一,并且斜率 大于0.15 可以视为触发选择事件
                    if (Math.abs(left) > getMeasuredWidth() / 3 && Math.abs(sloap) > 0.15) {
                        mReleasedPoint.x = left;
                        mReleasedPoint.y = top;
                        isDraging = false;
                    }
    

    在这里因为需要记录状态值,和拖动事件触发的位置,用于释放时的计算。通过isDraging,mReleasedPoint保存。接下来看onViewReleased的实现,这里是实现的事件触发的关键

                    if (isDraging) {
    
    

    通过isDraging的判断是否停止拖动触发事件

    if (mReleasedPoint.x != 0 && mReleasedPoint.y != 0) {
                            final float sloap = mReleasedPoint.y / (mReleasedPoint.x * 1.0f);
                            if (Math.abs(mReleasedPoint.x) > getMeasuredWidth() / 3 && Math.abs(sloap) > 0.15) {
                                mDragHelper.smoothSlideViewTo(releasedChild, getMeasuredWidth(), (int) (getMeasuredWidth() * sloap));
    
                                onChoosePick(sloap);
    
                                invalidate();
                                mReleasedPoint.x = 0;
                                mReleasedPoint.y = 0;
                                removeView(releasedChild);
                                onAddView();
                            }
                        }
    

    通过代码判断是否触发移除和触发事件。mDraghelper.smoothSlideViewTo 把view 通过动画移到远处,并且removeView,触发onChoosePick(sloap)是左选还是右选,onAddView()添加新的view进来,如果有的话。

    通过以上实现我们已经可以拖动到指定限制处释放view了。实现选择功能了。但是我们还需要旋转,这里很简单,在onViewPositionChanged里面的rate可以帮助实现,并且rate是又方向的,这可以实现左右拖动角度的变化

                        changedView.setRotation(rate * DEFAULT_DEGRESS);
    
    

    限制基本上效果都有了,但是还有个问题,因为left不会为0,所以rate不会为0 会有偏差,所以需要监听IDLE状态,设置到0

                public void onViewDragStateChanged(int state) {
                    super.onViewDragStateChanged(state);
                    // 停止滑动的时候,将最后一个view 角度设置为0,因为算斜率的
                    // 的方式最后滑动完成会有微小的偏差
                    if (state == ViewDragHelper.STATE_IDLE && isDraging) {
                        View childTop = getChildAt(getChildCount() - 1);
                        if (childTop != null) {
                            childTop.setRotation(0);
                        }
                    }
                }
    
    

    这样基本功能已经实现,但是我们需要数据还有选择的监听,这也很重要。这里采用适配器实现我们关心的只有是否添加view.还有个数。

       public interface BaseCardAdapter {
            int getItemCount();
    
            View getView();
        }
    
        public interface OnChooseListener{
            // 1 为右边滑动 0 为左边滑动
            void onPicked(int directon);
        }
    

    这里是回调

      private void onAddView() {
            if (adapter != null) {
                if (adapter.getView() == null) {
                    return;
                }
                addView(adapter.getView(),0);
            }
        }
    
        private void onChoosePick(float sloap) {
            if (chooseListener != null) {
                chooseListener.onPicked(sloap > 0 ? 1 : 0);
            }
        }
    

    设置adapter添加初始数据

       public void setAdapter(BaseCardAdapter adapter) {
            this.adapter = adapter;
            if (adapter != null){
                int count = Math.min(adapter.getItemCount(),showCount);
                if (count <= 0) {
                    return ;
                }
                for (int i = 0 ;i < count ; i++) {
                    addView(adapter.getView());
                }
            }
        }
    

    到这里已经实现完毕,效果还不错,如果需要查看一下demo,请参考源码。


    device-2019-01-05-181446.png

    我是源码,有兴趣可以看下

    参考资料

    Android ViewDragHelper完全解析 自定义ViewGroup神器

    相关文章

      网友评论

        本文标题:仿探探卡片滑动选择

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