手把手講解ViewPager翻頁特效:向源碼學習!

前言

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

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

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

正文大綱

  • 參考效果
  • 前置技能
  • 實現思路
  • 關鍵代碼
  • 思維拓展

正文


參考效果

手把手講解ViewPager翻頁特效:向源碼學習!

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

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


前置技能

經過對 ViewPager可能特效的研究,發現它自身就帶有這種動畫特效的可能性,不用我們去自定義控件。

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

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

  • android 視圖動畫 android體系中比較原始的一種動畫類型。原理,是將view的繪製過程指定區域,按照指定規則再進行一遍,但是原本view所攜帶的事件交互,則不受影響。由於無法真正地繼承事件交互,所以被屬性動畫所取代。但是它仍然有自己的價值。在不涉及到交互,只考慮視覺效果的情況下,它的效率反而比屬性動畫更高。
  • 數學建模思想 不要誤會,這裡說的數學建模是一種思維方式,把我們肉眼看到的現象,用數學公式的形式表達出來而已,並不是什麼高深的操作。學過自定義控件並且深入實踐過的童鞋應該能夠體會到,要想真正從0開始完成一個 DIY控件,會有大量的數學計算,而擁有好的數學思維能力,能夠在自定義的時候如魚得水。

實現思路

一、源碼研究

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

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

<code>
1. `protected void onLayout(boolean changed, int l, int t, int r, int b) {`

2. `final int count = getChildCount();`

3. `...`

4. `for (int i = 0; i < count; i++) {`

5. `if (child.getVisibility() != GONE) {`

6. `final LayoutParams lp = (LayoutParams) child.getLayoutParams();`

7. `int childLeft = 0;`

8. `int childTop = 0;`

9. `if (lp.isDecor) {`

10. `...`

11. `}`

12. `}`

13. `}`

14. `...`

15. `for (int i = 0; i < count; i++) {`

16. `final View child = getChildAt(i);`

17. `if (child.getVisibility() != GONE) {`

18. `final LayoutParams lp = (LayoutParams) child.getLayoutParams();`

19. `ItemInfo ii;`

20. `if (!lp.isDecor && (ii = infoForChild(child)) != null) {`

21. `int loff = (int) (childWidth * ii.offset);`

22. `int childLeft = paddingLeft + loff;`

23. `int childTop = paddingTop;`

24. `if (lp.needsMeasure) {`

25. `// This was added during layout and needs measurement.`

26. `// Do it now that we know what we're working with.`

27. `lp.needsMeasure = false;`

28. `final int widthSpec = MeasureSpec.makeMeasureSpec(`

29. `(int) (childWidth * lp.widthFactor),`

30. `MeasureSpec.EXACTLY);`

31. `final int heightSpec = MeasureSpec.makeMeasureSpec(`

32. `(int) (height - paddingTop - paddingBottom),`

33. `MeasureSpec.EXACTLY);`

34. `child.measure(widthSpec, heightSpec);`

35. `}`

36. `if (DEBUG) {`

37. `Log.v(TAG, "Positioning #" + i + " " + child + " f=" + ii.object`

38. `+ ":" + childLeft + "," + childTop + " " + child.getMeasuredWidth()`

39. `+ "x" + child.getMeasuredHeight());`

40. `}`

41. `child.layout(childLeft, childTop,`

42. `childLeft + child.getMeasuredWidth(),`

43. `childTop + child.getMeasuredHeight());`

44. `}`

45. `}`

46. `}`

47. `}`

/<code>

上面代碼中,對count進行了兩輪循環,其中第一輪是針對lp.isDecor為true的,意為:如果當前view是一個decoration裝飾,並不是adapter提供的view則返回true。

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

而在下面的循環中,可以看到:

<code>1.  `child.layout(childLeft, childTop,`

2. `childLeft + child.getMeasuredWidth(),`

3. `childTop + child.getMeasuredHeight());`

/<code>

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

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

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

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

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

<code>1.  `case  MotionEvent.ACTION_MOVE:`

2. `if (!mIsBeingDragged) {`

3. `final int pointerIndex = ev.findPointerIndex(mActivePointerId);`

4. `if (pointerIndex == -1) {`

5. `// A child has consumed some touch events and put us into an inconsistent`

6. `// state.`

7. `needsInvalidate = resetTouch();`

8. `break;`

9. `}`

10. `final float x = ev.getX(pointerIndex);`

11. `final float xDiff = Math.abs(x - mLastMotionX);`

12. `final float y = ev.getY(pointerIndex);`

13. `final float yDiff = Math.abs(y - mLastMotionY);`

14. `if (DEBUG) {`

15. `Log.v(TAG, "Moved x to " + x + "," + y + " diff=" + xDiff + "," + yDiff);`

16. `}`

17. `if (xDiff > mTouchSlop && xDiff > yDiff) {`

18. `if (DEBUG) Log.v(TAG, "Starting drag!");`

19. `mIsBeingDragged = true;`

20. `requestParentDisallowInterceptTouchEvent(true);`

21. `mLastMotionX = x - mInitialMotionX > 0 ? mInitialMotionX + mTouchSlop :`

22. `mInitialMotionX - mTouchSlop;`

23. `mLastMotionY = y;`

24. `setScrollState(SCROLL_STATE_DRAGGING);`

25. `setScrollingCacheEnabled(true);`

27. `// Disallow Parent Intercept, just in case`

28. `ViewParent parent = getParent();`

29. `if (parent != null) {`

30. `parent.requestDisallowInterceptTouchEvent(true);`

31. `}`

32. `}`

33. `}`

34. `// Not else! Note that mIsBeingDragged can be set above.`

35. `if (mIsBeingDragged) {`

36. `// Scroll to follow the motion event`

37. `final int activePointerIndex = ev.findPointerIndex(mActivePointerId);`

38. `final float x = ev.getX(activePointerIndex);`

39. `needsInvalidate |= performDrag(x);`

40. `}`

41. `break;`

/<code>

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

<code>
1. `private boolean performDrag(float x) {`

2. `boolean needsInvalidate = false;`

4. `final float deltaX = mLastMotionX - x;`

5. `mLastMotionX = x;`

7. `...`

8. `// Don't lose the rounded component`

9. `mLastMotionX += scrollX - (int) scrollX;`

10. `scrollTo((int) scrollX, getScrollY()); // 關鍵代碼1, 控件在畫布上的橫像滾動`

11. `pageScrolled((int) scrollX);// 關鍵代碼2,將 scrollX進一步往下傳遞`


13. `return needsInvalidate;`

14. `}`

/<code>

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

<code>
1. `private boolean pageScrolled(int xpos) {`

2. `...`

3. `final float pageOffset = (((float) xpos / width) - ii.offset)`

4. `/ (ii.widthFactor + marginOffset);`

5. `final int offsetPixels = (int) (pageOffset * widthWithMargin);`

7. `mCalledSuper = false;`

8. `onPageScrolled(currentPage, pageOffset, offsetPixels);`

9. `if (!mCalledSuper) {`

10. `throw new IllegalStateException(`

11. `"onPageScrolled did not call superclass implementation");`

12. `}`

13. `return true;`

14. `}`

/<code>

追蹤參數 xpos得知,x方向上的偏移量信息,最後進入了onPageScrolled(...)方法。

<code>
1. `protected void onPageScrolled(int position, float offset, int offsetPixels) {`


2. `// Offset any decor views if needed - keep them on-screen at all times.`

3. `if (mDecorChildCount > 0) {`

4. `... // 這裡還是在處理 裝飾,所以不用看,而且參數也沒進入到這裡`

5. `}`

7. `dispatchOnPageScrolled(position, offset, offsetPixels);`

9. `if (mPageTransformer != null) {`

10. `final int scrollX = getScrollX();`

11. `final int childCount = getChildCount();`

12. `for (int i = 0; i < childCount; i++) {`

13. `final View child = getChildAt(i);`

14. `final LayoutParams lp = (LayoutParams) child.getLayoutParams();`

16. `if (lp.isDecor) continue;`

17. `final float transformPos = (float) (child.getLeft() - scrollX) / getClientWidth();`

18. `mPageTransformer.transformPage(child, transformPos);`

19. `}`

20. `}`

22. `mCalledSuper = true;`

23. `}`

/<code>

又是兩句關鍵代碼:

dispatchOnPageScrolled(position,offset,offsetPixels);

點進去看了之後,發現只是調用了OnPageChangeListener監聽回調。

如果我們設置了滑動監聽,就可以在滑動的時候,收到回調。相信大家都用過這個。

mPageTransformer.transformPage(child,transformPos);

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

那麼外界拿到這兩個參數值之後可以做什麼事呢?理論上,可以做任何事。

二、探索源碼結論

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

三、PageTransformer參數規律探索

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

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

手把手講解ViewPager翻頁特效:向源碼學習!

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

<code>
1. `viewPager.adapter = MyFragmentPagerAdapter(supportFragmentManager);`

2. `viewPager.offscreenPageLimit = 3 // 最少緩存3個,讓左右兩邊都顯示出來`

3. `viewPager.setPageTransformer(true, ViewPager.PageTransformer { view, position ->`

4. `Log.d("setPageTransformer", "view:${view.hashCode()} | position:${position}")`

5. `})`

/<code>

然後啟動app,看看日誌:

<code>
1. `03-1214:14:46.2221583-1583/? D/setPageTransformer: view:136851691| position:0.0`

2. `03-1214:14:46.2221583-1583/? D/setPageTransformer: view:147234376| position:1.0`

3. `03-1214:14:46.2221583-1583/? D/setPageTransformer: view:75203809| position:2.0`

4. `03-1214:14:46.2221583-1583/? D/setPageTransformer: view:35279366| position:3.0`

/<code>

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

向左滑動一格。

日誌節略如下:

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

<code>
1. `03-1214:22:11.8361583-1583/? D/setPageTransformer: view:136851691| position:-1.0`

/<code>

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

<code>
1. `03-1214:22:11.8361583-1583/? D/setPageTransformer: view:147234376| position:0.0`

/<code>

再試試向又滑動一格,hashCode為 136851691 的子view,從 -0.99326146 變成了0.0 ,這裡的小數大概是由於計算精度丟失造成的。可以認為是 從 -1.0 變為了 0.0 。

畫圖描述剛才的結論(粉色是當前視野):

手把手講解ViewPager翻頁特效:向源碼學習!

OK,瞭解到這裡,position的變化規律基本也掌握了,那麼接下來可以進行動畫拆分編程實現。下篇放送關鍵代碼。


分享到:


相關文章: