手把手講解 ViewPager翻頁特效

前言

2020年后第一篇,來點輕松的話題吧。在家辦公,UI美眉心血來潮要搞一個滑動特效。 ViewPager+TabLayout ,老生常談的東西了。ViewPager 是基礎(chǔ)的滑動切換控件,TabLayout 是 和ViewPager配合使用的 標(biāo)題欄部分(但是TabLayout也可以脫離ViewPager 獨立使用). 根據(jù)查到的資料顯示,谷歌工程師在ViewPager創(chuàng)立之時,就給 風(fēng)騷的動畫特效預(yù)留了接口,我們可以很方便地去使用這個接口進(jìn)行動畫編程,但是TabLayout就比較悲情,不但動畫沒預(yù)留接口,甚至一些常規(guī)操作的接口都沒有提供,所以網(wǎng)上也出現(xiàn)了一些人按照 原TabLayout的代碼,自己去創(chuàng)造新的xxTabLayout控件。

本文將提供ViewPager+TabLayout實例效果開發(fā)思路 ,以及Demo github工程. 有興趣的童鞋們希望可以留言多多交流。

Demo地址:https://github.com/18598925736/StudyTabLayout

正文大綱

  • 參考效果
  • 前置技能
  • 實現(xiàn)思路
  • 關(guān)鍵代碼
  • 思維拓展

正文


參考效果

特效.gif

上圖 UI 美眉給的手機(jī)錄屏,是螞蟻財富app某一個版本上的滑動切換效果.

我們需要開發(fā)的是 下面這一半 這個滑動切換的控件


前置技能

經(jīng)過對ViewPager可能特效的研究,發(fā)現(xiàn)它自身就帶有這種動畫特效的可能性,不用我們?nèi)プ远x控件。

但是上方的TabLayout字體大小變化,指示器indicator的長度和位置變化,谷歌給的TabLayout貌似沒法弄,所以只能自己DIY了.

要完成這個特效,兩個技能必須就位:

  • android 視圖動畫
    android體系中比較原始的一種動畫類型。原理,是將view的繪制過程指定區(qū)域,按照指定規(guī)則再進(jìn)行一遍,但是原本view所攜帶的事件交互,則不受影響。由于無法真正地繼承事件交互,所以被屬性動畫所取代。但是它仍然有自己的價值。在不涉及到交互,只考慮視覺效果的情況下,它的效率反而比屬性動畫更高。

  • 數(shù)學(xué)建模思想

    不要誤會,這里說的數(shù)學(xué)建模是一種思維方式,把我們?nèi)庋劭吹降默F(xiàn)象,用數(shù)學(xué)公式的形式表達(dá)出來而已,并不是什么高深的操作。學(xué)過自定義控件并且 深入實踐過的童鞋應(yīng)該能夠體會到,要想真正從0開始完成一個DIY控件,會有大量的數(shù)學(xué)計算,而擁有好的數(shù)學(xué)思維能力,能夠在自定義的時候如魚得水。

實現(xiàn)思路

一 ,源碼研究

要對ViewPager進(jìn)行特效改造,那么首先我們要知道ViewPager是一個容器ViewGroup,它內(nèi)部的子View是如何擺放的,雖然從視覺上我們能夠感覺到 子view是橫向擺放的,但是作為技術(shù)人,就要敢于追根究底,用源碼說話。

進(jìn)入源碼,找到 onLayout方法(以下是我提煉的關(guān)鍵代碼):

protected void onLayout(boolean changed, int l, int t, int r, int b) {
    final int count = getChildCount();
    ...
     for (int i = 0; i < count; i++) {
        if (child.getVisibility() != GONE) {
              final LayoutParams lp = (LayoutParams) child.getLayoutParams();
              int childLeft = 0;
              int childTop = 0;
              if (lp.isDecor) {
                    ...
              }
          }
      }
    ...
    for (int i = 0; i < count; i++) {
            final View child = getChildAt(i);
            if (child.getVisibility() != GONE) {
                final LayoutParams lp = (LayoutParams) child.getLayoutParams();
                ItemInfo ii;
                if (!lp.isDecor && (ii = infoForChild(child)) != null) {
                    int loff = (int) (childWidth * ii.offset);
                    int childLeft = paddingLeft + loff;
                    int childTop = paddingTop;
                    if (lp.needsMeasure) {
                        // This was added during layout and needs measurement.
                        // Do it now that we know what we're working with.
                        lp.needsMeasure = false;
                        final int widthSpec = MeasureSpec.makeMeasureSpec(
                                (int) (childWidth * lp.widthFactor),
                                MeasureSpec.EXACTLY);
                        final int heightSpec = MeasureSpec.makeMeasureSpec(
                                (int) (height - paddingTop - paddingBottom),
                                MeasureSpec.EXACTLY);
                        child.measure(widthSpec, heightSpec);
                    }
                    if (DEBUG) {
                        Log.v(TAG, "Positioning #" + i + " " + child + " f=" + ii.object
                                + ":" + childLeft + "," + childTop + " " + child.getMeasuredWidth()
                                + "x" + child.getMeasuredHeight());
                    }
                    child.layout(childLeft, childTop,
                            childLeft + child.getMeasuredWidth(),
                            childTop + child.getMeasuredHeight());
                }
            }
        }
}

上面代碼中,對 count 進(jìn)行了兩輪循環(huán),其中第一輪是針對 lp.isDecortrue的,

意為:如果當(dāng)前view是一個 decoration 裝飾,并不是adapter提供的view 則返回 true

顯然,我們要探討的是 adapter提供的View 是如何擺放的,所以忽略這一塊。

而在下面的循環(huán)中,可以看到

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

這個便是child的排布的核心代碼,追溯這4個參數(shù),可以得知:第 1,3 參數(shù) 表示 left ,right , 他們都和一個 int loff = (int) (childWidth * ii.offset); 掛鉤,而 第2,4 參數(shù)表示 top,bottom , 則 并沒有與 任何動態(tài)參數(shù)相掛鉤。

因此可以斷定,ViewPager的子View排布,只會存在X軸方向上的位置偏差,在Y方向上會保持上下平齊。

其實還可以繼續(xù)追溯 int loff = (int) (childWidth * ii.offset); 看看 x軸方向上的位置偏差是如何造成的,但是目的已經(jīng)達(dá)到,到有必要的時候再去追查。

確定是橫向排布,那么左右滑動邏輯又是怎么樣的呢?

找到 onTouchEvent() 方法, 并且在其中找到 ACTION_MOVE 邏輯分支:

case MotionEvent.ACTION_MOVE:
                if (!mIsBeingDragged) {
                    final int pointerIndex = ev.findPointerIndex(mActivePointerId);
                    if (pointerIndex == -1) {
                        // A child has consumed some touch events and put us into an inconsistent
                        // state.
                        needsInvalidate = resetTouch();
                        break;
                    }
                    final float x = ev.getX(pointerIndex);
                    final float xDiff = Math.abs(x - mLastMotionX);
                    final float y = ev.getY(pointerIndex);
                    final float yDiff = Math.abs(y - mLastMotionY);
                    if (DEBUG) {
                        Log.v(TAG, "Moved x to " + x + "," + y + " diff=" + xDiff + "," + yDiff);
                    }
                    if (xDiff > mTouchSlop && xDiff > yDiff) {
                        if (DEBUG) Log.v(TAG, "Starting drag!");
                        mIsBeingDragged = true;
                        requestParentDisallowInterceptTouchEvent(true);
                        mLastMotionX = x - mInitialMotionX > 0 ? mInitialMotionX + mTouchSlop :
                                mInitialMotionX - mTouchSlop;
                        mLastMotionY = y;
                        setScrollState(SCROLL_STATE_DRAGGING);
                        setScrollingCacheEnabled(true);

                        // Disallow Parent Intercept, just in case
                        ViewParent parent = getParent();
                        if (parent != null) {
                            parent.requestDisallowInterceptTouchEvent(true);
                        }
                    }
                }
                // Not else! Note that mIsBeingDragged can be set above.
                if (mIsBeingDragged) {
                    // Scroll to follow the motion event
                    final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
                    final float x = ev.getX(activePointerIndex);
                    needsInvalidate |= performDrag(x);
                }
                break;

我們需要關(guān)注的只是 X方向上的拖拽有什么規(guī)律. 所以,順著final float x = ev.getX(pointerIndex); 這個變量去找關(guān)鍵方法, 最終鎖定:performDrag(x); 它是處理X方向上位移的關(guān)鍵入口。

private boolean performDrag(float x) {
        boolean needsInvalidate = false;

        final float deltaX = mLastMotionX - x;
        mLastMotionX = x;

        ...
        // Don't lose the rounded component
        mLastMotionX += scrollX - (int) scrollX;
        scrollTo((int) scrollX, getScrollY()); // 關(guān)鍵代碼1, 控件在畫布上的橫像滾動
        pageScrolled((int) scrollX);// 關(guān)鍵代碼2,將 scrollX進(jìn)一步往下傳遞

        return needsInvalidate;
    }

發(fā)現(xiàn)兩句關(guān)鍵代碼,一個是處理滑動的 scrolllTo,一個是把scrollX往下傳遞的 pageScrolled(scrollX). 前面一句都明白,但是這個第二句就有點不懂了,繼續(xù)深入。

private boolean pageScrolled(int xpos) {
        ...
        final float pageOffset = (((float) xpos / width) - ii.offset)
                / (ii.widthFactor + marginOffset);
        final int offsetPixels = (int) (pageOffset * widthWithMargin);

        mCalledSuper = false;
        onPageScrolled(currentPage, pageOffset, offsetPixels);
        if (!mCalledSuper) {
            throw new IllegalStateException(
                    "onPageScrolled did not call superclass implementation");
        }
        return true;
    }

追蹤 參數(shù)xpos得知,x方向上的偏移量信息,最后進(jìn)入了 onPageScrolled(...) 方法.

    protected void onPageScrolled(int position, float offset, int offsetPixels) {
        // Offset any decor views if needed - keep them on-screen at all times.
        if (mDecorChildCount > 0) {
            ... // 這里還是在處理 裝飾,所以不用看,而且參數(shù)也沒進(jìn)入到這里
        }

        dispatchOnPageScrolled(position, offset, offsetPixels);

        if (mPageTransformer != null) {
            final int scrollX = getScrollX();
            final int childCount = getChildCount();
            for (int i = 0; i < childCount; i++) {
                final View child = getChildAt(i);
                final LayoutParams lp = (LayoutParams) child.getLayoutParams();

                if (lp.isDecor) continue;
                final float transformPos = (float) (child.getLeft() - scrollX) / getClientWidth();
                mPageTransformer.transformPage(child, transformPos);
            }
        }

        mCalledSuper = true;
    }

又是兩句關(guān)鍵代碼:

dispatchOnPageScrolled(position, offset, offsetPixels);

點進(jìn)去看了之后,發(fā)現(xiàn)只是 調(diào)用了 OnPageChangeListener 監(jiān)聽回調(diào).

如果我們設(shè)置了滑動監(jiān)聽,就可以在滑動的時候,收到回調(diào)。相信大家都用過這個。

mPageTransformer.transformPage(child, transformPos);

這里就比較奇怪了。這句代碼把子view,以及子view當(dāng)前的位置信息返回到了外界。

那么外界拿到這兩個參數(shù)值之后可以做什么事呢?理論上,可以做任何事

二,探索源碼結(jié)論

  1. ViewPager的初始子view擺放,都是橫向的。在縱向上是上下平齊。

  2. ViewPager將 子view以及子view的當(dāng)前位置參數(shù),通過PageTransformer.transformPage(view,position)反饋到外界,能做很多。比如說,讓橫著排放的子view變成豎著放,又或者 讓即將滑出屏幕的子view以傾斜的角度以某個加速度飛出去,為所欲為。這個就是我們可以完成這個動畫的基礎(chǔ)。

三,PageTransformer參數(shù)規(guī)律探索

ViewPager 提供了一個DIY滑動特效的可能性。不過在動手做動畫之前,還需要了解 這兩個參數(shù)的變化規(guī)律。

新建一個android工程,寫好ViewPager+TabLayout 的代碼和布局。運行起來大概是這個效果:

一般滑動效果.gif

同時,我們給viewpager加上setPageTransformer(...)方法,并且打印日志。

viewPager.adapter = MyFragmentPagerAdapter(supportFragmentManager);
viewPager.offscreenPageLimit = 3 // 最少緩存3個,讓左右兩邊都顯示出來        
viewPager.setPageTransformer(true, ViewPager.PageTransformer { view, position ->
    Log.d("setPageTransformer", "view:${view.hashCode()} | position:${position}")
})

然后啟動app,看看日志:

03-12 14:14:46.222 1583-1583/? D/setPageTransformer: view:136851691 | position:0.0
03-12 14:14:46.222 1583-1583/? D/setPageTransformer: view:147234376 | position:1.0
03-12 14:14:46.222 1583-1583/? D/setPageTransformer: view:75203809 | position:2.0
03-12 14:14:46.222 1583-1583/? D/setPageTransformer: view:35279366 | position:3.0

可以看到,在一開始,有4個子view被初始化,位置信息分別是 0.0 / 1.0 / 2.0 / 3.0 . 這是由于我設(shè)置了offscreenPageLimit 為3 ,所以除了當(dāng)前view之外,還會初始化3個屏幕之外的view 。這就意味著:當(dāng)前view的position是0,而往右邊,position會遞增,每遞增1個view,就會加1.0, 反過來,我們也可以推導(dǎo),往左邊,每過一個view,position會遞減. 為了驗證我們的推導(dǎo),我們滑動一下,觀察position的變化.

向左滑動一格。

日志節(jié)略如下:

hashCode為 136851691 的子view,它的position從 原本的0.0,,最終變成了 -1.0

03-12 14:22:11.836 1583-1583/? D/setPageTransformer: view:136851691 | position:-1.0

而,原本hashCode為147234376,position為1的子view,position則變成了 0.0

03-12 14:22:11.836 1583-1583/? D/setPageTransformer: view:147234376 | position:0.0

再試試向又滑動一格,hashCode為 136851691 的子view, 從 -0.99326146 變成了0.0 , 這里的小數(shù)大概是由于計算精度丟失造成的。可以認(rèn)為是 從-1.0 變?yōu)榱?code>0.0 .

畫圖描述剛才的結(jié)論(粉色是當(dāng)前視野):

滑動position變化.png

OK,了解到這里,position的變化規(guī)律基本也掌握了,那么接下來可以進(jìn)行動畫拆分 編程實現(xiàn).

關(guān)鍵代碼

有了思路,那么IT民工現(xiàn)在開始搬磚。

一,動畫拆分各個擊破

  1. 子view重疊排布

原本的子view都是橫向,從左到又排布,默認(rèn)的排布方式并沒有相互覆蓋. 所以我們可以考慮使用視圖動畫

? 為什么是視圖動畫,而不是屬性動畫?因為沒必要,當(dāng)前的需求我只需要視覺效果上的位置變化,不需要子view的交互事件,用屬性動畫理論上應(yīng)該也可以,但是直覺會存在交互問題,有時間再試試).

使用視圖動畫,將所有子view層疊在一起。原本都是橫向排布,所以只需要將所有的view進(jìn)行x軸位移,即可。

上代碼:

代碼1.png

公式的推導(dǎo)很簡單,就是讓右邊的子view向左平移 -position個自身寬度.

效果為:

改造1.gif

滑動之后,不再出現(xiàn)其他子view。

  1. 讓多個子view之間呈現(xiàn)x軸上的位置差

雖然重疊在了一起,但是我還需要讓右邊的子view呈現(xiàn)位置偏差. 并且,越往右,偏差越大。

上代碼:

代碼2.png

效果:

改造2.png
  1. 讓多個子view之間呈現(xiàn)縮放差

x軸上的位置差雖然有了,但是,原圖上,越往右,越小,所以還需要做出x,y方向上的縮放

上代碼:

代碼3.png

效果:

改造3.png
  1. 監(jiān)聽滑動position,做出透明度逐漸變化

視覺效果都有了,那么可以開始做動畫效果.

經(jīng)過對position的觀察,我們知道position會以小數(shù)的形式漸變。原圖中,向左滑出的view,會以一個透明度慢慢減小的方式消失,那么先來完成這一步。


代碼4.png

效果:

改造4.gif
  1. 監(jiān)聽滑動position,做出左滑時 當(dāng)前view的平移動畫

最后一步,滑出消失的view雖然透明度的動畫完成了,但是原圖中,還有一個漸漸向左移動的動畫。

上代碼:

代碼5.png

效果:

改造5.gif

最終效果和原圖差不多。

二,聲明幾個坑

如果有人按照我的思路去實現(xiàn)上面的效果,很有可能失敗,因為其中幾個坑。

  1. ViewGroup.clipChildren 屬性

    任何一個ViewGroup的子類都具備的屬性,它的作用是,決定是否消減掉 子view超出自身繪制范圍的部分

    意思就是說,子view的繪制范圍其實是無限大的,但是它能顯示的范圍由父viewGroup決定,這個屬性為true,父view不允許子view超出自身的部分顯示出來,反之,則是允許超出。這個屬性默認(rèn)是true。所以,如果發(fā)現(xiàn) 上述效果中某些部分顯示不出來,就要看看ViewPager(它是一個ViewGroup)的clipChildren屬性是否為true,如果是true,設(shè)置成false試試。如果還是不行,看看ViewPager的父容器 的 clipChildren屬性是否為false。以此類推。

  2. ViewPager.setPageTranformer(boolean reverseDrawingOrder,PageTransformer transformer)方法有兩個參數(shù),第一個是 bool值,它能決定子view的繪制順序。如果按照上述思路實現(xiàn)效果發(fā)現(xiàn),是右邊的子view覆蓋了左邊的子view,那么就要看看是不是這個值是不是true。如果不是ture,改成true再嘗試。

  3. 第2點中,如果不想把 reverseDrawingOrder 設(shè)置為true,也有辦法解決。android View體系中存在一個z軸概念,z值越大,就越在上層,其實,也可以使用改變子viewz屬性的辦法來解決覆蓋效果錯誤的問題。(但是Z軸的設(shè)置與版本有關(guān),要區(qū)分設(shè)備版本,不然低版本上可能程序崩潰)

思維拓展

還記得前文講過的么,拿到了View之后,再根據(jù)滑動時的參數(shù)變化,我們幾乎可以對它為所欲為,那么我們能做的,就不僅僅是 本次的目標(biāo)效果,像是類似這種滑動特效,還有很多風(fēng)騷的操作可以玩。像是:

額外效果1.gif
額外效果2.gif

沒有做不到,只有想不到,想到之后,最終能做成什么效果,就要看自己的數(shù)學(xué)造詣夠不夠高了。

這次的研究,最大的收獲,并不是 知道了pageTransformer這個接口,而是 一種解耦的編程思維,比如我們希望給一個View控件加特效,可以直接在,原本View控件里面去修改代碼,重寫onTouchEvent來響應(yīng)滑動事件,或者重寫draw/onDraw 進(jìn)行另外的繪制,用這種方法,無論怎么做,都已經(jīng)在對原View進(jìn)行侵入式的變動,這種方法不到萬不得已,不想用,因為一不小心改出連鎖反應(yīng)的bug,導(dǎo)致原來的某些特性都受到影響,得不償失。

但是谷歌ViewPager提供了一個另外的思路,將內(nèi)部View對象,以及view的相關(guān)參數(shù)通過接口的形式開放給外界,讓編程者可以不再需要關(guān)心原本View的內(nèi)部實現(xiàn),而直接專心做自己的特效,符合編程的開閉法則,即保證了原代碼的安全,又讓新的特效代碼與原View代碼沒有直接關(guān)聯(lián)。這是一種優(yōu)雅,安全又高效的編程方式!


結(jié)語

至此,ViewPager部分結(jié)束。做出這個效果的基本思路和詳細(xì)過程都已經(jīng)呈上。

再次給出Demo地址:https://github.com/18598925736/StudyTabLayout/tree/hank_v1

此Demo不僅僅是針對 ViewPager的滑動特效,還包含了TabLayout 呈現(xiàn)效果的完全自定義。至于TabLayout如何隨心所欲的操縱,下一篇文章將會詳解。先預(yù)告一個最終效果圖, 文章會盡快出爐。

TabLayout+ViewPager特效.gif
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容