前言
上一篇文章講了如何 從ViewPager的源碼入手荠卷,定義自己的ViewPager滑動特效及老。ViewPager由于有自己的動畫接口接口,我們可以直接拿到當前ItemView顶岸,以及它的position位置參數(shù)盈电,因此可以做出任何我們能夠想到的特效。
但是燎斩,TabLayout虱歪,谷歌貌似就沒有那么周到的服務,像是今日頭條那樣的TabLayout 滑動時的 文字部分顏色變化栅表,還有很多其他app中出現(xiàn)的下方橫條indicator長短變化的 特效笋鄙,還有更多其他特效。如果使用谷歌原生的TabLayout是無法做到的怪瓶,這個時候就需要我們 自定義TabLayout萧落,但是,說是自定義洗贰,前提還是要參照 谷歌的TabLayout源碼找岖,然后在其基礎(chǔ)上進行再創(chuàng)作。因為哆姻,從0開始要制作一個 和谷歌原生同樣質(zhì)量的控件宣增,包括滑動流暢度和邊界控制,并且具有良好的擴展性矛缨,并沒有那么容易爹脾,如果存在改造原生TabLayout的可能性帖旨,改造的代價要小于 從0創(chuàng)造。所以灵妨,優(yōu)先 閱讀源碼解阅,探尋這種可能性,如果沒有可能性泌霍,再去從0創(chuàng)造货抄。
Demo的地址為:https://github.com/18598925736/StudyTabLayout/tree/hank_v1
正文大綱
- 源碼分析
- 開發(fā)思路
- 開始搬磚
- 一. 尊重原著
- 二. 聯(lián)動滑動
- 三.特效解耦
正文
源碼分析
創(chuàng)建一個androidStudio工程,然后寫一個TabLayout+ViewPager效果朱转,之后進入看TabLayout的java源碼蟹地。源碼總長度超過了3000行。但是其中大部分都是注釋以及空行藤为,無需害怕怪与。
從我們對TabLayout UI 上的感官印象,可以得知缅疟,它應該是一個橫向可滾動的 HorizontalScrollView
, 它包含兩個關(guān)鍵的元素分别,一個是文字部分的 TabView,一個是 下劃線 Indicator .
image-20200320164934056-1585550575830.png
我們的動畫也是圍繞這兩個部分存淫,所以明確這次源碼分析的最終目標:
- TabView 在TabLayout中是如何 添加 進去的
2)Indicator在TabLayout中是如何 繪制 進去的
TabView明顯是一個以TextView為基礎(chǔ)耘斩,所以是添加到TabLayout 中;Indicator是 一個圖形桅咆,所以應該是繪制的括授。
帶上目標來探索源碼,事半功倍轧邪。
開工刽脖。先看TabView.
從注釋中得知,TabLayout其實可以主動去addTab來添加子view,那就從 addTab方法來入手忌愚。
image-20200320165636066-1585550578377.png
注釋中有這么一句。
tabLayout.addTab(tabLayout.newTab().setText("Tab 1"));
找到一個關(guān)鍵方法却邓,關(guān)于Tab
是如何創(chuàng)建的硕糊。
public Tab newTab() {
Tab tab = createTabFromPool();
tab.parent = this;
tab.view = createTabView(tab);
return tab;
}
從pool中創(chuàng)建,并且 指定了當前TabLayout為它的parent腊徙,并且createTabView(tab)然后 給他的view屬性賦值简十。
后續(xù)還有一句:setText("Tab 1")
, 在創(chuàng)建出來的Tab上調(diào)用setText方法,進入看看:
public Tab setText(@Nullable CharSequence text) {
if (TextUtils.isEmpty(contentDesc) && !TextUtils.isEmpty(text)) {
// If no content description has been set, use the text as the content description of the
// TabView. If the text is null, don't update the content description.
view.setContentDescription(text);
}
this.text = text;
updateView();
return this;
}
由此得知撬腾,我們在上圖中所看到的title0
文本內(nèi)容螟蝙,就是傳到了這個方法(可以debug驗證)。那么 這里的 text
屬性用在了什么位置呢民傻?內(nèi)部類TabView的updateTextAndIcon()方法
image-20200320173530548-1585550586641.png
從這段中可以得出胰默,我們的文本內(nèi)容场斑,最終傳給了 參數(shù) textView : textView.setText(text);
追蹤updateTextAndIcon()的調(diào)用位置,看看這個textView是什么牵署。
經(jīng)過4處調(diào)用位置的檢查漏隐,
image-20200320174011680-1585550589010.png
發(fā)現(xiàn)它是:
image-20200320174106769-1585550590865.png
這兩個屬性之一。TabView的兩個TextView類型的成員變量奴迅。
那么只需要關(guān)心這兩個TextView是如何添加到TabView中去的青责。最后發(fā)現(xiàn) customTextView沒有被addView,而唯一一處addView(textView)的代碼如下:
private void inflateAndAddDefaultTextView() {
ViewGroup textViewParent = this;
if (BadgeUtils.USE_COMPAT_PARENT) {
textViewParent = createPreApi18BadgeAnchorRoot();
addView(textViewParent);
}
this.textView =
(TextView)
LayoutInflater.from(getContext())
.inflate(R.layout.design_layout_tab_text, textViewParent, false);
textViewParent.addView(textView);// 在這里添加的
}
所以取具,可以斷定脖隶,我們之前設(shè)置的 title0
這個文本被設(shè)置到 了 內(nèi)部類TabView(一個線性布局)的TextView成員中。
所以 title0
這個文本 的所在對象暇检,從小到大浩村,依次是,
原生TextView -> 內(nèi)部類TabView -> 內(nèi)部類Tab -> 原生TabLayout
基于這樣的認知占哟,要探究一下是不是存在文字被重新繪制的可能性心墅。
要想對TextView根據(jù)需求重新繪制怎燥,那么除非可以像ViewPager一樣蜜暑,把View以及當前Position反饋到最外層。Position暫且不管隐绵,先看 最終的TextView.
經(jīng)過一番搏斗,發(fā)現(xiàn)并沒有這樣的接口拙毫。缀蹄。缺前。所以沒辦法了拯刁。TabView 在TabLayout中是如何 添加 進去的 的探索結(jié)果表明垛玻,谷歌并沒有給機會讓我們 定制文本部分的內(nèi)容特效棺牧。所以颊乘,放棄吧乏悄。
然后從頭看起,如果以 Indicator在TabLayout中是如何 繪制 進去的 為準來進行探索规求。
image-20200321201914158-1585550594385.png
這里有兩個方法,configureTab 方法丛塌,只是對tab對象進行了 保存±蚜玻看addTabView方法。
image-20200321202058037-1585550596434.png
這里的tab.view 是 TabView對象鲤桥,它最終添加到了 slidingTabIndicator 中去茶凳。而 slidingTabIndicator 它則是一個 內(nèi)部類筒狠,同樣是線性布局雇庙,方向為橫向疆前,它把TabView對象添加進去之后,多個TabView就會橫向排列胸完。而底下那一個橫向的indicator,則是由 畫筆 selectedIndicatorPaint 繪制而成誓琼。根據(jù)如下:
image-20200321203148966-1585550597890.png
image-20200321203307642-1585550599417.png
得出最終結(jié)論:TabLayout的設(shè)計布局如下圖:
image-20200321204132776-1585550603705.png
最后我探索了一下齿穗,indicator 橫條跺株,谷歌是不是有提供對外接口來編輯特效。倒是 內(nèi)部類 SlidingTabIndicator 有一個ValueAnimator indicatorAnimator 在控制 橫條滑動的位置動畫袖扛,使用的是 FastOutSlowInInterpolator 插值器唇礁。但是對我們自定義特效沒啥用。
最后結(jié)論琢融,放棄治療了。在TabLayout上奋蔚,谷歌確實不給機會。
開發(fā)思路
谷歌工程師設(shè)計的控件是針對全世界的開發(fā)者和使用者馒过,肯定會考慮周全,支持很多自定義屬性没隘,細節(jié)細致入微葫录,所以代碼看上起會顯得非常復雜骇扇,難以讀懂少孝,而且這么多英文注釋,你懂的,反正我看他們的注釋一邊看一邊猜盖淡。
然而我們的UI姐姐有自己的要求,所以如果我們可以做自己的UI控件味赃,就可以擺脫谷歌源碼的控制,隨心所欲地控制TabLayout的視覺效果。
今天本文的最終目的:
是開發(fā)一個 綠色版的 GreenTabLayout狠持,去掉谷歌原本一些繁雜的設(shè)定,增添開發(fā)常用的自定義屬性,并且開放 自定義效果的接口昭齐,讓其他開發(fā)者可以在不改動我原本代碼的前提下,編輯自己的動畫特效里覆。
上面TabLayout UI層級
圖,展示了谷歌工程師的設(shè)計思路车荔,此思路沒有問題,我們可以參照它珠增。
但是一步達成最終效果不太可能,我們分階段來達成效果:
-
尊重原著
GreenTabLayout 必須與原TabLayout相差不大,要有文字title標題,要有indicator橫條
-
聯(lián)動滑動
自定義TabLayout必須能夠和ViewPager一樣苔严,產(chǎn)生同樣的聯(lián)動滑動效果,包括橫條的滑動和 標題部分的滑動
-
特效解耦
自定義TabLayout 把 標題欄的View覆旭,indicator橫條View寂祥,對外提供方便的動畫特效定制接口,符合開閉法則.
開始搬磚
確定了基本思路惜犀,接下來就要腳踏實地了。在Kotlin語言如此之香的潮流下撇吞,我也追求一波時尚,開發(fā)將采用Kotlin編碼颂砸,最大程度節(jié)省代碼量勤篮,使用kotlin"域"的概念隔離程序邏輯,盡可能使源碼可讀性提高瀑焦。
一. 尊重原著
要實現(xiàn)與原生TabLayout一樣的效果,可以抄谷歌的作業(yè), 原本的UI層級,照搬即可。
下載源碼之后凫乖,git checkout 4ed2 運行看效果
從外到內(nèi)有三層:
最外層
它的最外層是一個橫向可滾動的 HorizontalScrollView
的子類,同時它提供addTabView
方法 供外界添加item
/**
* 最外層
*/
class HankTabLayout : HorizontalScrollView {
constructor(ctx: Context) : super(ctx) {
init()
}
constructor(ctx: Context, attributes: AttributeSet) : super(ctx, attributes) {
init()
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
init()
}
private lateinit var indicatorLayout: IndicatorLayout
private fun init() {
indicatorLayout = IndicatorLayout(context)
val layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, MATCH_PARENT)
addView(indicatorLayout, layoutParams)
overScrollMode = View.OVER_SCROLL_NEVER
isHorizontalScrollBarEnabled = false
}
fun addTabView(text: String) {
indicatorLayout.addTabView(text)
}
}
中間層
中間層橘蜜,是一個橫向線性布局跌捆,寬度自適應姆钉,根據(jù)內(nèi)容而定,提供addTabView方法,用來添加 TabView到自身思恐,同時 繪制 indicator橫條, 橫條與當前選中的tabView等長并處于最下方.
繪制橫條可能有多種方式婚温,這里借鑒了谷歌的思路栈顷,使用Drawable.draw(canvas) ,好處就是,可以指定drawable圖片惑朦,使用圖片內(nèi)容繪制在canvas上胃珍。后續(xù)會有體現(xiàn)吩蔑。
/**
* 中間層 可滾動的
*/
class IndicatorLayout : LinearLayout {
constructor(ctx: Context) : super(ctx) {
init()
}
private fun init() {
setWillNotDraw(false) // 如果不這么做,它自身的draw方法就不會調(diào)用
}
var indicatorLeft = 0
var indicatorRight = 0
/**
* 作為一個viewGroup仆潮,有可能它不會執(zhí)行自身的draw方法暑诸,這里有一個值去控制,好像是 setWillNotDraw
*/
override fun draw(canvas: Canvas?) {
val indicatorHeight = dpToPx(context, 4f)// 指示器高度
// 現(xiàn)在貌似應該去畫indicator了
// 要繪制,首先要確定范圍,左上右下
var top = height - indicatorHeight
var bottom = height
Log.d("drawTag", "$indicatorLeft $indicatorRight $top $bottom")
// 現(xiàn)在只考慮在底下的情況
var selectedIndicator: Drawable = GradientDrawable()
selectedIndicator.setBounds(indicatorLeft, top, indicatorRight, bottom)
DrawableCompat.setTint(selectedIndicator, resources.getColor(R.color.c2))
selectedIndicator.draw(canvas!!)
super.draw(canvas)
}
fun updateIndicatorPosition(tabView: TabView, left: Int, right: Int) {
indicatorLeft = left
indicatorRight = right
postInvalidate()// 刷新自身,調(diào)用draw
// 把其他的都設(shè)置成未選中狀態(tài)
for (i in 0 until childCount) {
val current = getChildAt(i) as TabView
if (current.hashCode() == tabView.hashCode()) {// 如果是當前被點擊的這個冶忱,那么就不需要管
current.setSelectedStatus(true) // 選中狀態(tài)
} else {// 如果不是
current.setSelectedStatus(false)// 非選中狀態(tài)
}
}
}
/**
* 但是onDraw一定會執(zhí)行
*/
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
}
// 對外提供方法尾菇,添加TabView
fun addTabView(text: String) {
val tabView = TabView(context, this)
val param = LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
param.setMargins(dpToPx(context, 10f))
val textView = TextView(context)
textView.setBackgroundDrawable(resources.getDrawable(R.drawable.my_tablayout_textview_bg))
textView.text = text
textView.gravity = Gravity.CENTER
textView.setPadding(dpToPx(context, 15f))
textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
textView.setTextColor(resources.getColor(TabView.unselectedTextColor))
tabView.setTextView(textView)
addView(tabView, param)
postInvalidate()
if (childCount == 1) {
val tabView0 = getChildAt(0) as TabView
tabView0.performClick()
}
}
}
最里層
說是最里層,其實這里分為兩小層囚枪,一個是TabView(繼承線性布局)派诬,一個是TextView(用來展示 title)链沼,
提供點擊事件默赂,和狀態(tài)切換的方法setSelectedStatus(boolean)
/**
* 最里層TabView
*/
class TabView : LinearLayout {
private lateinit var titleTextView: TextView
private var selectedStatue: Boolean = false
private var parent: IndicatorLayout
companion object {
const val selectedTextColor = R.color.cf
const val unselectedTextColor = R.color.c1
}
constructor(ctx: Context, parent: IndicatorLayout) : super(ctx) {
init()
this.parent = parent
}
fun setTextView(textView: TextView) {
titleTextView = textView
removeAllViews()
val param = LayoutParams(WRAP_CONTENT, MATCH_PARENT)
addView(titleTextView, param)
titleTextView.setOnClickListener {
parent.updateIndicatorPosition(this, left, right)
}
}
private fun init() {
}
fun setSelectedStatus(selected: Boolean) {
selectedStatue = selected
if (selected) {
titleTextView.setTextColor(resources.getColor(R.color.cf))
} else {
titleTextView.setTextColor(resources.getColor(R.color.c1))
}
}
}
初階效果
做完這些,基本就呈現(xiàn)出下圖的狀態(tài):
尊重原著.gif
上一半是原生TabLayout括勺,用來對比缆八,下一半是剛剛完成的效果曲掰。但是和上面的原生TabLayout比起來. 第一步完成。從開始寫代碼耀里,到完成這個效果蜈缤,一直參考的 谷歌的代碼。
二. 聯(lián)動滑動
下載源碼之后冯挎,git checkout a132 運行看效果
布局層級已經(jīng)完成底哥,現(xiàn)在需要聯(lián)動Viewpager的滑動參數(shù),讓GreenTabLayout 跟隨ViewPager一起滑動房官。
注冊監(jiān)聽
要實現(xiàn)聯(lián)動趾徽,首先要知道,谷歌源碼中翰守,TabLayout是如何與ViewPager發(fā)生聯(lián)動的孵奶,它們的聯(lián)結(jié)點在哪里,請看代碼:
tabLayout.setupWithViewPager(viewpager)
平時我們用 原生TabLayout蜡峰,兩者唯一發(fā)生交集的地方就是這里了袁,進入看源碼:
image-20200330142618611.png
顯然他們的交集可能是某個回調(diào)監(jiān)聽,順著這個線索湿颅,最終確定载绿,上面的 pageChangeListener
就是 聯(lián)動滑動的交界點,這里把監(jiān)聽器傳給ViewPager油航,ViewPager則可以把自己的滑動參數(shù)傳遞給TabLayout崭庸,TabLayout則做出相應的行為。
監(jiān)聽器的源碼為:
private TabLayoutOnPageChangeListener pageChangeListener;
public static class TabLayoutOnPageChangeListener implements ViewPager.OnPageChangeListener {
@Override
public void onPageScrolled(final int position, final float positionOffset, final int positionOffsetPixels) {
....
}
@Override
public void onPageSelected(final int position) {
...
}
@Override
public void onPageScrollStateChanged(final int state) {
...
}
}
了解到這里谊囚,我們可以給 GreenTabLayuot 直接加上 這個接口實現(xiàn)
class GreenTabLayout : HorizontalScrollView, ViewPager.OnPageChangeListener {
@Override
public void onPageScrolled(final int position, final float positionOffset, final int positionOffsetPixels) {
....
}
@Override
public void onPageSelected(final int position) {
...
}
@Override
public void onPageScrollStateChanged(final int state) {
...
}
}
然后提供一個 相同的 setupWithViewPager(viewpager)
方法, 在內(nèi)部怕享,給ViewPager綁定監(jiān)聽,同時根據(jù) viewPager的adapter內(nèi)的 page數(shù)目镰踏,決定TabView的數(shù)目和每一個的標題函筋。
fun setupWithViewPager(viewPager: ViewPager) {
this.mViewPager = viewPager
viewPager.addOnPageChangeListener(this)// 注冊監(jiān)聽
val adapter = viewPager.adapter ?: return
val count = adapter!!.count // 欄目數(shù)量
for (i in 0 until count) {
val pageTitle = adapter.getPageTitle(i)
addTabView(pageTitle.toString())// 根據(jù)adapter的item數(shù)目,決定TabView的數(shù)目和每一個標題
}
}
參數(shù)分析
注冊監(jiān)聽之后余境,Viewpager可以把自己的滑動參數(shù)的變化告知TabLayout驻呐,但是TabLayout如何去處理這個參數(shù)變化,還需要從參數(shù)的規(guī)律上去著手芳来。重點分析 監(jiān)聽的 onPageScrolled
方法, 重點中的重點含末,則是前兩個參數(shù):position(當前page的index) 和 positionOffset(當前page的偏移百分比,小數(shù)表示的)
為了研究規(guī)律即舌,我們用上面剛剛完成的代碼把GreenTabLayout和ViewPager連結(jié)上佣盒,然后打印日志onPageScrolled
:
image-20200330145008704.png
基本得出一個結(jié)論:
position為0的,為當前選中的這個page顽聂,當慢慢從當前page劃走時肥惭,它的positionOffset會從0慢慢變成1
并且盯仪,如果手指分方向滑動試驗,可知:
當手指向左蜜葱,positionOffset會遞增全景,從0到極限值1,到達極限之后歸0牵囤,同時 position遞加1
反之爸黄,手指向右,positionOffset會遞減揭鳞,從1 遞減到0炕贵,從遞減的那一刻開始,position遞減1
基于上面的規(guī)律野崇,我們可以調(diào)試出 indicator橫條動畫的代碼:
...
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
scrollTabLayout(position, positionOffset)
}
private fun scrollTabLayout(position: Int, positionOffset: Float) {
// 如果手指向左劃称开,indicator橫條應該從當前位置,滑動到 下一個子view的位置上去乓梨,position應該+1
// 如果手指向右滑動鳖轰,position立即減1,indicator橫條應該從當前位置向左滑動
val currentTabView = indicatorLayout.getChildAt(position) as GreenTabView
val currentLeft = currentTabView.left
val currentRight = currentTabView.right
val nextTabView = indicatorLayout.getChildAt(position + 1)
if (nextTabView != null) {
val nextLeft = nextTabView.left
val nextRight = nextTabView.right
Log.d("scrollTabLayout","當前index:${position} left:${currentLeft} right:${currentRight} " +" 目標index:${position + 1} left:${nextLeft} right:${nextRight} positionOffset:${positionOffset}" )
val leftDiff = nextLeft - currentLeft
val rightDiff = nextRight - currentRight
indicatorLayout.updateIndicatorPosition(
currentLeft + (leftDiff * positionOffset).toInt(),
currentRight + (rightDiff * positionOffset).toInt()
)
}
}
為什么這樣就能正確區(qū)分滑動的方向扶镀?把日志打印出來一看就明白:
這是手指向左劃一格:
image-20200330151551105.png
觀察positionOffset的變化脆霎,從0 變?yōu)?,然后歸零狈惫。
而看橫條的當前 left = 26,right=170鹦马, 以及 目標left=222胧谈,right=380 ,隨著positionOffset的遞增荸频,橫條會慢慢向右菱肖。
而到達最后,positionOffset歸零了旭从,當前l(fā)eft 也變成了 目標的left = 222稳强,right=380.
橫條向右平移完成。
而手指向右劃一格和悦,日志如下:
image-20200330152206881.png
position先直接減1退疫,positionOffset則從1慢慢變成0.
橫條從 left=26 right=170 的起始位置,向 目標 left=222鸽素,righ=380 移動褒繁,但是由于positionOffset是遞減的,所以馍忽,橫條的移動方向反而是 向左棒坏。一直到positionOffset為0燕差,到達 left=26 right=170.
橫條向左平移也完成。
整體平移
橫條雖然可以跟著viewPager的滑動而滑動坝冕,但是如果TabView已經(jīng)排滿了當前屏幕徒探,橫條到達了當前屏幕最右側(cè),viewPager上右側(cè)還有內(nèi)容還可以讓手指向左滑動喂窟。此時测暗,就必須滾動最外層布局,來讓TabView顯示出來谎替。
通過觀察原生TabLayout偷溺,它會盡量讓 當前選中的tabView位于 控件的橫向居中的位置。而隨著 ViewPager的當前page的變化钱贯,最外層GreenTabLayout也要發(fā)生橫向滾動挫掏。
所以我選擇在 回調(diào)函數(shù)onPageSelected中執(zhí)行滾動:
class GreenTabLayout: HorizontalScrollView, ViewPager.OnPageChangeListener {
...
override fun onPageSelected(position: Int) {
val tabView = indicatorLayout.getChildAt(position) as GreenTabView
if (tabView != null) {
indicatorLayout.updateIndicatorPositionByAnimator(tabView, tabView.left, tabView.right)
}
}
}
執(zhí)行滾動的思路為:
- 確定 當前選中的tabView的 矩形范圍
tabView.getHitRect(tabViewBounds)
- 確定 確定最外層GreenTbaLayout的矩形范圍
getHitRect(parentBounds)
- 計算兩個矩形的x軸的中點,然后計算出兩個中點的差值秩命,差值就是需要滾動的距離
- 使用屬性動畫進行平滑滾動
/**
* 用動畫平滑更新indicator的位置
* @param tabView 當前這個子view
*/
fun updateIndicatorPositionByAnimator(
tabView: GreenTabView,
targetLeft: Int,
targetRight: Int) {
...
// 處理最外層布局( HankTabLayout )的滑動
parent.run {
tabView.getHitRect(tabViewBounds) //確定 當前選中的tabView的 矩形范圍
getHitRect(parentBounds) // 確定最外層GreenTbaLayout的矩形范圍
val scrolledX = scrollX // 已經(jīng)滑動過的距離
val tabViewRealLeft = tabViewBounds.left - scrolledX // 真正的left, 要算上scrolledX
val tabViewRealRight = tabViewBounds.right - scrolledX // 真正的right, 要算上scrolledX
val tabViewCenterX = (tabViewRealLeft + tabViewRealRight) / 2
val parentCenterX = (parentBounds.left + parentBounds.right) / 2
val needToScrollX = -parentCenterX + tabViewCenterX // 差值就是需要滾動的距離
startScrollAnimator(this, scrolledX, scrolledX + needToScrollX)
}
}
/**
* 用動畫效果平滑滾動過去
*/
private fun startScrollAnimator(tabLayout: GreenTabLayout, from: Int, to: Int) {
if (scrollAnimator != null && scrollAnimator.isRunning) scrollAnimator.cancel()
scrollAnimator.duration = 200
scrollAnimator.interpolator = FastOutSlowInInterpolator()
scrollAnimator.addUpdateListener {
val progress = it.animatedValue as Float
val diff = to - from
val currentDif = (diff * progress).toInt()
tabLayout.scrollTo(from + currentDif, 0)
}
scrollAnimator.start()
}
二階效果
完成到這里尉共,就能達成下圖中的效果:
聯(lián)動滑動.gif
上半部分為原生TabLayout效果,下把那部分為 剛剛完成的效果弃锐,幾乎沒有差別了袄友。
當然,我們這是把TabLayout本地化霹菊,完成這些剧蚣,僅僅用了kotlin 300多行代碼⌒ⅲ可見Kotlin在省代碼方面鸠按,確實是一絕,比java簡潔很多饶碘。
三.特效解耦
這一階段主要做2件事:
- 支持開發(fā)中的常用的UI設(shè)計要求目尖,這個可以做成自定義屬性
- 開放無耦合接口,使得開發(fā)者可以使用該接口編輯 indicator橫條 / TabView文本 的滑動特效扎运,而不用改動GreenTabLayout的內(nèi)部實現(xiàn)
第一點瑟曲,都是一些基礎(chǔ)性的改造,就不贅述了豪治,關(guān)于自定義屬性的添加和使用洞拨,都是死框架,沒什么好說的鬼吵,下面扣甲,總結(jié)一下 我所支持的所有屬性:
盤點自定義屬性
TabView標題欄部分:
屬性名 | 意義 | 取值類型 |
---|---|---|
tabViewTextSize | 標題字體大小 | dimension|reference |
tabViewTextSizeSelected | 選中后的標題字體大小 | dimension|reference |
tabViewTextColor | 標題字體顏色 | color|reference |
tabViewTextColorSelected | 選中后的標題字體顏色 | color|reference |
tabViewBackgroundColor | 標題區(qū)域背景色 | color|reference |
tabViewTextPaddingLeft | 標題區(qū)內(nèi)邊距左 | dimension|reference |
tabViewTextPaddingRight | 標題區(qū)內(nèi)邊距右 | dimension|reference |
tabViewTextPaddingTop | 標題區(qū)內(nèi)邊距上 | dimension|reference |
tabViewTextPaddingBottom | 標題區(qū)內(nèi)邊距下 | dimension|reference |
tabViewDynamicSizeWhenScrolling | 是否允許滾動時的字體大小漸變 | boolean |
Indicator橫條部分:
屬性名 | 意義 | 取值類型 |
---|---|---|
indicatorColor | 橫條顏色 | color|reference |
indicatorLocationGravity | 橫條位置 | 枚舉:TOP 放在頂部 / BOTTOM 放在底部 |
indicatorMargin | 橫條間距,當indicatorLocationGravity為TOP時表示距離頂端的距離,BOTTOM時表示距離底部的距離 | dimension|reference |
indicatorWidthMode | 橫條寬度模式 | 枚舉:RELATIVE_TAB_VIEW 取TabView寬度的倍數(shù) / EXACT 取精確值 |
indicatorWidthPercentages | 橫條寬度百分比琉挖,當indicatorWidthMode 為 RELATIVE_TAB_VIEW時才會生效启泣,表示橫條寬度占TabView寬度的百分比 |
float(大于0) |
indicatorExactWidth | 橫條寬度精確值,當indicatorWidthMode 為 EXACT時才會生效示辈,表示橫條的精確寬度 |
dimension|reference |
indicatorHeight | 橫條高度 | dimension|reference |
indicatorAlignMode | 橫條對其模式 | 枚舉: LEFT / CENTER / RIGHT |
indicatorDrawable | 橫條drawable寥茫,可以指定橫條的內(nèi)容為圖片 | reference |
indicatorElastic | 是否開啟滾動時橫條的彈性效果 | boolean |
indicatorElasticBaseMultiple | 當indicatorElastic開啟時生效,表示彈性倍數(shù)矾麻,數(shù)字越大纱耻,彈性越明顯 | float |
其中大部分屬性的處理都是基于非常基礎(chǔ)的View控件知識和簡單的數(shù)學計算险耀,只有幾點需要講解說明:
- tabViewDynamicSizeWhenScrolling 是否允許滾動時的字體大小漸變
- indicatorElastic 是否開啟滾動時橫條的彈性效果
這兩點弄喘,都與 viewPager滑動時的參數(shù)變化有關(guān)系,所以處理這兩個特性甩牺,需要結(jié)合參數(shù)變化規(guī)律
較復雜屬性處理
-
tabViewDynamicSizeWhenScrolling
viewPager滾動時蘑志,標題的字體大小會發(fā)生漸變:
標題欄字體大小漸變.gif
class GreenTabLayout : HorizontalScrollView, ViewPager.OnPageChangeListener {
...
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int){
Log.d("positionOffset", "$positionOffset")
scrollTabLayout(position, positionOffset)
}
fun scrollTabLayout(position: Int, positionOffset: Float) {
val currentTabView = indicatorLayout.getChildAt(position) as GreenTabView
val currentLeft = currentTabView.left
val currentRight = currentTabView.right
val nextTabView = indicatorLayout.getChildAt(position + 1) // 目標TabView
if (nextTabView != null) {
val nextGreenTabView = nextTabView as GreenTabView
dealAttrTabViewDynamicSizeWhenScrolling(// 關(guān)鍵代碼
positionOffset,
currentTabView,
nextGreenTabView
)
...
}
}
/**
* 處理屬性 tabViewDynamicSizeWhenScrolling
*/
private fun dealAttrTabViewDynamicSizeWhenScrolling(
positionOffset: Float,
currentTabView: GreenTabView,
nextTabView: GreenTabView
) {
if (tabViewAttrs.tabViewDynamicSizeWhenScrolling) {
if (positionOffset != 0f) {
// 在這里,讓當前字體變小贬派,next的字體變大
val diffSize =
tabViewAttrs.tabViewTextSizeSelected - tabViewAttrs.tabViewTextSize
when (mScrollState) {
ViewPager.SCROLL_STATE_DRAGGING -> {
currentTabViewTextSizeRealtime =
tabViewAttrs.tabViewTextSizeSelected - diffSize * positionOffset
currentTabView.titleTextView.setTextSize(
TypedValue.COMPLEX_UNIT_PX,
currentTabViewTextSizeRealtime
)
nextTabViewTextSizeRealtime =
tabViewAttrs.tabViewTextSize + diffSize * positionOffset
nextTabView.titleTextView.setTextSize(
TypedValue.COMPLEX_UNIT_PX,
nextTabViewTextSizeRealtime
)
settingFlag = false
}
ViewPager.SCROLL_STATE_SETTLING -> {
// OK急但,定位到問題,在 mScrollState 為setting狀態(tài)時搞乏,positionOffset的變化沒有 dragging時那么細致
// 只要不處理 SETTING下的字體大小變化波桩,也可以達成效果
if (!settingFlag)
indicatorLayout.resetTabViewsStatueByAnimator(indicatorLayout[mCurrentPosition] as GreenTabView)
settingFlag = true
}
}
}
}
}
}
處理思路依舊是圍繞 onPageScrolled 的參數(shù)變化,核心方法為:dealAttrTabViewDynamicSizeWhenScrolling(..), 讓當前tabView的文本漸漸變小请敦,而nextTabView的文本逐漸變大镐躲。這里如果有疑問可以參照上文的 參數(shù)分析小章節(jié)。
但是侍筛,有一個坑匀油,就是當拖拽停止的時候,viewpager會有一個自動的回彈動作勾笆,如果這里沒處理好,就會出現(xiàn)桥滨,字體大小突變的情況窝爪,和我要的平滑動畫過渡不相符,所以齐媒,這里我做了一個特殊處理蒲每,當拖拽停止,也就是手指松開的時候喻括,抓準 ViewPager的 SCROLL_STATE_SETTLING 狀態(tài)剛剛進入的時機邀杏,使用屬性動畫平滑改變字體,核心代碼就是上文代碼塊中的:indicatorLayout.resetTabViewsStatueByAnimator(indicatorLayout[mCurrentPosition] as GreenTabView)
這句話可以讓 tabView的文本字體平滑地從 當前值(不確定,因為dragging狀態(tài)是用戶人為控制)望蜡,變?yōu)?目標值(這是確定值唤崭,要么是 正常狀態(tài)下的字體大小,要么是選中狀態(tài)下的字體大胁甭伞)谢肾,由此完美解決字體平滑變化的問題。
-
indicatorElastic
滾動時小泉,橫條會拉伸和回縮芦疏,也是跟隨onPageScrolled
的參數(shù)變化而變化關(guān)鍵代碼在
SlidingIndicatorLayout.kt
中的 draw方法:override fun draw(canvas:Canvas?){ ... val baseMultiple = parent.indicatorAttrs.indicatorElasticBaseMultiple // 基礎(chǔ)倍數(shù),決定拉伸 val indicatorCriticalValue = 1 + baseMultiple val ratio = if (parent.indicatorAttrs.indicatorElastic) { when { positionOffset >= 0 && positionOffset < 0.5 -> { 1 + positionOffset * baseMultiple // 拉伸長度 } else -> {// 如果到了下半段,當offset越過中值之后ratio的值 indicatorCriticalValue - positionOffset * baseMultiple } } } else 1f // 可以開始繪制 selectedIndicator.run { setBounds( ((centerX - indicatorWidth * ratio / 2).toInt()), top, ((centerX + indicatorWidth * ratio / 2).toInt()), bottom )// 規(guī)定它的邊界 draw(canvas!!)// 然后繪制到畫布上 } ... }
這一段提出來特別說明微姊,因為它代表了一種解題思路酸茴,我需要的效果是:
viewPager滾動1格,我需要它在滾動一半的時候兢交,橫條拉伸到最長薪捍,從一半滾完的時候,橫條回縮到應該的寬度
但是魁淳,viewPager滾1格飘诗,positionOffset的變化是從0 到1(手指向右),或者是從1到0(手指向左)界逛,我需要把positionOffset在到達0.5的時候當作一個臨界時間點昆稿,計算出 這個臨界時間點上,indicator橫條應該的長度息拜。
關(guān)鍵在于:在臨界點0.5上溉潭,前半段的0->0.5的最終值,必須等于 后半段 0.5->1 的 開始值少欺,
由于我是按照倍數(shù)來拉伸喳瓣,所以,原始倍率是1赞别。我還想用參數(shù)控制拉伸的程度畏陕,所以設(shè)計一個變量
baseMultiple
(拉伸倍數(shù),數(shù)值越大仿滔,拉伸越明顯)列出公式:
前半段的ratio最終值 = 1(
原始倍率
)+ 0.5 *baseMultiple
后半段的ratio值 =
indicatorCriticalValue
(臨界值
) - 0.5 *baseMultiple
前半段的ratio最終值 = 后半段的ratio值
計算得出惠毁,
indicatorCriticalValue
(臨界值) = 1 (原始倍率
)+baseMultiple
于是就寫出了上面的代碼。
三階效果
說了這么多崎页,不如親眼看一眼效果更佳實在鞠绰,以上各項屬性,下面的動態(tài)圖基本都有體現(xiàn), 具體效果可以按需定制,基本可以滿足UI姐姐的各種騷操作要求,如果還不行飒焦,可以拿我的代碼自行修改,我的代碼注釋應該比谷歌大佬要親民很多蜈膨。,歡迎fork,star...
開放無耦合特效接口
為什么生出這種想法翁巍?這個是源自:ViewPager的無耦合動畫接口驴一。
Viewpager.setPageTransformer(true, MyPageTransformer(this, adapter.count))
viewPager的setPageTransformer,可以傳入一個 PageTransformer(接口)的實現(xiàn)類曙咽,從而控制ViewPager滑動時的動畫蛔趴,開發(fā)者可以自由定制效果,而不用關(guān)心ViewPager的內(nèi)部實現(xiàn)例朱。符合程序設(shè)計的開閉法則孝情,讓控件開發(fā)者和 控件使用者都省心省力。
GreenTabView接口
我在Demo中洒嗤,提供了 GreenTabLayout的setupWithViewPager泛型方法箫荡,使用者可以傳入 GreenTextView的子類.兩段關(guān)鍵代碼如下:
open class GreenTextView : AppCompatTextView {
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context) : super(context)
/**
* 可重寫,接收來自viewpager的position參數(shù)渔隶,做出隨心所欲的textView特效
*
* @param isSelected 是不是當前選中的TabView
* @param positionOffset 偏移值 0<= positionOffset <=1
*/
open fun handlerPositionOffset(positionOffset: Float, isSelected: Boolean) {}
/**
* 如果發(fā)生了滑動過程中特效殘留的情況羔挡,可以重寫此方法用來清除特效
*/
open fun removeShader(oldPosition: Int, newOldPosition: Int) {}
/**
* 添加特效
*/
open fun addShader(oldPosition: Int, newOldPosition: Int) {}
/**
* 通知,viewPager 即將進入setting狀態(tài)
* @param positionOffset 當前offset
* @param isSelected 是否是被選擇的TabView
* @param direction 滑動方向间唉,大于0 表示向右回彈绞灼,小于0 表示向左回彈
*/
open fun onSetting(positionOffset: Float, isSelected: Boolean, direction: Int) {}
}
class GreenTabLayout : HorizontalScrollView, ViewPager.OnPageChangeListener{
...
fun <T : GreenTextView> setupWithViewPager(viewPager: ViewPager, t: T?) {
...
}
}
你可以按照下面的模板使用這個接口:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val adapter = MyPagerAdapter(supportFragmentManager)
hankViewpager.adapter = adapter
hankViewpager.offscreenPageLimit = 3
hankViewpager.setPageTransformer(true, MyPageTransformer(this, adapter.count))
//*******************關(guān)鍵代碼*****************
hankTabLayout.setupWithViewPager(hankViewpager, GradientTextView(this))
//*******************************************
hankTabLayout2.setupWithViewPager(hankViewpager)
}
....
}
GradientTextView是GreenTabView的一個子類,它的源碼是:
/**
* 提供顏色漸變的TextView
*/
class GradientTextView : GreenTextView {
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context) : super(context)
private var mLinearGradient: LinearGradient? = null
private var mGradientMatrix: Matrix? = null
private lateinit var mPaint: Paint
private var mViewWidth = 0f
private var mTranslate = 0f
private val mAnimating = true
private val fontColor = Color.BLACK
private val shaderColor = Color.YELLOW
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
if (mViewWidth == 0f) {
mViewWidth = measuredWidth.toFloat()
if (mViewWidth > 0) {
mPaint = paint
mLinearGradient = LinearGradient(
0f,// 初始狀態(tài)呈野,是隱藏在x軸負向低矮,一個view寬的距離
0f,
mViewWidth,
0f,
intArrayOf(fontColor, shaderColor, shaderColor, fontColor),
floatArrayOf(0f, 0.1f, 0.9f, 1f),
Shader.TileMode.CLAMP
)
mPaint.shader = mLinearGradient
mGradientMatrix = Matrix()
}
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (mAnimating && mGradientMatrix != null) {
mGradientMatrix!!.setTranslate(mTranslate, 0f)
mLinearGradient!!.setLocalMatrix(mGradientMatrix)
}
}
private inline fun dealSwap(positionOffset: Float, isSelected: Boolean) {
// 如果不是初始值,那說明已經(jīng)賦值過被冒,那么用 參數(shù)positionOffset 和 它對比军掂,來得出滑動的方向
Log.d(
"setMatrixTranslate",
" positionOffset:$positionOffset isSelected:$isSelected "
)
// 來,先判定滑動的方向昨悼,因為方向會決定從哪個角度
mTranslate = if (mPositionOffset < positionOffset) {// 手指向左
if (isSelected) {// 如果當前是選中狀態(tài)蝗锥,那么 offset會從0到1 會如何變化?
mViewWidth * positionOffset // OK率触,沒問題终议。
} else {
-mViewWidth * (1 - positionOffset)
}
} else {// 手指向右
if (isSelected) {// 如果當前是選中狀態(tài),那么 offset會從0到1 會如何變化葱蝗?
-mViewWidth * (1 - positionOffset) // OK痊剖,沒問題。
} else {
mViewWidth * positionOffset
}
}
postInvalidate()
}
/**
* 由外部參數(shù)控制shader的位置
* @param positionOffset 只會從0到1變化
* @param isSelected 是否選中
*/
override fun handlerPositionOffset(positionOffset: Float, isSelected: Boolean) {
if (mPositionOffset == -1f) {// 如果你是初始值
mPositionOffset = positionOffset // 那就先賦值
} else {
dealSwap(positionOffset, isSelected)
}
}
override fun removeShader(direction: Int) {
Log.d("removeShaderTag", "要根據(jù)它當前的mTranslate位置決定從哪個方向消失 mTranslate:$mTranslate")
mTranslate = mViewWidth
postInvalidate()
}
override fun addShader(direction: Int) {
// 屬性動畫實現(xiàn)shader平滑移動
val from =
if (direction < 0) {
-mViewWidth
} else {
mViewWidth
}
startAnimator(from, 0f)
}
override fun onSetting(positionOffset: Float, isSelected: Boolean, direction: Int) {
Log.d(
"onSettingTag",
"isSelected:$isSelected positionOffset:$positionOffset direction:$direction"
)
mPositionOffset = -1f
val targetTranslate = if (isSelected) {
0f
} else {
if (direction > 0f) {// 向右回彈
mViewWidth
} else {
Log.d("onSettingTag2", "難道這里還要分情況么垒玲?mTranslate:$mTranslate mViewWidth:$mViewWidth")
if (mTranslate == mViewWidth || mTranslate == -mViewWidth) {
mTranslate // 如果已經(jīng)到達了最右邊,那就保持你這個樣子就行了, 可是你是怎么到最右邊的找颓?
} else
-mViewWidth
}
}
val thisTranslate = mTranslate
startAnimator(thisTranslate, targetTranslate)
}
private fun startAnimator(from: Float, targetTranslate: Float) {
if (animator != null) animator?.cancel()
// 屬性動畫實現(xiàn)shader平滑移動
animator = ValueAnimator.ofFloat(from, targetTranslate)
animator?.run {
duration = animatorDuration
addUpdateListener {
mTranslate = it.animatedValue as Float
postInvalidate()
}
start()
}
}
private var mPositionOffset: Float = -1f
private val animatorDuration = 200L
private var animator: ValueAnimator? = null
}
運行效果:請注意看下圖的上面半部分合愈,下半部分只是沒有加特效的對比。理論上,利用現(xiàn)在的參數(shù)佛析,可以定制出想要的任何效果益老,下圖只是我的一些效果測試。
文字漸變最終效果.gif
注意寸莫,使用了Shader特效之后捺萌,原本的 titleTextView字體顏色可能會失效,這是由shader機制決定的膘茎,但是依然可以用shader控制字體的顏色桃纯,運行Demo,閱讀源碼披坏,很快就能得出答案态坦。
既然這是一個開放接口,那么所能達成的效果棒拂,就不僅僅是上圖中所示, 利用 handlerPositionOffset的幾個參數(shù)伞梯,發(fā)揮想象力(或者UI姐姐發(fā)揮想象力),想要做出任何你希望的效果帚屉,只是時間問題谜诫。
Indicator接口
同樣,針對Indicator橫條的繪制攻旦,你也可以完全自定義喻旷,使用自己的實現(xiàn)方式,強制接管 原代碼中的繪制邏輯敬特。
接口在 GreenTabLayout.kt 中掰邢,入口方法為:
/**
* 注意,使用了此方法伟阔,傳入了非空的CustomDrawHandler實現(xiàn)類對象辣之,
* 原本indicator的所有屬性都會失效,因為indicator的繪制工作皱炉,全部由CustomDrawHandler接管
*/
fun setIndicatorDrawHandler(customDrawHandler: SlidingIndicatorLayout.CustomDrawHandler?) {
indicatorLayout.customDrawHandler = customDrawHandler
}
接口為:SlidingIndicatorLayout.kt
類中的 CustomDrawHandler
怀估,提供一個draw方法,方法內(nèi)提供2個關(guān)鍵參數(shù)合搅,第一個是 SlidingIndicatorLayout 對象多搀,第二個是,畫布canvas對象, 前者可以讓我們拿到任何想要拿的參數(shù)灾部,后者康铭,讓我們可以動用想象力,把想象的特效赌髓,繪制在畫布上从藤。
interface CustomDrawHandler {
fun draw(indicatorLayout: SlidingIndicatorLayout, canvas: Canvas?)
}
var customDrawHandler: CustomDrawHandler? = null
使用方法:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val adapter = MyPagerAdapter(supportFragmentManager)
hankViewpager.adapter = adapter
hankViewpager.offscreenPageLimit = 3
hankViewpager.setPageTransformer(true, MyPageTransformer(this, adapter.count))
hankTabLayout.setupWithViewPager(hankViewpager, GradientTextView(this))
hankTabLayout.setIndicatorDrawHandler(CustomDrawHandlerImpl(this))
hankTabLayout2.setupWithViewPager(hankViewpager)
}
class CustomDrawHandlerImpl : SlidingIndicatorLayout.CustomDrawHandler {
val context: Context
constructor(context_: Context) {
context = context_
}
override fun draw(indicatorLayout: SlidingIndicatorLayout, canvas: Canvas?) {
val paint = Paint()
paint.color = context.resources.getColor(R.color.c1)
val fraction =
(indicatorLayout.parent.mCurrentPosition.toFloat() + 1) / indicatorLayout.childCount.toFloat()// 分數(shù)
val left = indicatorLayout.parent.scrollX
val right =
(indicatorLayout.parent.scrollX + indicatorLayout.parent.measuredWidth * fraction).toInt()
val rect = Rect(left, 0, right, dpToPx(context, 10f))
canvas?.drawRect(rect, paint)
}
}
...
}
運行效果請看下圖上半部分(下面一半仍然是用來對比)催跪,我實現(xiàn)了一個用indicator來記錄當前滑動的進度的特效,只作為簡單效果的展示夷野,表示它可以實現(xiàn)任何你能想到的indicator動效懊蒸,上面的代碼,我只繪制了矩形悯搔,其實還可以繪制任何其他圖形骑丸,任你想像。
indicator特效解耦.gif
結(jié)語
Demo的地址為:https://github.com/18598925736/StudyTabLayout/tree/hank_v1
請下載運行最新版本代碼看效果妒貌。
至此通危,所有內(nèi)容放送完畢,全文技術(shù)從立意到實踐編碼苏揣,再到文章出爐黄鳍,歷時半月,終于功成平匈。由于只是業(yè)余時間研究所得框沟,細節(jié)上還沒有打磨得十分圓滿。
寫出一個類似這樣的控件并不難增炭,技術(shù)上基本沒有什么縱深忍燥,但是涉及面很廣,而且一旦開頭的思路錯了隙姿,后續(xù)隱患無窮梅垄。我的思維是,向源碼學習输玷,將基礎(chǔ)架構(gòu)學到手队丝,具體的實操,我們再自己把握欲鹏。谷歌的注釋雖然有些生澀難懂机久,但是大體思維,只要認真研讀源碼赔嚎,總是能得到啟發(fā)的膘盖。
希望能給其他開發(fā)者帶來新的思路和借鑒。
歡迎 看到的各位大佬留言交流尤误,批評指正侠畔。謝過!