仿掌閱實現 TabLayout 切換時的字體和 Indicator 動畫

前言

最近在作的一個小說閱讀 APP,打算模仿掌閱實現 TabLayout 切換時的動畫效果。php

首先看下掌閱的切換效果:java

接下來是個人實現效果:android

分析

切換動畫主要有兩部分組成:canvas

  1. 字體的縮放動畫:進入頁面的字體逐漸放大,移除頁面的字體逐漸縮小
  2. Indicator 的長度變化動畫:在進行頁面滑動時,Indicator 的長度由短邊長再變短。以頁面移出一半爲分界線,前半部分 Indicator 由短變長,後半部分 Indicator 由長變短。

接下來的實現也分這兩部分進行。緩存

實現字體縮放動畫

這裏的重點是獲取到當前頁面移出屏幕和旁邊頁面進入屏幕的比例,我採用的方法是實現 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);
        }
    }
}
複製代碼

有幾點說明一下。佈局

  1. 爲了經過 View 獲取到其對應的位置,在給 Fragment 建立視圖的時候,給它設置一個 tag,值爲它的位置索引:
@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;
    }
複製代碼
  1. 在獲取當前 TabView 的 TextView 時,我經過當前位置獲取到的 TabView 能夠直接進行轉換:
LinearLayout ll = (LinearLayout) mTabLayout.getChildAt(0);
            TabLayout.TabView tb = (TabLayout.TabView) ll.getChildAt(currPosition);
複製代碼

若是使用官方 TabLayout,是不能這樣作的,由於它內部的 TabView 不是 public,你在外界不能訪問到。我這裏能這樣作是由於這個 TabLayout 是我拷貝官方 TabLayout 到個人項目中,並對它進行了一些修改(之因此要修改官方 TabLayout,是爲了實現 Indicator 的動畫效果,以後會說到)。這裏只需將內部類 TabView 的訪問等級修改成 public 就好了。post

  1. 這裏我是經過 setScaleX 和 setScaleY 方法對 TextView 進行縮放操做。

其實一開始我是直接使用 setTextSize 來進行縮放的,不過這樣作的話會很發生很明顯的文字抖動,因此放棄了這種作法。

後來參考一個開源庫(MagicIndicator)的實現,使用 setScaleX 和 setScaleY 方法進行縮放,發現這樣作效果好了不少。最終採用了這種方式實現文字的縮放。

如下是我對於這種差異的緣由猜想(不知道對不對):setScaleX 和 setScaleY 內部使用 invalidate(false) 重繪視圖,而 setTextSize 使用 invalidate()(即 invalidate(true))重繪視圖, invalidate 方法傳入的參數表示是否讓此視圖的緩存無效,也就是說傳入 true 時不使用緩存。因此我以爲是由於 setScaleX 和 setScaleY 使用了緩存,因此效率更高,特別是進行動畫時須要重繪屢次,使用緩存對於效率的提升就更加明顯了。

實現 Indicator 的長度變化動畫

實現這個仍是挺不容易的。要實現長度變化動畫,你首先就要改變它的長度,但 TabLayout 並無直接提供設置 Indicator 寬度的方法,只能經過其餘方式來設置。

總的來講有這幾種方法:反射實現、自定義 View、sdk28+ 屬性配置、layer-list。可是這幾種方法都不能實現最終的動畫效果。

反射不能讓 Indicator 的寬度小於文本寬度,否則會壓縮文本。sdk28+ 屬性配置雖然簡單,但 Indicator 的寬度只能等於文本寬度。自定義 View 實現麻煩,而且很難實現動畫效果。layer-list 實現簡單,但缺點是 Indicator 沒有動畫效果。

以上方法除了自定義 View,我都一一嘗試過,後來發現很難知足本身的需求。後來看到這篇文章騷操做之改造TabLayout,修改指示線寬增長切Tab過渡動畫,裏面講到能夠經過修改官方 TabLayout 來實現功能,受此啓發,我最終經過修改官方 TabLayout 實現了 Indicator 的長度變化動畫。

下面看下具體過程:

1、準備工做

  1. 導入相關類

這裏我使用的是 API26 的 TabLayout。

  1. 一開始發現 Indicator 不顯示,需在 xml 文件設置 Indicator 的顏色和高度
<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" />
複製代碼
  1. 一些分析

TabLayout 包含一個 SlidingTabStrip,SlidingTabStrip 中包含 n 個 TabView,TabView 包含:

private Tab mTab;
        private TextView mTextView;
        private ImageView mIconView;
複製代碼

經過 TabView 的 update() 方法添加 mIconView 和 mTextView

TextView 不能放大到整個 tab,是由於它的父 View(TabView)默認設置了 padding。

2、讓 TextView 撐滿 TabView

默認狀況下,TabView 是有 padding 的,因此文本之間纔會有間距。可是這樣會影響到我放大字體,字體放大是須要空間的,因此要在 TextView 內部設置 padding,可是因爲 TabView 也有 padding,兩個 padding 加起來會使得文本間距很大,很難看。因此我要取消 TabView 的 padding,讓 TextView 撐滿整個 TabView,而後在 TextView 內部設置 padding,留下放大的空間。

實現過程以下:

  1. 取消 TabView 默認的 padding:
public TabView(Context context) {
// ViewCompat.setPaddingRelative(this, mTabPaddingStart, mTabPaddingTop,
// mTabPaddingEnd, mTabPaddingBottom);
        }
複製代碼
  1. 給 TabView 的 TextView 加上本身的佈局:
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"/>
複製代碼

經過 paddingStartpaddingEnd 設置字體間的間距。

  1. TabView 設置了一個最小寬度,爲了防止 TextView 比較短時不能撐滿,把它取消掉:
private TabView createTabView(@NonNull final Tab tab) {

// tabView.setMinimumWidth(getTabMinWidth());

    }
複製代碼

3、實現滑動時 Indicator 的動畫效果

動畫效果:當從一個頁面滑向另外一個頁面時,Indicator 的寬度由短變長再變短。

下面簡單分析下動畫過程

以頁面滑出一半(另外一個頁面進來一半)爲分界線,在前半段,由短變長:

頁面滑動一半時,Indicator 的寬度達到最長:

在後半段,由長變短:

實現步驟:

  1. SlidingTabStrip 的修改
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 的左右邊界。

  1. 修改 TabLayoutOnPageChangeListener 的 onPageScrolled 方法

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 的佈局更加清晰了,也體會到了改官方源碼的快感(笑)。寫這篇文章更可能是爲了趁剛寫完把本身的理解記錄下來,方便之後回看。若是可以幫助到有須要的人,那就更好了。

參考

相關文章
相關標籤/搜索