Skip to main content

[Android] How to make scrolling effect like GooglePlay’s homepage

前陣子公司 App 要設計新的首頁樣式,滑動效果長得像當時的 GooglePlay 首頁,因此花了一些時間研究,撞了些牆、踩了些地雷,最後終於把效果生出來。讓我們先來看看 GooglePlay 首頁的滑動效果。

giphy.gif

根據這個影片,整理了以下幾點滑動的重點行為:

手指往上滑時
1. Header 會被內容推上去,並推到螢幕外
2. Tab 會被內容推上去,並卡在畫面最上方 (註:原本GooglePlay的會被推上去,我們希望卡在最上方)
3. 當 Tab 往上移動碰到 Toolbar 時,Toolbar 要被 Tab 推上去

手指往下滑時
1. TabToolbar 要被拉下來,並卡在最上面
2. 等內容滑到最頂端時,HeaderTab 要被拉下來,並回到原本的位子

為了實現這個效果,最先想到的就是 Google Design Library 所提供的 CoordinatorLayout ,原以為可以簡單的用這套完成,沒想到卻卡了相當久。前後嘗試了約莫四、五種方法,從 CoordinatorLayoutCustomize ScrollViewCustomize NestedScrolling 等方法,幾乎都卡在 Fling 上。

最後在無計可施的情況下,才決定直接用 Child 的 RecyclerViewOnScrolledListener 來計算並移動 Header,也就是今天要跟各位分享的方法,讓我們開始吧!


NineYiScrolling

我們的首頁是由一個 ParentFragment ( 簡稱 Parent ) 包著 ViewPager,而 ViewPager 中會有最多三個 ChildFragment ( 簡稱 Child )。

從下方的剖面圖可以看到我們在 Child 的 RecyclerView 頂端放置了一塊 Decoration,並在 Parent 中把 HeaderToolbar 疊在 Decoration 上方。

剖面圖.jpg

在滑動時透過 Child 中的 RecyclerViewscrollY 來推動 Parent 中的 Header,來達到滑動的效果。


Part 1 — Getting Start

Layout

Parent 的 Layout 是將 Tab 包在 Header 中,並且將 HeaderToolbar 疊在 Child 的 ViewPager 上方:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_layout"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ViewPager
        android:id="@+id/main_pager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

    <RelativeLayout
        android:id="@+id/main_header"
        android:layout_width="match_parent"
        android:layout_height="@dimen/header_height"
        android:orientation="vertical">
        <ImageView
            android:id="@+id/main_banner"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:scaleType="centerCrop"
            android:layout_above="@+id/shop_home_slidingtab"/>

        <SlidingTabLayout
            android:id="@+id/slidingtab"
            android:layout_width="match_parent"
            android:layout_height="48dp"
            android:layout_alignParentBottom="true"/>
    </RelativeLayout>

    <android.support.v7.widget.Toolbar 
        android:id="@+id/shop_main_toolbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:minHeight="?attr/actionBarSize"
        app:theme="@style/toolbar_theme"/>

</FrameLayout>

Child 的 Layout 非常的單純,只有一個 RecyclerView

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout      
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <android.support.v7.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/bg_body"
        android:scrollbars="vertical" />
</FrameLayout>

Layout 都設定完後看起來會像這樣:

iKjKDqt.gif


Start Scrolling

IScrollingParent / IScrollingChild

有了 Layout 後,我們要開始來滑動了。由於我們的 RecyclerViewHeader 不同層,因此我們需要透過 IScrollingParentIScrollingChild 兩個 Interface 來讓 Parent 與 Child 溝通來進行滑動。

IScrollingParent — 監聽 Child 的滑動,並且根據 dy 移動 HeaderToolbar

public interface IScrollingParent {
    void onChildScrolled(ScrollingView view, int pagePosition, int scrollY, int dy);
}

IScrollingChild — 滑動時通知 Parent,並透過 adjustScroll()onHeaderScrolled() 調整 Child 的畫面。

public interface IScrollingChild {
    void adjustScroll(float headerTranslationY, boolean isHeaderExpanded);
    void onHeaderTranslation(int headerTranslationY);
    void setScrollingParent(IShopHomeScrollingParent scrolling);
    void setPagePosition(int position);
}

現在要根據 Child 的 RecyclerViewscrollY 來滑動 Parent 中的 Header

我們先讓 Child 實作 IScrollingChild ,並且新增一個 OnScrollListenerRecyclerView ,並在 onScrolled() 時把滑動的參數傳給 Parent 。

public class ScrollingChildFragment implements IScrollingChild {
    private IScrollingParent mScrollingParent;
    private int mPagePosition;

    protected RecyclerView mRecyclerView;
    ScrollingOnScrollListener mScrollListener;

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
        mScrollListener = new ScrollingOnScrollListener();
        mRecyclerView.addOnScrollListener(mScrollListener);
    }

    @Override
    public void adjustScroll(float headerTranslationY, boolean isHeaderExpanded) {
    }

    @Override
    public void onHeaderTranslation(int headerTranslationY) {
    }

    @Override
    public void setScrollingParent(IScrollingParent scrolling) {
        mScrollingParent = scrolling;
    }

    @Override
    public void setPagePosition(int position) {
        mPagePosition = position;
    }

    class ScrollingOnScrollListener extends RecyclerView.OnScrollListener {
        private int mScrollY = 0;

        @Override
        public void onScrolled(RecyclerView view, int dx, int dy) {
            mScrollY += dy;
            mScrollingParent.onChildScrolled(view, mPagePosition, mScrollY, dy);

        }

        public int getScrollY() {
            return mScrollY;
        }
    }

}

接著讓 Parent 實作 IScrollingParent ,並且在 onChildScrolled() 裡透過 Child 丟進來的 scrollY 移動 Header

Tips:
ViewTranslationYRecyclerViewScrollY 方向是相反的。

當手指向上滑時:
View 向畫面上方移動, TranslationY 會是負的;
RecyclerViewScrollY 則會是正的。

public class ScrollingParentFragment implements IScrollingParent {

    @Override
    public void onChildScrolled(ScrollingView view, int pagePosition, int scrollY, int dy) {
        mHeader.setTranslationY(-scrollY);
    }

}

現在 Parent 與 Child 都已經準備好要滑動了,但還需要把 Parent 與 Child 串起來。

由於我們架構的關係,我需要透過 Parent 的 PagerAdapter 幫我把 IScrollingParentpagePosition 等參數在 addPageFragment() 時傳給 Child ,同時把 IScrollingChild 存起來,在後續 Header 的滑動中會需要用到。

public class ParentAdapter extends FragmentPagerAdapter {
    private final ArrayList<Fragment> mFragments = new ArrayList<>();

    private SparseArrayCompat<IScrollingChild> mIScrollingChildList;
    private IScrollingParent mScrollingParent;

    public ParentAdapter(FragmentManager fm) {
        super(fm);
        mIScrollingChildList = new SparseArrayCompat<>();
    }

    public void addPageFragment(Fragment fragment) {
        mFragments.add(fragment);

        if(fragment instanceof IScrollingChild) {
            int position = mFragments.indexOf(fragment);

            IScrollingChild child = (IScrollingChild) fragment;

            child.setPagePosition(position);
            mIScrollingChildList.put(position, child);

            if (mScrollingParent != null) {
                child.setScrollingParent(mScrollingParent);
            }
        }
    }

    public void setIScrollingParent(IScrollingParent parent) {
         mScrollingParent = parent;
    }

    public IScrollingChild getIScrollingChild(int position) {
         return mIScrollingChildList.get(position);
    }

}

現在 Header 已經可以滑動了:

xj72ODT.gif

我們已經完成基本的滑動行為 — Header 跟著 Child 的滑動移動。但可以看到目前還有許多問題,像是 Header 蓋住了 Child 的 RecyclerView 等等,接下來會開始一一處理這些問題。


Part 2 — Sync Child’s scroll with Header

Decoration

現在要先來處理 Header 蓋住 Child 的問題。

JB9sChW.png

要解決這個問題,我們必須想辦法把 Child 的 RecyclerView 推到 Header 下面。所以我們在 RecyclerView 的最上面加上一塊高度與 Header 相同的 Decoration 來把 RecyclerView 往下推。

public class ScrollingChildFragment implements IScrollingChild {

    ...

    protected RecyclerView mRecyclerView;

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
        mRecyclerView.addOnScrollListener(new ScrollingOnScrollListener());
        mRecyclerView.addItemDecoration(new ScrollingDecoration(getHeaderHeight()));
    }

    ...   

    class ScrollingDecoration extends RecyclerView.ItemDecoration {
        private int mHeaderHeight;

        public ShopHomeScrollingDecoration(height) {
            super();
            mHeaderHeight = height;
        }

        @Override
        public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
            if(parent.getChildAdapterPosition(view) == 0) {
                outRect.top = mHeaderHeight;
            }
        }
    }

}

現在 Header 已經不會壓到 Child 了。

oE3papr.gif

但很快的我們發現一個問題:

vKatopk.gif

由於其他的 Child 並不知道目前的 Child 與 Header 的滑動狀況,所以當我們切換 Child 的時候,就會發生上圖的狀況。所以我們必須在切換 Child 的時候,將下一個 Child 的 RecyclerView 同步到 Header 下方。

在這裡我們要使用 adjustScroll() 來解決這個問題。


adjustScroll()

現在我們要在切換 Child 時,透過 adjustScroll() 來將下一個 Child 的 RecyclerView 同步到 Header 下方。

void adjustScroll(float headerTranslationY, boolean isHeaderExpanded);

可以看到在 adjustScroll() 中有傳入兩個參數,分別是:

  • headerTranslationY — Header 目前移動的距離。我們會透過這個參數,在切換 Child 時,調整的 RecyclerView 的滑動位子。
  • isHeaderExpanded — Header 是否展開。這個目前先不用理他。

現在要在 Parent 裡幫 ViewPager 加入 OnPageChangeListener,並分別在 onPageScrolled()onPageSelected() 裡呼叫 adjustScroll()

public class ScrollingParentFragment implements ISCrollingParent {

    ...

    ViewPager mViewPager;

    ...

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
        mViewPager = (ViewPager) view.findViewById(R.id.main_pager);
        mViewPager.setOnPageChangeListener(mOnPageChangeListener);
    }

    ...

    ViewPager.OnPageChangeListener mOnPageChangeListener = new ViewPager.OnPageChangeListener() {
        @Override
        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
            if (positionOffsetPixels > 0) {
                IScrollingChild scrolling = mPagerAdapter.getIScrollingChild(position);

                if(scrolling != null) {
                    scrolling.adjustScroll(mHeaderHeight, mHeader.getTranslationY(), mIsHeaderExpanded);
                }
            }
        }

    @Override
    public void onPageSelected(int position) {
        IScrollingChild scrolling = mPagerAdapter.getIScrollingChild(position);

        if(scrolling != null) {
            scrolling.adjustScroll(mHeaderHeight, mHeader.getTranslationY(), mIsHeaderExpanded);
        }
    }

    @Override
        public void onPageScrollStateChanged(int state) {}
    };
}

接著再透過 adjustScroll() 中,使用scrollBy() 將高度調整到目前 Header 的下方位子。

mRecyclerView.scrollBy(0, -scrollY); // 先把 RecyclerView  滑回最上面 (position 0)
mRecyclerView.scrollBy(0, (int) -translationY); // 再滑動到 Header 目前的位子

Question 1

為什麼要用 scrollBy() 分兩次做,不直接 scrollheader 的位子就好?

Answer

因為 RecyclerView 不支援 scrollTo()

Question 2

為什麼要是移動 -translationY

Answer

因為 translationYRecyclerViewscroll 的方向是相反的,所以滑動負的 translationY

Question 3

為什麼 onPageScrolled() 時也要呼叫 adjustScroll()

Answer

如果 onPageScrolled() 沒有呼叫的話,在滑動 ViewPager 時會先看到 Child 在錯誤的位置,然後滑動完觸發 onPageSelected() 時才瞬間跳到正確的位置。

mAoEAIx.gif

下面是 adjustScroll() 完整的程式碼:

public void adjustScroll(float translationY, boolean isHeaderExpanded) {
    int scrollY = mOnScrollListener.getScrollY();

  mRecyclerView.scrollBy(0, -scrollY); // 先把 RecyclerView  滑回最上面 (position 0)
  mRecyclerView.scrollBy(0, (int) -translationY); // 再滑動到 Header 目前的位子    
}

現在切換 Child 時已經可以同步 RecyclerView 了。下圖是到目前為止的效果:

U2O7URv.gif


Part 3 — Scroll behavior of Header and Toolbar

從 Part 2 的完成圖可以看到,目前已經將 Child 與 Header 的滑動行為處理完,但 Toolbar 仍舊原地不動,且 Header 中的 Tab 也沒有卡在畫面的頂端。所以接著就要來處理 HeaderToolbar 的細部滑動行為。

我們要在 Parent 的 onChildScrolled() 中,根據 dy 把滑動分為 Finger swipe upFinger swipe down 這兩種滑動行為。再依據 scrollYheaderTranslationY 來處理 HeaderToolbar 的滑動。

###Finger swipe up

Swipe up 時的行為主要有兩個:
* Tab 會被內容推上去,並卡在畫面最上方
* 當 Tab 往上移動碰到 Toolbar 時,Toolbar 要被 Tab 推上去

uh5YYYE.gif

要實作這些行為,我們需要用到下面幾個變數:

mHeaderTopLimit — Header 最高可以移動的距離。由於 Headery 會從 0 開始往上(負)移動,所以 mHeaderTopLimit 會是負的。

如果希望 Header 能整個被推出畫面外,mHeaderTopLimit 的值就是負的 Header 的高度;而我們希望 Header 中的 Tab 能卡在畫面的最上方,所以這裡的 mHeaderTopLimitHeader 的高度減掉 Tab 的高度的負數。

headertoplimit.png

toolbarPushAnchor — 當 Header 移動多少時,Toolbar 要開始被推上去。由於我們希望 Toolbar 看起來像被 Tab 推上去,所以我們將 toolbarPushAnchor 設定為:

toolbarPushAnchor = mHeaderTopLimit - toolbarHeight;

因為 mHeaderTopLimit 的值已經是 Tab 會卡在畫面最上方的 Position,所以直接在用這個位置減掉 toolbarHeight ,就能拿到 Toolbar 該被推動的 Position

toolbarpushanchor.png


先來處理 Tab 的行為:

Tab 會被內容推上去,並卡在畫面最上方

這裡用 if(headerTranslationY &gt; mHeaderTopLimit) 來判斷 Header 是否已經移動到最上面 (Tab 會卡在畫面最上方的位置) 。

由於 mHeaderTopLimit 是負的,所以 headerTranslationY &gt; mHeaderTopLimit 代表 Header 還沒滑動到最上面,可以進行滑動。

這裡還要使用 Math.max() 來確保移動後的距離不會超過 mHeaderTopLimit

@Override
public void onChildScrolled(ScrollingView view, int pagePosition, int scrollY, int dy) {
    int headerTranslationY = (int) mHeader.getTranslationY();

    if (dy > 0) {
        /**
         * Finger swipe up
         */

        // 如果 Header 還沒移動到最頂端就移動
        if (headerTranslationY > mHeaderTopLimit) {
            int scroll = Math.max(headerTranslationY - dy, mHeaderTopLimit);    
            mHeader.setTranslationY(scroll);
        }       
    }
}

這樣就完成了 Swipe Up 時 Tab 的滑動行為。

QLCKTv5.gif

然後是 Toolbar 的行為:

Tab 往上移動碰到 Toolbar 時,Toolbar 要被 Tab 推上去

這裡要根據剛才的 scroll,判斷是否需要移動 Toolbar。當 scroll 已經移動超過 toolbarPushAnchor 時,就要移動 Toolbar 。與 Tab 移動時一樣, Toolbar 也要使用 Math.max() 來確保不會超過

if(scroll < toolbarPushAnchor) {
    int toolbarTranslationY = (int) mToolbar.getTranslationY();
    int toolbarScroll = Math.max(toolbarTranslationY - dy, -toolbarHeight);
    mToolbar.setTranslationY(toolbarScroll);
}

完成程式碼如下:

@Override
public void onChildScrolled(ScrollingView view, int pagePosition, int scrollY, int dy) {
    int headerTranslationY = (int) mHeader.getTranslationY();

    if (dy > 0) {
        /**
         * Finger swipe up
         */

        // Header(Tab) 還沒移動到最頂端,移動 Header
        if (headerTranslationY > mHeaderTopLimit) {
            int scroll = Math.max(headerTranslationY - dy, mHeaderTopLimit);    
            mHeader.setTranslationY(scroll);

            // 移動超過 toolbar 的 anchor 就移動 Toolbar
            if(scroll < toolbarPushAnchor) {
                int toolbarTranslationY = (int) mToolbar.getTranslationY();
                int toolbarScroll = Math.max(toolbarTranslationY - dy, -toolbarHeight);
                mToolbar.setTranslationY(toolbarScroll);
            }
        }
    }
}

現在已經完成 Swipe Up 時的滑動行為了!

9YJ43HD.gif


###Finger swipe down
Swipe down 時也有兩個行為要處理:
* TabToolbar 要被拉下來,並卡在最上面
* 等內容滑到最頂端時,HeaderTab 要被拉下來,並回到原本的位子

Jpay9mu.gif

讓我們先處理 Toolbar 的部份:

TabToolbar 要被拉下來,並卡在最上面

與 Swipe up 時相似,我們透過 ToolbartoolbarTranslationY 來判斷目前 Toolbar 的狀況。

toolbarTranslationY 等內容滑到最頂端時,HeaderTab` 要被拉下來,並回到原本的位子

這裡要做的是依據 RecyclerViewscrollY 判斷是否已經滑回到 toolbarPushAnchor ,如果已經滑超過,代表已經回到上方,需要拉出 Header

// 由於 toolbarPushAnchor 是負的
// 所以把 scrollY 轉成負數來看
int scroll = -scrollY;

// 如果scroll已經超過toolbar的基準點,就代表已經滑到上面,要把Header拉出來
if (scroll >= toolbarPushAnchor) {
    mHeader.setTranslationY(scroll);
}

完整程式碼如下:

@Override
public void onChildScrolled(ScrollingView view, int pagePosition, int scrollY, int dy){
    int headerTranslationY = (int) mHeader.getTranslationY();

    int toolbarHeight = mToolbar.getHeight();
    int toolbarPushAnchor = mHeaderTopLimit + toolbarHeight;

    if (dy > 0) {

    ...

    } else {
        /**
          * 手指往下滑
          */

        // 移動 Toolbar
        int toolbarTranslationY = (int) mToolbar.getTranslationY();

        // 如果 Toolbar 已經被推上去
        if(toolbarTranslationY < 0) {
            // 把 Toolbar 往下拉,最低不超過 0
            int scroll = Math.min(toolbarTranslationY - dy, 0);
            mToolbar.setTranslationY(scroll);

            int headerTranslationY = (int) mHeader.getTranslationY();

            // Header 跟著現在移動的距離移動
            // 由於這裡只需要把 Toolbar 跟 Tab 往下拉出來,因此最低的位置不應該超過 toolbarPushAnchor
            int headerScroll = Math.min(headerTranslationY - dy, toolbarPushAnchor);
            mHeader.setTranslationY(headerScroll);
        }

        int scroll = -scrollY;

        // 如果scroll已經超過toolbar的基準點,就代表已經滑到上面,要把Header拉出來
        if (scroll >= toolbarPushAnchor) {
            mHeader.setTranslationY(scroll);
        }

    }
}

5a8bGwX.gif

IsPageChanging

現在滑動的行為已經都處理的差不多了,但還需要再做一些小調整。

由於在切換 Child 時,目前的 Child 與目標 Child 都會呼叫 adjustScroll() ,而 adjustScroll() 又會呼叫 onChildScrolled() ,所以為了避免干擾造成 Header 跳動,我們必須在 onChildScrolled() 的最前方加入 mIsPageChanging 判斷,當 mIsPageChangingtrue 時,就不執行後續的程式碼。

mIsPageChanging — 是否正在切換 Child。這個值取自 Parent 的 OnPageChangeListener

@Override
public void onPageScrollStateChanged(int state) {
    mIsPageChanging = state != ViewPager.SCROLL_STATE_IDLE;
}
 @Override
public void onChildScrolled(ScrollingView view, int pagePosition, int scrollY, int dy) {
    if(mIsPageChanging) {
        return;
    }

    ...

}

最後 onChildScrolled() 的完整程式碼如下:

@Override
public void onChildScrolled(ScrollingView view, int pagePosition, int scrollY, int dy){
    if(mIsPageChanging) {
        return;
    }

    int headerTranslationY = (int) mHeader.getTranslationY();

    if (dy > 0) {
        /**
         * Finger swipe up
         */

        // Header(Tab) 還沒移動到最頂端,移動 Header
        if (headerTranslationY > mHeaderTopLimit) {
            int scroll = Math.max(headerTranslationY - dy, mHeaderTopLimit);    
            mHeader.setTranslationY(scroll);

            // 移動超過 toolbar 的 anchor 就移動 Toolbar
            if(scroll < toolbarPushAnchor) {
                int toolbarTranslationY = (int) mToolbar.getTranslationY();
                int toolbarScroll = Math.max(toolbarTranslationY - dy, -toolbarHeight);
                mToolbar.setTranslationY(toolbarScroll);
            }
        }

    } else {
        /**
          * 手指往下滑
          */

        // 移動 Toolbar
        int toolbarTranslationY = (int) mToolbar.getTranslationY();

        // 如果 Toolbar 已經被推上去
        if(toolbarTranslationY < 0) {
            // 把 Toolbar 往下拉,最低不超過 0
            int scroll = Math.min(toolbarTranslationY - dy, 0);
            mToolbar.setTranslationY(scroll);

            int headerTranslationY = (int) mHeader.getTranslationY();

            // Header 跟著現在移動的距離移動
            // 由於這裡只需要把 Toolbar 跟 Tab 往下拉出來,因此最低的位置不應該超過 toolbarPushAnchor
            int headerScroll = Math.min(headerTranslationY - dy, toolbarPushAnchor);
            mHeader.setTranslationY(headerScroll);
        }

        int scroll = -scrollY;

        // 如果scroll已經超過toolbar的基準點,就代表已經滑到上面,要把Header拉出來
        if (scroll >= toolbarPushAnchor) {
            mHeader.setTranslationY(scroll);
        }

    }
}

Wn7Iifv.gif


#Part 4 — Other adjust
最後我們需要在針對一些細節做調整,讓滑動看起來更完美:

視差 (Parallax) 效果

Header 滑動時,我們要幫 Header 的背景圖 mMainImage 做視差 (Parallax) 的效果。

將原本的 mMainImage.setTranslationY() 移到 scrollHeader() 中,並把 mMainImagescroll 的反向移動 2/3 回來。

private void scrollHeader(int scroll) {
    mHeader.setTranslationY(scroll);

    // 視差
    // 因為Image是包在Header裡的,所以Header往上移之後,要往反方向移動2/3回來
    mMainImage.setTranslationY(-(scroll * 2/3));
}

Child 的 View 的調整

由於我們 Child 中有些 View 是固定在原本的 Fragment 上方,套用進這個滑動效果後,我們必須在滑動時讓那些 View 跟在 Header 下方,否則會被 Header 蓋住。

所以在每次 Header 滑動完後,再呼叫 childScrolling.onHeaderTranslation(scroll) 來告訴 Child Header 移動了。

private void scrollHeader(int scroll, IScrollingChild childScrolling) {
    mHeader.setTranslationY(scroll);

    // 視差
    // 因為Image是包在Header裡的,所以Header往上移之後,要往反方向移動2/3回來
    mMainImage.setTranslationY(-(scroll * 2/3));

    if(childScrolling != null) {
        childScrolling.onHeaderTranslation(scroll);
    }
}

之後 Child 再在 onHeaderTranslation() 中實作自己的調整就好了。

@Override
public void onHeaderTranslation(int headerTranslationY{
    setupViewOffset(headerTranslationY);
}

Toolbar 填滿動畫

Toolbar 被往上推動時,Toolbar 旁必須要填滿,否則會有奇怪的空洞。在這我們是在 Tab 上方的位置,放了一個高度是 0 的 View ,在 Toolbar 被推動時,用動畫來將這個 View 長高,就完成這個效果。

0zet7O8.gif

onChildScrolled() 的最後增加下面這段程式碼:

float newHeaderTranslationY = mHeader.getTranslationY();

if(newHeaderTranslationY > toolbarPushAnchor) {
    doHeaderBackgroundAnimation(mIsHeaderExpanded);
}

然後為了避免每次移動時都會重新執行動畫,我們要加入 mIsHeaderExpanded 來判斷是否要執行動畫。

這裡命名為 mIsHeaderExpanded 的原因是,當 Toolbar 被推動時,當我們 Swipe down 時,往下拉出 ToolbarTab ,這時 Toolbar 會被拉回到它原本的位子,但這時填滿還不能取消,要等到回到頂端後, Header 被拉出來才能取消填滿。所以用 Header 是否已經展開來做命名。

float newHeaderTranslationY = mHeader.getTranslationY();

if(newHeaderTranslationY > toolbarPushAnchor) {
    if(!mIsHeaderExpanded) {
        mIsHeaderExpanded = true;
        doHeaderBackgroundAnimation(mIsHeaderExpanded);
    }
} else {
    if(mIsHeaderExpanded) {
        mIsHeaderExpanded = false;
        doHeaderBackgroundAnimation(mIsHeaderExpanded);
    }
}

U0Lhmou.gif


Header 展開時才同步 Child 的滑動

q0dc9zR.gif

目前的滑動行為是,只要切換 Child,RecyclerView 就會因為 adjustScroll() 而被滑到最上方。這其實是有點影響到使用者操作的,因此我們希望當 Header 展開時再進行同步就好。這裡我們要使用剛剛的 mIsHeaderExpanded 來控制。

Parent:

scrolling.adjustScroll(mHeader.getTranslationY(), mIsHeaderExpanded);

Child:

public void adjustScroll(float translationY, boolean isHeaderExpanded) {
        int scrollY = mOnScrollListener.getScrollY();

    if(isHeaderExpanded) {
            mRecyclerView.scrollBy(0, -scrollY); // 先把 RecyclerView  滑回最上面 (position 0)     
            mRecyclerView.scrollBy(0, (int) -translationY); // 再滑動到 Header 目前的位子    
    }
}

但其實這樣會遇到另一個問題:

q4HUW36.gif

由於我們希望在 Header 展開時才同步 Child 的滑動,所以當 Header 已經收合起來,且 Child 的滑動尚未滑到 HeaderHeader 以上的位置時,就會出現這個問題。因此我們必須把判斷式改成下面這樣:

if(isHeaderExpanded || scrollY < Math.abs(translationY))

Header 是展開時,或是 Child 的滑動尚未滑到 HeaderHeader 以上的位置時進行同步。以下是完整程式碼:

public void adjustScroll(float translationY, boolean isHeaderExpanded) {
  int scrollY = mOnScrollListener.getScrollY();

  if(isHeaderExpanded || scrollY < Math.abs(translationY)) {
    mRecyclerView.scrollBy(0, -scrollY); // 先把 RecyclerView  滑回最上面 (position 0)     
    mRecyclerView.scrollBy(0, (int) -translationY); // 再滑動到 Header 目前的位子    
  }
}

這樣就完成了,讓我們來看看最終的效果!

M8QK1X1.gif

Conclusion

最後這個方法終於繞過 Fling 的問題,順利完成類似 GooglePlay 的滑動效果。有任何問題或建議歡迎提出一起討論!

搶攻行動商機,現在就加入 7,000 家已在網路開店的品牌行列!
分享至:
91APP Android team 雜魚工程師

掌握最新電商脈動,加入 91APP 品牌全通路學院!

免費獲得最新市場趨勢、行銷技巧與資源,直接送達您的信箱。

完全免費,可隨時取消。
搶攻行動商機,現在就加入 7,000 家已在網路開店的品牌行列!