[Digging] 支付宝首页交互三部曲 3 实现支付宝首页交互

cover_3

博客原文:kyleduo.com

前言

这个系列源自前几天看到一篇使用CoordinatorLayout实现支付宝首页效果的文章,下载看了效果和源码,不敢苟同,所以打算自己动手。实现的过程有点曲折,但也发现了一些有意思的事情,用三篇文章来记录并分享给大家。

  • CoordinatorLayout和Behavior
  • 自定义CoordinatorLayout.Behavior
  • 支付宝首页效果实现

文中:CoL代表CoordinatorLayout,ABL表示AppBarLayout,CTL表示CollapsingToolbarLayout,SRL表示SwipeRefreshLayout,RV表示RecyclerView。

源码:Github

先看下最终效果:

跳转到优酷

效果分析

支付宝首页基本可以看成4个部分:

alipay_home_struct

折叠时QuickAction部分折叠,继续向上滑动,GridMenu移出屏幕。下拉时,刷新动画出现在GridMenu和MessageList之间。

结构设计

前一部分只是分析了一下结构,这里就要开始设计了。为了实现QuickAction折叠的效果,其实有好几种设计方法:

  1. 除SearchBar外,剩下的部分均使用RecyclerView实现。
  2. SearchBar和QuickAction作为Header,其他部分使用RecyclerView实现。
  3. SearchBar、QuickAction、GridMenu作为Header,MessageList使用RecyclerView实现。
  4. ……

为了方便摆放下拉刷新的位置,我选择了第三种结构,同时使用SwipeRefreshLayout实现下拉刷新,这种结构也是为了方便替换成其他下拉刷新控件。看下最终的实现效果:

视频

除了下拉刷新效果使用了SwipeRefreshLayout以及在GridMenu和QuickAction位置下拉不能触发下拉刷新外,其他的交互效果都和支付宝无异。

在开始动手之前,我还查看了支付宝的实现方法,很意外的是支付宝是使用ListView实现的这个页面,除了SearchBar,其他部分均为ListView。

alipay-home-uiviewer

实现

我们的效果实际上和AppBarLayout有很多相似之处,通过上篇文章,我们知道了AppBarLayout使用的两个Behavior使用了3个基类,如果能用就好了。不过这三个基类的访问权限是包可见,所以只好从Support中拷出来使用了。还有些步骤需要修改基类中的方法,以及增加方法可见性。

APHeaderView

APHeaderView包括除MessageList以外的其他部分,要实现的大致相当于AppBarLayout和CollapsingToolbarLayout结合的效果。

APHeaderView继承自ViewGroup。

首先在onFinishInflate()方法中获取子View的引用,mBar是SearchBar部分,mSnapView是QuickAction部分,mScrollableViews是其余的View。之所以没有使用上面的命名方式,是因为我不想把这个效果限制的那么死,这些变量就以他们的功能命名了。

@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    final int childCount = getChildCount();
    if (childCount < 2) {
        throw new IllegalStateException("Child count must >= 2");
    }
    mBar = findViewById(R.id.alipay_bar);
    mSnapView = findViewById(R.id.alipay_snap);
    mScrollableViews = new ArrayList<>();
    for (int i = 0; i < childCount; i++) {
        View v = getChildAt(i);
        if (v != mBar && v != mSnapView) {
            mScrollableViews.add(v);
        }
    }
    mBar.bringToFront();
}

最后一行语句将mBar移至顶部,这样可以一直显示。

布局部分没啥好说的,实现的是类似LinearLayout的布局,子View顺次排列。偏移量的处理并不在此处。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    if (heightSize == 0) {
        heightSize = Integer.MAX_VALUE;
    }

    int height = 0;

    final int childCount = getChildCount();
    for (int i = 0; i < childCount; i++) {
        View c = getChildAt(i);
        measureChildWithMargins(
                c,
                MeasureSpec.makeMeasureSpec(widthSize - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY),
                0,
                MeasureSpec.makeMeasureSpec(heightSize - getPaddingTop() - getPaddingBottom(), MeasureSpec.AT_MOST),
                height
        );
        height += c.getMeasuredHeight();
    }

    height += getPaddingTop() + getPaddingBottom();

    setMeasuredDimension(
            widthSize,
            height
    );
}

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    int childTop = getPaddingTop();
    int childLeft = getPaddingLeft();
    mBar.layout(childLeft, childTop, childLeft + mBar.getMeasuredWidth(), childTop + mBar.getMeasuredHeight());
    childTop += mBar.getMeasuredHeight();

    mSnapView.layout(childLeft, childTop, childLeft + mSnapView.getMeasuredWidth(), childTop + mSnapView.getMeasuredHeight());

    childTop += mSnapView.getMeasuredHeight();

    for (View sv : mScrollableViews) {
        sv.layout(childLeft, childTop, childLeft + sv.getMeasuredWidth(), childTop + sv.getMeasuredHeight());
        childTop += sv.getMeasuredHeight();
    }
}

滚动区域是控制滚动的重要部分,这里涉及到两个方法。getScrollRange返回总的可滚动区域,getSnapRange返回折叠效果的区域。

public int getScrollRange() {
    int range = mSnapView.getMeasuredHeight();
    if (mScrollableViews != null) {
        for (View sv : mScrollableViews) {
            range += sv.getMeasuredHeight();
        }
    }
    return range;
}

private int getSnapRange() {
    return mSnapView.getHeight();
}

APHeaderView.Behavior

继承自HeaderBehavior,和AppBarLayout一样,天生自带Offset处理和Touch事件处理,需要实现的,是NestedScrolling和snap效果。比AppBarLayout更多的,APHeaderView.Behavior实现了精确地Fling效果。也就是说Fling效果和RecyclerView也是联动的。这里主要说一下我是怎么处理Fling效果的。

Header -> ScrollingView

fling效果的实现,是通过Scroller不断修改偏移量最终呈现出连贯的动画。如果不处理fling效果,结果就是ScrollingView在fling到顶端时,出现overscroll效果,也就是剩余了部分偏移量没有消费。所以,要实现fling的联动,就是消费多余的偏移量

fling可能在两个方法中触发,一个是onTouchEvent,还有就是onNestedPreFling。我们在onNestedPreFling方法中,判断如果是向上滑动,就手动调用fling方法,和onTouchEvent一致。

@Override
public boolean onNestedPreFling(CoordinatorLayout coordinatorLayout, APHeaderView child, View target, float velocityX, float velocityY) {
    if (velocityY > 0 && getTopAndBottomOffset() > -child.getScrollRange()) {
        fling(coordinatorLayout, child, -child.getScrollRange(), 0, -velocityY);
        mWasFlung = true;
        return true;
    }
    return false;
}

HeaderBehavior类中的Scroller回调,最终调用setHeaderTopAndBottomOffset方法设置偏移量:

@Override
public void run() {
    if (mLayout != null && mScroller != null) {
        if (mScroller.computeScrollOffset()) {
            setHeaderTopBottomOffset(mParent, mLayout, mScroller.getCurrY());
            // Post ourselves so that we run on the next animation
            ViewCompat.postOnAnimation(mLayout, this);
        } else {
            onFlingFinished(mParent, mLayout);
        }
    }
}

APHeaderView.Behavior的实现,就是覆写这个方法,将没有消费的偏移量分发出去。我们先看覆写的fling方法:如果判断向上滑动,除了设置标记为为true,同时会修改边界值:

@Override
protected boolean fling(CoordinatorLayout coordinatorLayout, APHeaderView layout, int minOffset, int maxOffset, float velocityY) {
    int min = minOffset;
    int max = maxOffset;
    if (velocityY < 0) {
        // 向上滚动
        mShouldDispatchFling = true;
        mTempFlingDispatchConsumed = 0;
        mTempFlingMinOffset = minOffset;
        mTempFlingMaxOffset = maxOffset;
        min = Integer.MIN_VALUE;
        max = Integer.MAX_VALUE;
    }
    return super.fling(coordinatorLayout, layout, min, max, velocityY);
}

修改边界值是因为我们希望即使达到边界,fling效果依然不能停止,因为我们要把多余的偏移量再次分发给ScrollingView。

@Override
public int setHeaderTopBottomOffset(CoordinatorLayout parent, APHeaderView header, int newOffset, int minOffset, int maxOffset) {
    final int curOffset = getTopAndBottomOffset();
    final int min;
    final int max;
    if (mShouldDispatchFling) {
        min = Math.max(mTempFlingMinOffset, minOffset);
        max = Math.min(mTempFlingMaxOffset, maxOffset);
    } else {
        min = minOffset;
        max = maxOffset;
    }

    int consumed = super.setHeaderTopBottomOffset(parent, header, newOffset, min, max);
    // consumed 的符号和 dy 相反

    header.dispatchOffsetChange(getTopAndBottomOffset());

    int delta = 0;

    if (mShouldDispatchFling && header.mOnHeaderFlingUnConsumedListener != null) {
        int unconsumedY = newOffset - curOffset + consumed - mTempFlingDispatchConsumed;
        if (unconsumedY != 0) {
            delta = header.mOnHeaderFlingUnConsumedListener.onFlingUnConsumed(header, newOffset, unconsumedY);
        }
        mTempFlingDispatchConsumed += -delta;
    }

    return consumed + delta;
}

首先修正边界值,然后调用父类的setHeaderTopBottomOffset实现,这个方法返回父类消费的偏移量。然后计算剩余的偏移量:

int unconsumedY = newOffset - curOffset + consumed - mTempFlingDispatchConsumed;

注意这里的mTempFlingDispatchConsumed变量,因为不能直接获取总的dy,在使用newOffset-curOffset获取dy时,当到达实际边界时,因为curOffset不会继续变小,所以获取到的dy实际上是累计的,所以使用mTempFlingDispatchConsumed变量存储额外消费的掉的偏移量。

unconsumedY不为0时,说明有剩余未消费的偏移量,我们把它分发出去,同时记录Listener消费的值,把这个值加上header本身消费的值,作为总消费量返回。

mHeaderView.setOnHeaderFlingUnConsumedListener(new APHeaderView.OnHeaderFlingUnConsumedListener() {
    @Override
    public int onFlingUnConsumed(APHeaderView header, int targetOffset, int unconsumed) {
        APHeaderView.Behavior behavior = mHeaderView.getBehavior();
        int dy = -unconsumed;
        if (behavior != null) {
            mRecyclerView.scrollBy(0, dy);
        }
        return dy;
    }
});

在listener中,直接调用RecyclerView的scrollBy方法进行滑动(注意符号)。

这样就完成了Header向ScrollingView的fling分发。

ScrollingView -> Header

ScrollingView需要在向下触发fling效果时,将未消费的偏移量交给Header处理。RecyclerView依赖LayoutManager进行滚动。具体为scrollVerticallyBy方法,我们需要覆写这个方法,分发未消费的偏移量,这里直接使用匿名内部类进行覆写。

final LinearLayoutManager lm = new LinearLayoutManager(mActivity, LinearLayoutManager.VERTICAL, false) {

    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        int scrolled = super.scrollVerticallyBy(dy, recycler, state);
        if (dy < 0 && scrolled != dy) {
            // 有剩余
            APHeaderView.Behavior behavior = mHeaderView.getBehavior();
            if (behavior != null) {
                int unconsumed = dy - scrolled;
                int consumed = behavior.scroll((CoordinatorLayout) mHeaderView.getParent(), mHeaderView, unconsumed, -mHeaderView.getScrollRange(), 0);
                scrolled += consumed;
            }
        }
        return scrolled;
    }
};

和RecyclerView类似,调用HeaderBehavior.scroll方法进行滚动,注意边界的处理。

虽然scroll也会调用到setHeaderTopBottomOffset,但是因为此时mShouldDispatchFling一定是为false的,所以不会造成循环调用。

这样就实现了fling事件的双向分发。

APScrollingBehavior

因为APScrollingBehavior和AppBarLayout.ScrollingBehavior并没有特别不同,这里就不赘述了。

总结

虽然实现这个效果没用多少时间,但是借此机会又完整的分析了AppBarLayout、CoordinatorLayout、Behavior等官方的实现,让这个效果变得有意义了一些。

如果单独评价这个交互的话,我倒觉得下拉刷新应该出现在GridMenu上面,这样页面看起来重心就比较稳了,不至于头重脚轻。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容