<-- mermaid -->

AndroidのTabLayoutをカラフルにカスタマイズしてみた

このブログは NewsPicks Advent Calendar 2022 8日目の記事です。

qiita.com

こんにちは。NewsPicks Androidアプリエンジニアの sefwgweo です。
今回はタイトル通りAndroidのTabLayoutをカラフルにカスタマイズした話をします。

要件

  1. タブの数だけタブ部分を含めたヘッダー部分の背景色を変えたい
  2. 細かいデザイン仕様は以下とします
      2-1. ヘッダー部分とは、ステータスバー、ツールバー、タブの3つをあわせた部分とする
      2-2. タブの背景色はベースとなる色に対してアルファ付きのOverlayを乗せること
      2-3. 背景色に応じてヘッダー領域内のアイコン、文字色、タブインジケータ色を白か黒にすること
      2-4. 横スワイプでタブ移動時、隣り合う色を移動位置によってブレンド比率を変えながら色が変わること

以下完成動画

苦労ポイント

  1. ステータスバー文字色の動的変更が意外に面倒だった
  2. タブスワイプによる挙動があったためMVVMで書こうとするとViewModelにActivityを渡す必要があったりで、チーム内ルール的にアウトだった
  3. onPageScrolledとonPageSelectedそれぞれに書くべき処理をしっかり書かないとインジケータが消えたりした

タブの見た目詳細を構成しているViewModel:TabViewModel.kt

テストのため、赤・白・黒のタブと、文字・アイコン色と、表示文字列をこのクラスでハードコードしています。

TabDto(
    "Red",
    "#FF0000",
    "タブ1"
),
TabDto(
    "White",
    "#FFFFFF",
    "タブ2"
),
TabDto(
    "Black",
    "#FF000000",
    "タブ3"
),

本件の仕様を満たすためのメイン処理が書かれたクラス:TabFragment.kt
ViewPager+TabLayoutを構築。以下はステータスバーの色を設定している箇所になります。

class TabFragment : Fragment(R.layout.fragment_tab) {
...
    // ステータスバー色(1段目)
    requireActivity().window.apply {
        statusBarColor = bgColor
        // 背景色に応じてステータスバー表示文字色をかえる
        WindowInsetsControllerCompat(this, decorView).isAppearanceLightStatusBars =
          tabColors.systemUiFlagLightStatusBar
    }
...
}

よくみる方法としては以下な感じです。これだとTargetSDKによってはFLAG_TRANSLUCENT_STATUSとsystemUiVisibilityがDeprecatedとなってしまっています。
回避するには↑のように記載する必要がありました。

activity.window.apply {
  clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
  addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
}

val textColor = fetchTextColor()
var flags: Int = activity.window.decorView.systemUiVisibility
flags = if (textColor == Color.WHITE) {
    0
} else {
    flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
}

// ステータスバーの背景色に応じて文字色をかえる
activity.window.decorView.systemUiVisibility = flags

次はタブスワイプによる色変更まわりです。
以下にあるように、ViewPager2.OnPageChangeCallback#onPageScrolled内に処理を書くことで目的が達成できますが、1点注意があります。
onPageScrolledとonPageSelectedで処理を分けているのは、どちらかのみにすべての処理を書いてしまうと期待しない動作をすることになります。
具体的には、すべての処理をonPageScrolled又はonPageSelectedでやろうとするとタブインジケータが消えてしまいます。

class TabFragment : Fragment(R.layout.fragment_tab) {
…
binding.pager.apply {
...
    adapter = pagerAdapter
    offscreenPageLimit = pagerAdapter.itemCount
    registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
        override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
            super.onPageScrolled(position, positionOffset, positionOffsetPixels)
            setSellingTabBackgroundColor(positionOffset, position)
        }
        override fun onPageSelected(position: Int) {
            super.onPageSelected(position)
            setIdleTabColors(position)
        }
    })

private fun setSellingTabBackgroundColor(positionOffset: Float, currentPosition: Int) {
    val baseBgColor = TabColorCalculator.parseColor(tabList[currentPosition].bgColor)
    var nextTabBackgroundColor = baseBgColor
    if (positionOffset > 0f && currentPosition < tabList.size - 1) {
        nextTabBackgroundColor = TabColorCalculator.parseColor(tabList[currentPosition + 1].bgColor)
    }
    val bgColor = ColorUtils.blendARGB(nextTabBackgroundColor, baseBgColor, 1f - positionOffset)
    val tabColors = TabColorCalculator(bgColor).getTabColors()

    // ステータスバー色(1段目)
    requireActivity().window.apply {
        statusBarColor = bgColor
        // 背景色に応じてステータスバー表示文字色をかえる
        WindowInsetsControllerCompat(this, decorView).isAppearanceLightStatusBars =
            tabColors.systemUiFlagLightStatusBar
    }
    // toolBar色(2段目)
    binding.toolbar.setBackgroundColor(bgColor)
    // タブ背景色(3段目)
    val overlayColor = ContextCompat.getColor(requireContext(), TabColorCalculator(baseBgColor).getTabColors().overlay)
    val overlayColorAddAlpha = Color.argb(tabColors.alpha, Color.red(overlayColor), Color.green(overlayColor), Color.blue(overlayColor))
    val bgOverlayColor = ColorDrawable(bgColor).apply {
        colorFilter = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            BlendModeColorFilter(overlayColorAddAlpha, BlendMode.SRC_OVER)
        } else {
            PorterDuffColorFilter(overlayColorAddAlpha, PorterDuff.Mode.SRC_OVER)
        }
    }
    binding.tabLayout.background = bgOverlayColor
}

private fun setIdleTabColors(position: Int) {
    val tabColors = TabColorCalculator(TabColorCalculator.parseColor(tabList[position].bgColor)).getTabColors()

    // タブインジケーター色
    binding.tabLayout.setSelectedTabIndicatorColor(ContextCompat.getColor(requireContext(), tabColors.selectedText))

    // タブテキスト色
    binding.tabLayout.setTabTextColors(
        ContextCompat.getColor(requireContext(), tabColors.unSelectedText),
        ContextCompat.getColor(requireContext(), tabColors.selectedText)
    )

    // Menuアイコン
    binding.menuButtonIcon.imageTintList = ColorStateList(
        arrayOf(intArrayOf(android.R.attr.state_enabled), intArrayOf( android.R.attr.state_pressed)),
        intArrayOf(
            ContextCompat.getColor(requireContext(), tabColors.icon),
            ContextCompat.getColor(requireContext(), tabColors.icon)
        )
    )
  }
}

最後に、色の輝度からアイコンと文字色を決定している部分を説明します
色計算用:TabColorCalculator.kt

輝度を求めて、それをもとにしきい値によってもろもろの色を決定しています。blackTextIsBetterというメソッドを使うことでなるべくアイコン色・文字色が背景色に 埋もれないようにしています。

    /**
     * colorを元に輝度を求める
     *
     * @return 輝度 Double
     */
    private fun getLightness(): Double {
        val colors = mutableListOf(
            Color.red(backgroundColor),
            Color.green(backgroundColor),
            Color.blue(backgroundColor)
        )
        val max = colors.maxOrNull() ?: 0
        val min = colors.minOrNull() ?: 0
        return (max + min).toDouble() / (2 * 255).toDouble()
    }

    /**
     * 背景色によって、白文字列 or 黒文字列が良いかを判断する.<br></br>
     * 以下のW3Cのロジックを利用。
     * https://www.w3.org/WAI/ER/WD-AERT/#color-contrast
     *
     * @return 黒を指定した方が良い場合にtrue
     */
    private fun blackTextIsBetter(): Boolean {
        val red = Color.red(backgroundColor)
        val green = Color.green(backgroundColor)
        val blue = Color.blue(backgroundColor)
        val delta = (red * 299 + green * 587 + blue * 114) / 1000
        return delta >= 125
    }

おまけ

ColorUtils.blendARGBというメソッドはAという色とBという色を文字通り混ぜる(赤と青を混ぜて紫にする)というメソッドなのですが
Overlayさせるのとはそもそも結果が異なるということを知らなかったので学びでした。
※タブ背景色はPorterDuffによる重ねにより色を表現しています。

以上がコードによる全容です。もしもMVVM不使用かつTabLayoutで同様の問題を抱えている場合の1つの解として参考になれば幸いです。

以下に今回解説したもののリポジトリを載せておきます。

github.com

まとめ

今回も結構マニアックなケースについて取り上げてみましたがいかがだったでしょうか    こちらの記事が同様の機能をこれから開発する開発者様の助けになり、また弊社のサービスに興味を持っていただけるきっかけになりましたら幸いです

おわりに

ユーザベースではエンジニアを募集しています。ご興味ある方いらっしゃいましたらこちらからぜひご応募いただければと思います!

Page top