探探的滑动选择妹子的功能,算是一个很经典的交互方式。自从出来以后可以说是备受关注,渐渐地很多类似功能的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神器