仿探探卡片滑动选择

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

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

推荐阅读更多精彩内容

  • 本篇博客讲解的是自定义View之侧滑面板,应用场景:QQ,知乎,效果图如下 一、内容摘要 了解ViewDragHe...
    JackChen1024阅读 501评论 0 1
  • 红梅图 葛小芬 雀寒飞不远, 孤石相依偎。 情冷风流伴, 飘香万里梅。 题图 陈淑荣 霜寒梅吐艳, 石峭雀双偎。 ...
    金赛月阅读 968评论 8 25
  • java.io.File类提供了一组用于读取文件的属性方法,如判定是否可执行,可读,可写及最近修改时间: ²boo...
    博为峰51Code教研组阅读 118评论 0 0
  • 有一天做梦 你跑来找我 我一下子就原谅了你 其实你不会来找我 记性那么烂的我 记得你的样子 记得你的关心 你的努力...
    圈圆阅读 159评论 0 0