TabLayoutのインジケーターをカスタマイズする

Material ComponentsにはTabLayoutというものがあり、ViewPagerなどと組み合わせてタブ切り替えのUIを提供します。
標準的な見た目は以下のようになっています。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/b134c5cc-fe09-4687-671b-3f66ebe582dc.gif)

このように、標準では選択されているタブを示すインジケーターがアンダーラインで表示され、選択した場所へアニメーションを伴って移動します。

一方、iOS版のGoogleMapではタブがこんな動作をしています。（Android版はアニメーションしない）
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/7585ab77-1f64-1083-020e-66a16ee6cdab.gif)

これの実装がどうなっているのか、iOSに詳しくないこともあって分かりませんが、Segmented Controlが似た動きをしていますね。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/f4e82c68-6e51-dbe1-9490-981132df9f51.gif)

複数の選択肢があって、それを選択するとそこにボタン状の背景がアニメーションで移動する。というUIですね。

これをAndroidで実装するのは難しいと思うのですが、TabLayoutでどこまで頑張れるのか調べたのでまとめます。

## タブの中身の工夫

いきなりインジケーターから外れますが、タブの中身をカスタマイズすることで選択されているタブを示す表示ができます。

### テキストの色

XMLからなら`app:tabTextColor`で選択されていないタブのテキストの色を、`app:tabSelectedTextColor`で選択されたタブのテキストの色を指定することができます。コードからなら`void setTabTextColors(int normalColor, int selectedColor)`か、`void setTabTextColors(@Nullable ColorStateList textColor)`で２色を指定します。

```xml
<com.google.android.material.tabs.TabLayout
    android:id="@+id/tabs"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:tabSelectedTextColor="@color/colorPrimary"
    app:tabTextColor="@color/black"
    />
```
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/f5da41ba-3910-4113-066e-11ab59a4304e.png)

### アイコンの色

アイコンを設定している場合は、XMLから`app:tabIconTint`、コードからは`void setTabIconTint(@Nullable ColorStateList iconTint)`か`void setTabIconTintResource(@ColorRes int iconTintResourceId)`で指定できます。

```xml:fg_tab
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:color="@color/colorPrimary" android:state_selected="true"/>
    <item android:color="@color/black" android:state_selected="false"/>
</selector>
```

```xml
<com.google.android.material.tabs.TabLayout
    android:id="@+id/tabs"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:tabSelectedTextColor="@color/colorPrimary"
    app:tabTextColor="@color/black"
    app:tabIconTint="@color/fg_tab"
    />
```

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/5cb64b98-bc6d-128f-873c-9b2448cf6243.png)

### 背景の指定

XMLから`app:tabBackground`でタブの背景色を指定することができます。コードから設定する手段はなさそう？
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/fd0c90b5-8ba1-403a-98b9-e94a69dacea8.png)


### CustomViewの場合

CustomViewを表示させている場合、当然ながら前述の`setTabTextColors`や`setITabIconTint`は使えませんが、選択されているタブはselected状態なのでStateListを使って選択状態に応じて表示を変えることができます。

っとその前に、CustomViewを使う場合、あらかじめ設定されているパディングに注意する必要があります。CustomViewに背景を設定してみると以下のようにタブの間に結構広めのパディングがあります。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/6588935a-cd7a-a32d-d299-773b5fcf1614.png)

このパディングを除去するためには、`app:tabPaddingStart``app:tabPaddingEnd`を指定します。`app:tabPadding`ではパディングが残るので注意

```xml
<com.google.android.material.tabs.TabLayout
    android:id="@+id/tabs"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:tabPaddingStart="0dp"
    app:tabPaddingEnd="0dp"
    />
```

その上で、カスタムViewの背景を指定するとそれっぽい表示ができたりします。

```xml
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_selected="true">
        <shape android:shape="rectangle">
            <corners android:radius="20dp"/>
            <solid android:color="#20304FFE"/>
            <stroke android:color="#304FFE" android:width="1dp"/>
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle"/>
    </item>
</selector>
```
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/c246aa43-9b92-ede9-ca29-3df9982d71ab.png)

ただし、背景はアニメーションしてくれません。
またこの場合、アンダーラインのインジケーターが邪魔ですね。後述するようにインジケーターはDrawableとして指定できるので、nullを渡すなどすれば消すことができます。

```xml
<com.google.android.material.tabs.TabLayout
    android:id="@+id/tabs"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:tabPaddingStart="0dp"
    app:tabPaddingEnd="0dp"
    app:tabIndicator="@null"
    />
```

## インジケーターのカスタイマイズ

### 色

インジケーターの色は特に指定しなければ`colorAccent`の色になります。
変更したい場合は、XMLで`app:tabIndicatorColor`、コードからは`void setSelectedTabIndicatorColor(@ColorInt int color)`で変更することができます。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/48d7ffb3-b367-6067-e767-f84c23ae3efe.png)

### 表示位置

XMLから`app:tabIndicatorGravity`、コードから`void setSelectedTabIndicatorGravity(@TabIndicatorGravity int indicatorGravity)`でインジケーターの表示位置を変更することができます。
取り得る値は、xmlの場合、`bottom` / `center` / `top` / `stretch`、コードからはTabLayoutの定数`INDICATOR_GRAVITY_BOTTOM` / `INDICATOR_GRAVITY_CENTER` / `INDICATOR_GRAVITY_TOP` / `INDICATOR_GRAVITY_STRETCH` を指定することができます。
通常はbottomですね。

center：打ち消し線みたいになっちゃってます
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/8382b0a9-9bb4-46ce-9a69-69487da0f552.png)

top：上付き
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/d7364801-b526-269d-e664-7a7252bbe528.png)

stretch：塗りつぶしになってしまうのでテキストの色を変えています
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/c8130cc5-6803-c043-6498-f39510f2641c.png)

### 高さ

XMLから`app:tabIndicatorHeight`、コードから`void setSelectedTabIndicatorHeight(int height)`でインジケーターの高さ（太さ）を指定できます。デフォルトは2dpのようです。ただしDeprecatedです。drawableを指定することができるので、変更したい場合は形状含めてそちらで指定すべきと言うことのようです。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/929d4158-6585-45b2-b08d-2f3b495312f4.png)

### 形状

XMLから`app:tabIndicator`、コードから`void setSelectedTabIndicator(@DrawableRes int tabSelectedIndicatorResourceId)`もしくは`void setSelectedTabIndicator(@Nullable Drawable tabSelectedIndicator)`で、indicatorとして表示する形状を指定することができます。
表示する大きさ（高さ）は(Gravityがstretch以外の場合)DrawableのintrinsicHeightが使われますので、Drawableを作る時に注意してください、GradientDrawableはsizeタグがない場合、intrinsicHeightが0になってしまうので表示されません。IndicatorHeightで指定することもできますが、こちらはDeprecatedです。
Drawableはあくまで形状を指定するのみで、描画する色はIndicatorColorが使われます。不透明度はだけは残りますが、色の情報は失われます。Drawableの色をどんなに頑張って指定しても反映されないので注意しましょう。

こんなGradientDrawableを定義すると、少し丸みを帯びたIndicatorにすることができます。（色は関係ないので黒を指定しています）

```xml
<shape
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/black"/>
    <corners android:topLeftRadius="4dp" android:topRightRadius="4dp"/>
    <size android:height="4dp"/>
</shape>
```
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/9ea6e85e-281c-e994-a382-5721236fe6a8.png)

### 形状＋stretch

tabIndicatorGravityをstretchするとタブの大きさでindicatorが表示されます。これを利用してボタン形状を表示させることができます。

タブの大きさいっぱいに表示されてしまうのでlayer-listで囲んで周囲のマージンを設定します。

```xml
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:bottom="8dp" android:left="8dp" android:right="8dp" android:top="8dp">
        <shape android:shape="rectangle">
            <solid android:color="#20000000" />
            <stroke android:width="1dp" android:color="#000000" />
            <corners android:radius="20dp" />
        </shape>
    </item>
</layer-list>
```

```xml
<com.google.android.material.tabs.TabLayout
    android:id="@+id/tabs"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:tabSelectedTextColor="@color/colorPrimary"
    app:tabTextColor="@color/black"
    app:tabIndicator="@drawable/indicator"
    app:tabIndicatorGravity="stretch"
    />
```

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/e786839c-8848-aac9-d789-67c7d52d603f.gif)

これでそれっぽく表示させることができました。

背景も併せてカスタマイズすると、スライドスイッチ風の表示もできますね。ドロップシャドウが使えないのがちょっと残念です。またスイッチ風にする場合、マージンを詰めて角丸部分をオーバーラップさせたいところですがそれも無理ですね。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/43569173-0358-f600-ed05-a7cc45575815.gif)


## その他

### リップル指定

XMLから`app:tabRippleColor`、コードから`void setTabRippleColor(@Nullable ColorStateList color)`でタブをタップしたときのリップルエフェクトの色を指定することができます。

```xml
<com.google.android.material.tabs.TabLayout
    android:id="@+id/tabs"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:tabSelectedTextColor="@color/colorPrimary"
    app:tabTextColor="@color/black"
    app:tabIndicator="@drawable/indicator"
    app:tabIndicatorGravity="stretch"
    app:tabRippleColor="#10304FFE"
    />
```

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/2be0e03e-81b0-47d9-d9c0-8424fd3a6b46.gif)

また、通常はrippleの境界がtabのエリアになっていますが、`app:tabUnboundedRipple="true"`を指定することで、tabのエリアを越えて境界線を丸くすることができます。

```xml
<com.google.android.material.tabs.TabLayout
    android:id="@+id/tabs"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@android:color/transparent"
    app:tabIndicator="@drawable/indicator"
    app:tabIndicatorGravity="stretch"
    app:tabSelectedTextColor="@color/colorPrimary"
    app:tabTextColor="@android:color/black"
    app:tabRippleColor="#20304FFE"
    app:tabUnboundedRipple="true"
    />
```

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/2904d9fa-3391-e96f-0c61-e93ea8c725fd.gif)

`app:tabUnboundedRipple="true"`を指定した場合、rippleはtabの大きさを超えて広がります。何も指定しないと最大でTabが内接する円の大きさまで広がります。
この範囲を限定したい場合は、backgroundを指定します。指定するのは透過色（`@android:color/transparent`）でもかまいません。TabLayoutにbackgroundを指定すればTabLayoutの範囲内に収まります。TabLayoutにはbackgroundを指定しないで、その親レイアウトでbackgroundを指定すると、その親の範囲内で広がります。

backgroundがない場合|TabLayoutにbackgroundを指定した場合
:-:|:-:
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/eee4d6cb-ba85-81ce-ed18-789b51e0969a.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/14696037-1b6f-2b03-0c70-da8501a73344.png)

## もう一歩

さて、大分当初の目的は達成できたような気がしないでもないのですが、`app:tabIndicator`で指定できるdrawableは形状（＋透過率）しか反映されず、`app:tabIndicatorColor`で塗りつぶされてしまうため、単色しか使うことができないですね。これが任意のDrawableを指定できるようになれば……ということでインジケータの描画がどのように行われるか、実装を調べてみます。

```java:TabLayout.java
Drawable selectedIndicator;
selectedIndicator =
    DrawableCompat.wrap(
        tabSelectedIndicator != null ? tabSelectedIndicator : defaultSelectionIndicator);
selectedIndicator.setBounds(indicatorLeft, indicatorTop, indicatorRight, indicatorBottom);
if (selectedIndicatorPaint != null) {
  if (VERSION.SDK_INT == VERSION_CODES.LOLLIPOP) {
    // Drawable doesn't implement setTint in API 21
    selectedIndicator.setColorFilter(
        selectedIndicatorPaint.getColor(), PorterDuff.Mode.SRC_IN);
  } else {
    DrawableCompat.setTint(selectedIndicator, selectedIndicatorPaint.getColor());
  }
}
selectedIndicator.draw(canvas);
```

`setColorFilter`もしくは`setTint`で描画色が上書きされてしまっていますね。（selectedIndicatorPaintがnullなら設定されなさそうですが、selectedIndicatorPaintはNonNullです）`DrawableCompat.wrap`を挟んでいるのもめんどくさそうです。`DrawableCompat.wrap`の実装は以下のようになっています。

```java:DrawableCompat.java
public static Drawable wrap(@NonNull Drawable drawable) {
    if (Build.VERSION.SDK_INT >= 23) {
        return drawable;
    } else if (Build.VERSION.SDK_INT >= 21) {
        if (!(drawable instanceof TintAwareDrawable)) {
            return new WrappedDrawableApi21(drawable);
        }
        return drawable;
    } else {
        if (!(drawable instanceof TintAwareDrawable)) {
            return new WrappedDrawableApi14(drawable);
        }
        return drawable;
    }
}
```

TintAwareDrawableであればそのまま使われる……ということで、TintAwareDrawableを実装してTintを無視するDrawableWrapperを作ってやればいいんじゃないか？ってことでやってみました。
最低限使われているメソッドだけ実装すればいいのでここまでやる必要は無いでしょうが、`WrappedDrawableApi21`を参考に実装します。

```kotlin
class TintDisabledDrawableWrapper(
    private val wrappedDrawable: Drawable
) : Drawable(), TintAwareDrawable {
    override fun getDirtyBounds(): Rect = wrappedDrawable.dirtyBounds
    override fun jumpToCurrentState() = wrappedDrawable.jumpToCurrentState()
    override fun draw(canvas: Canvas) = wrappedDrawable.draw(canvas)
    override fun getChangingConfigurations(): Int = wrappedDrawable.changingConfigurations
    override fun isStateful(): Boolean = wrappedDrawable.isStateful
    override fun setState(stateSet: IntArray): Boolean = wrappedDrawable.setState(stateSet)
    override fun getState(): IntArray = wrappedDrawable.state
    override fun getCurrent(): Drawable = wrappedDrawable.current
    override fun getOpacity(): Int = wrappedDrawable.opacity
    override fun getTransparentRegion(): Region? = wrappedDrawable.transparentRegion
    override fun getIntrinsicWidth(): Int = wrappedDrawable.intrinsicWidth
    override fun getIntrinsicHeight(): Int = wrappedDrawable.intrinsicHeight
    override fun getMinimumWidth(): Int = wrappedDrawable.minimumWidth
    override fun getMinimumHeight(): Int = wrappedDrawable.minimumHeight
    override fun getPadding(padding: Rect): Boolean = wrappedDrawable.getPadding(padding)
    override fun isAutoMirrored(): Boolean = wrappedDrawable.isAutoMirrored
    override fun getConstantState(): ConstantState? = null
    override fun mutate(): Drawable = wrappedDrawable.mutate()
    override fun onLevelChange(level: Int): Boolean = wrappedDrawable.setLevel(level)
    override fun setTint(tint: Int) = Unit
    override fun setTintList(tint: ColorStateList?) = Unit
    override fun setTintMode(tintMode: PorterDuff.Mode?) = Unit
    override fun setHotspot(x: Float, y: Float) {
        wrappedDrawable.setHotspot(x, y)
    }
    override fun setHotspotBounds(left: Int, top: Int, right: Int, bottom: Int) {
        wrappedDrawable.setHotspotBounds(left, top, right, bottom)
    }
    override fun getOutline(outline: Outline) {
        wrappedDrawable.getOutline(outline)
    }
    override fun onBoundsChange(bounds: Rect) {
        wrappedDrawable.bounds = bounds
    }
    override fun setChangingConfigurations(configs: Int) {
        wrappedDrawable.changingConfigurations = configs
    }
    override fun setDither(dither: Boolean) {
        wrappedDrawable.setDither(dither)
    }
    override fun setFilterBitmap(filter: Boolean) {
        wrappedDrawable.isFilterBitmap = filter
    }
    override fun setAlpha(alpha: Int) {
        wrappedDrawable.alpha = alpha
    }
    override fun setColorFilter(cf: ColorFilter?) {
        wrappedDrawable.colorFilter = cf
    }
    override fun setAutoMirrored(mirrored: Boolean) {
        wrappedDrawable.isAutoMirrored = mirrored
    }
    override fun setVisible(visible: Boolean, restart: Boolean): Boolean =
        wrappedDrawable.setVisible(visible, restart)
}
```

これにDrawableを包んで指定します。

```kotlin
tabs.setSelectedTabIndicator(TintDisabledDrawableWrapper(getDrawable(R.drawable.indicator)!!))
```

indicatorは適当に2色使ってみます

```xml
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:bottom="8dp" android:left="8dp" android:right="8dp" android:top="8dp">
        <shape android:shape="rectangle">
            <solid android:color="#50ffff00" />
            <stroke android:width="1dp" android:color="#0000ff" />
            <corners android:radius="20dp" />
        </shape>
    </item>
</layer-list>
```

これで、Drawableの色を直接描画させてやることができました。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/d2c4517f-355a-16cc-90ee-1d351a6d695b.gif)

標準ボタンの背景（画像）でもいけます。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/d89ae039-12f8-b052-3d0d-601d82ae89fa.gif)

ただし、これはTabLayoutの実装を調べてその裏をつくような細工な訳なので、TabLayoutの実装が変わったりすると使えなくなるなどの問題が起こる可能性があります。ちょっとこれを本番アプリに実装しようとは思えないですね。。。

## スクロールさせる場合

インジケータそのものとはちょっと違いますが、タブ3つぐらいまでなら一つの画面に収まりますが、収まらない場合は、`app:tabMode="scrollable"`を指定することでタブ全体をスクロールさせるようになります。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/6de77f19-d7a9-72aa-b937-ff154baa4abf.gif)

`app:tabMode="auto"`なら画面内に収まるまでは画面幅をいっぱいに使い、収まらなくなるとスクロールするように自動的に変わってくれます。

スクロールさせる場合に、最初や最後のタブを選択している場合に、画面端ではなく、すこしパディングを開けたい場合があります。この場合の常套手段として、paddingを設定した上で`android:clipToPadding="false"`を設定するというものがありますが

```xml
<com.google.android.material.tabs.TabLayout
...
    android:paddingStart="64dp"
    android:paddingEnd="64dp"
    android:clipToPadding="false"
    app:tabMode="scrollable"
    />
```

これでもそれっぽく動くのですが、困ったことに、スクロール中中央にあった現在タブの位置がpaddingStart分だけ中央からずれてしまいます。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/10d36acd-d53e-6267-ad3a-7749b115584d.gif)

これを回避するには、`android:paddingStart`の代わりに、`app:tabContentStart`を指定します。

```xml
<com.google.android.material.tabs.TabLayout
...
    android:clipToPadding="false"
    android:paddingStart="0dp"
    android:paddingEnd="64dp"
    app:tabContentStart="64dp"
    app:tabMode="scrollable"
    />
```

これで、開始と末尾にマージンが設定され、スクロール中のインジケータの位置がずれることがありません。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/385e30fd-d578-c11d-9549-dd4dbb095d04.gif)

## まとめ

以上、TabLayoutのインジケーターをカスタマイズする方法を紹介しました。
あらかじめ用意されている機能だけでも想像以上にいろいろと変更することができるんですね。