前言
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
正文大綱
- 參考效果
- 前置技能
- 實現思路
- 關鍵代碼
- 思維拓展
正文
參考效果
上圖 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當前的位置信息返回到了外界。
那麼外界拿到這兩個參數值之後可以做什麼事呢?理論上,可以做任何事。
二、探索源碼結論
- ViewPager的初始子view擺放,都是橫向的。在縱向上是上下平齊。
- ViewPager將子view以及子view的當前位置參數,通過PageTransformer.transformPage(view,position)反饋到外界,能做很多。比如說,讓橫著排放的子view變成豎著放,又或者 讓即將滑出屏幕的子view以傾斜的角度以某個加速度飛出去,為所欲為。這個就是我們可以完成這個動畫的基礎。
三、PageTransformer參數規律探索
ViewPager 提供了一個DIY滑動特效的可能性。不過在動手做動畫之前,還需要了解這兩個參數的變化規律。
新建一個Android工程,寫好 ViewPager+TabLayout 的代碼和佈局。運行起來大概是這個效果:
同時,我們給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 。
畫圖描述剛才的結論(粉色是當前視野):
OK,瞭解到這裡,position的變化規律基本也掌握了,那麼接下來可以進行動畫拆分編程實現。下篇放送關鍵代碼。
閱讀更多 享學課堂online 的文章