前言
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)鍵代碼
- 思維拓展
正文
參考效果
上圖 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.isDecor
為 true
的,
意為:如果當(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é)論
ViewPager的初始子view擺放,都是橫向的。在縱向上是上下平齊。
ViewPager將 子view以及子view的當(dāng)前位置參數(shù),通過
PageTransformer.transformPage(view,position)
反饋到外界,能做很多。比如說,讓橫著排放的子view變成豎著放,又或者 讓即將滑出屏幕的子view以傾斜的角度以某個加速度飛出去,為所欲為。這個就是我們可以完成這個動畫的基礎(chǔ)。
三,PageTransformer參數(shù)規(guī)律探索
ViewPager 提供了一個DIY滑動特效的可能性。不過在動手做動畫之前,還需要了解 這兩個參數(shù)的變化規(guī)律。
新建一個android工程,寫好ViewPager+TabLayout
的代碼和布局。運行起來大概是這個效果:
同時,我們給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)前視野):
OK,了解到這里,position的變化規(guī)律基本也掌握了,那么接下來可以進(jìn)行動畫拆分 編程實現(xiàn).
關(guān)鍵代碼
有了思路,那么IT民工現(xiàn)在開始搬磚。
一,動畫拆分各個擊破
- 子view重疊排布
原本的子view都是橫向,從左到又排布,默認(rèn)的排布方式并沒有相互覆蓋. 所以我們可以考慮使用視圖動畫
(? 為什么是視圖動畫,而不是屬性動畫?因為沒必要,當(dāng)前的需求我只需要視覺效果上的位置變化,不需要子view的交互事件,用屬性動畫理論上應(yīng)該也可以,但是直覺會存在交互問題,有時間再試試).
使用視圖動畫,將所有子view層疊在一起。原本都是橫向排布,所以只需要將所有的view進(jìn)行x軸位移,即可。
上代碼:
代碼1.png
公式的推導(dǎo)很簡單,就是讓右邊的子view向左平移 -position個自身寬度.
效果為:
滑動之后,不再出現(xiàn)其他子view。
- 讓多個子view之間呈現(xiàn)x軸上的位置差
雖然重疊在了一起,但是我還需要讓右邊的子view呈現(xiàn)位置偏差. 并且,越往右,偏差越大。
上代碼:
代碼2.png
效果:
改造2.png
- 讓多個子view之間呈現(xiàn)縮放差
x軸上的位置差雖然有了,但是,原圖上,越往右,越小,所以還需要做出x,y方向上的縮放
上代碼:
代碼3.png
效果:
改造3.png
- 監(jiān)聽滑動position,做出透明度逐漸變化
視覺效果都有了,那么可以開始做動畫效果.
經(jīng)過對position的觀察,我們知道position會以小數(shù)的形式漸變。原圖中,向左滑出的view,會以一個透明度慢慢減小的方式消失,那么先來完成這一步。
代碼4.png
效果:
改造4.gif
- 監(jiān)聽滑動position,做出左滑時 當(dāng)前view的平移動畫
最后一步,滑出消失的view雖然透明度的動畫完成了,但是原圖中,還有一個漸漸向左移動的動畫。
上代碼:
代碼5.png
效果:
改造5.gif
最終效果和原圖差不多。
二,聲明幾個坑
如果有人按照我的思路去實現(xiàn)上面的效果,很有可能失敗,因為其中幾個坑。
-
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
。以此類推。 ViewPager.setPageTranformer(boolean reverseDrawingOrder,PageTransformer transformer)
方法有兩個參數(shù),第一個是 bool值,它能決定子view的繪制順序。如果按照上述思路實現(xiàn)效果發(fā)現(xiàn),是右邊的子view覆蓋了左邊的子view,那么就要看看是不是這個值是不是true。如果不是ture,改成true再嘗試。第2點中,如果不想把
reverseDrawingOrder
設(shè)置為true
,也有辦法解決。android View
體系中存在一個z軸
概念,z
值越大,就越在上層,其實,也可以使用改變子view
,z
屬性的辦法來解決覆蓋效果錯誤的問題。(但是Z軸的設(shè)置與版本有關(guān),要區(qū)分設(shè)備版本,不然低版本上可能程序崩潰)
思維拓展
還記得前文講過的么,拿到了View之后,再根據(jù)滑動時的參數(shù)變化,我們幾乎可以對它為所欲為,那么我們能做的,就不僅僅是 本次的目標(biāo)效果,像是類似這種滑動特效,還有很多風(fēng)騷的操作可以玩。像是:
沒有做不到,只有想不到,想到之后,最終能做成什么效果,就要看自己的數(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ù)告一個最終效果圖, 文章會盡快出爐。