[Digging] 支付寶首頁交互三部曲 3 實現(xiàn)支付寶首頁交互

cover_3

博客原文:kyleduo.com

前言

這個系列源自前幾天看到一篇使用CoordinatorLayout實現(xiàn)支付寶首頁效果的文章,下載看了效果和源碼,不敢茍同,所以打算自己動手。實現(xiàn)的過程有點曲折,但也發(fā)現(xiàn)了一些有意思的事情,用三篇文章來記錄并分享給大家。

  • CoordinatorLayout和Behavior
  • 自定義CoordinatorLayout.Behavior
  • 支付寶首頁效果實現(xiàn)

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

源碼:Github

先看下最終效果:

跳轉到優(yōu)酷

效果分析

支付寶首頁基本可以看成4個部分:

alipay_home_struct

折疊時QuickAction部分折疊,繼續(xù)向上滑動,GridMenu移出屏幕。下拉時,刷新動畫出現(xiàn)在GridMenu和MessageList之間。

結構設計

前一部分只是分析了一下結構,這里就要開始設計了。為了實現(xiàn)QuickAction折疊的效果,其實有好幾種設計方法:

  1. 除SearchBar外,剩下的部分均使用RecyclerView實現(xiàn)。
  2. SearchBar和QuickAction作為Header,其他部分使用RecyclerView實現(xiàn)。
  3. SearchBar、QuickAction、GridMenu作為Header,MessageList使用RecyclerView實現(xiàn)。
  4. ……

為了方便擺放下拉刷新的位置,我選擇了第三種結構,同時使用SwipeRefreshLayout實現(xiàn)下拉刷新,這種結構也是為了方便替換成其他下拉刷新控件??聪伦罱K的實現(xiàn)效果:

視頻

除了下拉刷新效果使用了SwipeRefreshLayout以及在GridMenu和QuickAction位置下拉不能觸發(fā)下拉刷新外,其他的交互效果都和支付寶無異。

在開始動手之前,我還查看了支付寶的實現(xiàn)方法,很意外的是支付寶是使用ListView實現(xiàn)的這個頁面,除了SearchBar,其他部分均為ListView。

alipay-home-uiviewer

實現(xiàn)

我們的效果實際上和AppBarLayout有很多相似之處,通過上篇文章,我們知道了AppBarLayout使用的兩個Behavior使用了3個基類,如果能用就好了。不過這三個基類的訪問權限是包可見,所以只好從Support中拷出來使用了。還有些步驟需要修改基類中的方法,以及增加方法可見性。

APHeaderView

APHeaderView包括除MessageList以外的其他部分,要實現(xiàn)的大致相當于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移至頂部,這樣可以一直顯示。

布局部分沒啥好說的,實現(xiàn)的是類似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();
    }
}

滾動區(qū)域是控制滾動的重要部分,這里涉及到兩個方法。getScrollRange返回總的可滾動區(qū)域,getSnapRange返回折疊效果的區(qū)域。

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事件處理,需要實現(xiàn)的,是NestedScrolling和snap效果。比AppBarLayout更多的,APHeaderView.Behavior實現(xiàn)了精確地Fling效果。也就是說Fling效果和RecyclerView也是聯(lián)動的。這里主要說一下我是怎么處理Fling效果的。

Header -> ScrollingView

fling效果的實現(xiàn),是通過Scroller不斷修改偏移量最終呈現(xiàn)出連貫的動畫。如果不處理fling效果,結果就是ScrollingView在fling到頂端時,出現(xiàn)overscroll效果,也就是剩余了部分偏移量沒有消費。所以,要實現(xiàn)fling的聯(lián)動,就是消費多余的偏移量

fling可能在兩個方法中觸發(fā),一個是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的實現(xiàn),就是覆寫這個方法,將沒有消費的偏移量分發(fā)出去。我們先看覆寫的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效果依然不能停止,因為我們要把多余的偏移量再次分發(fā)給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實現(xiàn),這個方法返回父類消費的偏移量。然后計算剩余的偏移量:

int unconsumedY = newOffset - curOffset + consumed - mTempFlingDispatchConsumed;

注意這里的mTempFlingDispatchConsumed變量,因為不能直接獲取總的dy,在使用newOffset-curOffset獲取dy時,當?shù)竭_實際邊界時,因為curOffset不會繼續(xù)變小,所以獲取到的dy實際上是累計的,所以使用mTempFlingDispatchConsumed變量存儲額外消費的掉的偏移量。

unconsumedY不為0時,說明有剩余未消費的偏移量,我們把它分發(fā)出去,同時記錄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分發(fā)。

ScrollingView -> Header

ScrollingView需要在向下觸發(fā)fling效果時,將未消費的偏移量交給Header處理。RecyclerView依賴LayoutManager進行滾動。具體為scrollVerticallyBy方法,我們需要覆寫這個方法,分發(fā)未消費的偏移量,這里直接使用匿名內部類進行覆寫。

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的,所以不會造成循環(huán)調用。

這樣就實現(xiàn)了fling事件的雙向分發(fā)。

APScrollingBehavior

因為APScrollingBehavior和AppBarLayout.ScrollingBehavior并沒有特別不同,這里就不贅述了。

總結

雖然實現(xiàn)這個效果沒用多少時間,但是借此機會又完整的分析了AppBarLayout、CoordinatorLayout、Behavior等官方的實現(xiàn),讓這個效果變得有意義了一些。

如果單獨評價這個交互的話,我倒覺得下拉刷新應該出現(xiàn)在GridMenu上面,這樣頁面看起來重心就比較穩(wěn)了,不至于頭重腳輕。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容