最近在作的一個小說閱讀 APP,打算模仿掌閱實現 TabLayout 切換時的動畫效果。php
首先看下掌閱的切換效果:java
接下來是個人實現效果:android
切換動畫主要有兩部分組成:canvas
接下來的實現也分這兩部分進行。緩存
這裏的重點是獲取到當前頁面移出屏幕和旁邊頁面進入屏幕的比例,我採用的方法是實現 ViewPager.PageTransformer 接口,經過裏面 transformPage 方法的 position 參數獲取頁面移出(進入)屏幕的比例。bash
這裏的難點是如何理解 position 參數的變化規律,怎麼理解就不在這裏講了,要解釋清楚須要很大篇幅,想要了解的話能夠另外查資料,或者看下我寫的這篇分析:ViewPager.PageTransformer 的 position 分析app
具體的接口實現以下:ide
/**
* @author Feng Zhaohao
* Created on 2019/11/2
*/
public class MyPageTransformer implements ViewPager.PageTransformer {
public static final float MAX_SCALE = 1.3f;
private TabLayout mTabLayout;
@SuppressLint("UseSparseArrays")
private HashMap<Integer, Float> mLastMap = new HashMap<>();
public MyPageTransformer(TabLayout mTabLayout) {
this.mTabLayout = mTabLayout;
}
@Override
public void transformPage(@NonNull View view, float v) {
if (v > -1 && v < 1) {
int currPosition = (int) view.getTag(); // 獲取當前 View 對應的索引
final float currV = Math.abs(v);
if (!mLastMap.containsKey(currPosition)) {
mLastMap.put(currPosition, currV);
return;
}
float lastV = mLastMap.get(currPosition);
// 獲取當前 TabView 的 TextView
LinearLayout ll = (LinearLayout) mTabLayout.getChildAt(0);
TabLayout.TabView tb = (TabLayout.TabView) ll.getChildAt(currPosition);
View textView = tb.getTextView();
// 先判斷是要變大仍是變小
// 若是 currV > lastV,則爲變小;若是 currV < lastV,則爲變大
if (currV > lastV) {
float leavePercent = currV; // 計算離開屏幕的百分比
// 變小
textView.setScaleX(MAX_SCALE - (MAX_SCALE - 1.0f) * leavePercent);
textView.setScaleY(MAX_SCALE - (MAX_SCALE - 1.0f) * leavePercent);
} else if (currV < lastV) {
float enterPercent = 1 - currV; // 進入屏幕的百分比
// 變大
textView.setScaleX(1.0f + (MAX_SCALE - 1.0f) * enterPercent);
textView.setScaleY(1.0f + (MAX_SCALE - 1.0f) * enterPercent);
}
mLastMap.put(currPosition, currV);
}
}
}
複製代碼
有幾點說明一下。佈局
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = LayoutInflater.from(getActivity()).inflate(R.layout.fragment_test, null);
view.setTag(index); // index 爲該 Fragment 的位置索引
return view;
}
複製代碼
LinearLayout ll = (LinearLayout) mTabLayout.getChildAt(0);
TabLayout.TabView tb = (TabLayout.TabView) ll.getChildAt(currPosition);
複製代碼
若是使用官方 TabLayout,是不能這樣作的,由於它內部的 TabView 不是 public,你在外界不能訪問到。我這裏能這樣作是由於這個 TabLayout 是我拷貝官方 TabLayout 到個人項目中,並對它進行了一些修改(之因此要修改官方 TabLayout,是爲了實現 Indicator 的動畫效果,以後會說到)。這裏只需將內部類 TabView 的訪問等級修改成 public 就好了。post
其實一開始我是直接使用 setTextSize 來進行縮放的,不過這樣作的話會很發生很明顯的文字抖動,因此放棄了這種作法。
後來參考一個開源庫(MagicIndicator)的實現,使用 setScaleX 和 setScaleY 方法進行縮放,發現這樣作效果好了不少。最終採用了這種方式實現文字的縮放。
如下是我對於這種差異的緣由猜想(不知道對不對):setScaleX 和 setScaleY 內部使用 invalidate(false)
重繪視圖,而 setTextSize 使用 invalidate()
(即 invalidate(true))重繪視圖, invalidate 方法傳入的參數表示是否讓此視圖的緩存無效,也就是說傳入 true 時不使用緩存。因此我以爲是由於 setScaleX 和 setScaleY 使用了緩存,因此效率更高,特別是進行動畫時須要重繪屢次,使用緩存對於效率的提升就更加明顯了。
實現這個仍是挺不容易的。要實現長度變化動畫,你首先就要改變它的長度,但 TabLayout 並無直接提供設置 Indicator 寬度的方法,只能經過其餘方式來設置。
總的來講有這幾種方法:反射實現、自定義 View、sdk28+ 屬性配置、layer-list。可是這幾種方法都不能實現最終的動畫效果。
反射不能讓 Indicator 的寬度小於文本寬度,否則會壓縮文本。sdk28+ 屬性配置雖然簡單,但 Indicator 的寬度只能等於文本寬度。自定義 View 實現麻煩,而且很難實現動畫效果。layer-list 實現簡單,但缺點是 Indicator 沒有動畫效果。
以上方法除了自定義 View,我都一一嘗試過,後來發現很難知足本身的需求。後來看到這篇文章騷操做之改造TabLayout,修改指示線寬增長切Tab過渡動畫,裏面講到能夠經過修改官方 TabLayout 來實現功能,受此啓發,我最終經過修改官方 TabLayout 實現了 Indicator 的長度變化動畫。
下面看下具體過程:
這裏我使用的是 API26 的 TabLayout。
<com.feng.tablayoutdemo.tablayout.TabLayout android:id="@+id/tab_layout" android:layout_width="match_parent" android:layout_height="wrap_content" app:tabIndicatorColor="@android:color/holo_blue_light" app:tabIndicatorHeight="3dp" />
複製代碼
TabLayout 包含一個 SlidingTabStrip,SlidingTabStrip 中包含 n 個 TabView,TabView 包含:
private Tab mTab;
private TextView mTextView;
private ImageView mIconView;
複製代碼
經過 TabView 的 update() 方法添加 mIconView 和 mTextView
TextView 不能放大到整個 tab,是由於它的父 View(TabView)默認設置了 padding。
默認狀況下,TabView 是有 padding 的,因此文本之間纔會有間距。可是這樣會影響到我放大字體,字體放大是須要空間的,因此要在 TextView 內部設置 padding,可是因爲 TabView 也有 padding,兩個 padding 加起來會使得文本間距很大,很難看。因此我要取消 TabView 的 padding,讓 TextView 撐滿整個 TabView,而後在 TextView 內部設置 padding,留下放大的空間。
實現過程以下:
public TabView(Context context) {
// ViewCompat.setPaddingRelative(this, mTabPaddingStart, mTabPaddingTop,
// mTabPaddingEnd, mTabPaddingBottom);
}
複製代碼
final void update() {
if (mTextView == null) {
// TextView textView = (TextView) LayoutInflater.from(getContext())
// .inflate(R.layout.design_layout_tab_text, this, false);
TextView textView = (TextView) LayoutInflater.from(getContext())
.inflate(com.feng.tabdemo.R.layout.tab_text, this, false);
}
}
複製代碼
R.layout.tab_text:
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:ellipsize="end" android:gravity="center" android:maxLines="2" android:paddingStart="10dp" android:paddingEnd="10dp"/>
複製代碼
經過 paddingStart
和 paddingEnd
設置字體間的間距。
private TabView createTabView(@NonNull final Tab tab) {
// tabView.setMinimumWidth(getTabMinWidth());
}
複製代碼
動畫效果:當從一個頁面滑向另外一個頁面時,Indicator 的寬度由短變長再變短。
下面簡單分析下動畫過程
以頁面滑出一半(另外一個頁面進來一半)爲分界線,在前半段,由短變長:
頁面滑動一半時,Indicator 的寬度達到最長:
在後半段,由長變短:
實現步驟:
public class SlidingTabStrip extends LinearLayout {
// Indicator 的左右邊界
private float left;
private float right;
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
canvas.drawRect(left, getHeight() - mSelectedIndicatorHeight,
right, getHeight(), mSelectedIndicatorPaint);
}
}
複製代碼
在 SlidingTabStrip 中增長兩個變量表示 Indicator 的左右邊界,而且改寫它的 draw 方法,這樣作是爲了方便滑動監聽器修改 Indicator 的左右邊界。
TabLayoutOnPageChangeListener 的 onPageScrolled 方法在頁面滑動時回調,能夠從中知道偏移頁面和頁面偏移量。知道偏移量就能夠從新設置 Indicator 的左右邊界並進行動畫重繪。具體代碼以下:
/** * @param position 當前顯示的第一頁的索引(右側頁面進入時顯示的是當前頁面,左側頁面進入時顯示的是左側頁面) * @param positionOffset 取值範圍 [0, 1),表示 position 頁面的偏移量(在屏幕外的比例) * @param positionOffsetPixels */
@Override
public void onPageScrolled(final int position, final float positionOffset, final int positionOffsetPixels) {
// ... 原有代碼不用刪,保留便可
if (tabLayout == null) {
return;
}
// Indicator 的寬度佔 TabView 的比例
float scale = 0.3f;
// 左滑(右側頁面進入)的第一階段
// 以及右滑(左側頁面進入)的第二階段
if (positionOffset > 0 && positionOffset < 0.5) {
tabLayout.mTabStrip.left = tabLayout.mTabStrip.getChildAt(position).getLeft()
+ scale * tabLayout.mTabStrip.getChildAt(position).getWidth();
float lr = tabLayout.mTabStrip.getChildAt(position).getRight()
- scale * tabLayout.mTabStrip.getChildAt(position).getWidth();
float rr = tabLayout.mTabStrip.getChildAt(position + 1).getRight()
- scale * tabLayout.mTabStrip.getChildAt(position + 1).getWidth();
tabLayout.mTabStrip.right = lr + (positionOffset / 0.5f) * (rr - lr);
ViewCompat.postInvalidateOnAnimation(tabLayout.mTabStrip);
}
// 左滑(右側頁面進入)的第二階段
// 以及右滑(左側頁面進入)的第一階段
if (positionOffset > 0.5) {
float rr = tabLayout.mTabStrip.getChildAt(position + 1).getRight()
- scale * tabLayout.mTabStrip.getChildAt(position + 1).getWidth();
// 先確保 Indicator 滑動最右
if (tabLayout.mTabStrip.right < rr) {
tabLayout.mTabStrip.right = rr;
ViewCompat.postInvalidateOnAnimation(tabLayout.mTabStrip);
}
float ll = tabLayout.mTabStrip.getChildAt(position).getLeft()
+ scale * tabLayout.mTabStrip.getChildAt(position).getWidth();
float rl = tabLayout.mTabStrip.getChildAt(position + 1).getLeft()
+ scale * tabLayout.mTabStrip.getChildAt(position + 1).getWidth();
tabLayout.mTabStrip.left = ll + ((positionOffset - 0.5f) / 0.5f) * (rl - ll);
ViewCompat.postInvalidateOnAnimation(tabLayout.mTabStrip);
}
// 滑動開始或結束
if (positionOffset == 0) {
tabLayout.mTabStrip.left = tabLayout.mTabStrip.getChildAt(position).getLeft()
+ scale * tabLayout.mTabStrip.getChildAt(position).getWidth();
tabLayout.mTabStrip.right = tabLayout.mTabStrip.getChildAt(position).getRight()
- scale * tabLayout.mTabStrip.getChildAt(position).getWidth();
ViewCompat.postInvalidateOnAnimation(tabLayout.mTabStrip);
}
}
複製代碼
到此爲止,就實現了完整的 Indicator 動畫效果。
字體的縮放動畫和 Indicator 的動畫是分開實現的,把它們二者合併起來就是最後的動畫效果了,合併的時候並不會產生什麼衝突,畢竟它們的實現原理不一樣,也沒有什麼耦合。
實現完整的動畫效果花了我三天時間,這三天包括還週末的兩天,時間仍是花了挺久的。不過總的來講仍是值得的,由於在實現過程不斷地發現問題不斷地解決,學到了很多。對反射的做用有了新的理解,對 TabLayout 的佈局更加清晰了,也體會到了改官方源碼的快感(笑)。寫這篇文章更可能是爲了趁剛寫完把本身的理解記錄下來,方便之後回看。若是可以幫助到有須要的人,那就更好了。