OpenWork Engineer Blog

OpenWork を運営するエンジニアによるテックブログです。

AppBarLayout + CollapsingToolbarLayoutで高さの違うTabLayoutを出し分ける

f:id:PEEE:20200110131624p:plain
流れるものと流れないもの

Androidエンジニアの藤樫です。

OpenWorkアプリの企業画面では、「企業トップ」「社員クチコミ」などが並ぶTabLayoutの表示がスクロール位置によって切り替わります。最初にページを開いた時は各タブに何件コンテンツが存在するかを表示して、最上部にある時は件数表示を省略してタブの高さを低くしています。ユーザーが特定のタブのコンテンツを見ている時は余分な情報を省いてコンテンツ領域を広げて見やすくしようという狙いがあります。

f:id:PEEE:20200110111340g:plain:w360

本記事では、この画面をどのように実装しているかを紹介します。

実装

以下のように単純化した画面で説明します。

f:id:PEEE:20200110111407p:plain:w360

CoordinatorLayoutAppBarLayoutAppBarLayout.ScrollingViewBehaviorを使えば、AppBarLayoutTabLayoutのスクロールをViewPagerのスクロールと連動させるAndroidアプリお馴染みのレイアウトが実現可能ですが、その際にTabLayoutの高さを変えるのは既存クラスを単純に組み合わせただけでは実現できなさそうです。例えばTabLayoutandroid:layout_height="wrap_content"を設定して、タブに2行タイトルが設定された状態でTabLayout.getTabAt(0).text = "1行タイトル"と設定したとしても、TabLayoutの高さは変わってくれません。

ではどうするかと言うと、以下のようなレイアウトを組んで、

<layout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <com.google.android.material.appbar.AppBarLayout
            android:id="@+id/appBarLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:stateListAnimator="@animator/appbar_elevation"
            android:background="@color/white">

            <com.google.android.material.appbar.CollapsingToolbarLayout
                android:id="@+id/collapsingToolbarLayout"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:layout_scrollFlags="scroll|exitUntilCollapsed">

                <!-- contentInsetStartを0にしないと左に不要な余白が表示される -->
                <androidx.appcompat.widget.Toolbar
                    android:layout_width="match_parent"
                    android:layout_height="44dp"
                    android:layout_gravity="top"
                    android:minHeight="44dp"
                    app:contentInsetStart="0dp"
                    app:layout_collapseMode="pin">

                    <com.google.android.material.tabs.TabLayout
                        android:id="@+id/tabLayoutCollapsed"
                        android:layout_width="match_parent"
                        android:layout_height="44dp"
                        android:visibility="invisible"
                        android:background="@color/white"
                        app:tabTextAppearance="@style/TabTextAppearance" />

                </androidx.appcompat.widget.Toolbar>

                <LinearLayout
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:orientation="vertical">

                    <TextView
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content"
                        android:padding="16dp"
                        android:background="@color/orangeLite"
                        android:textSize="24sp"
                        android:textColor="@color/black"
                        android:text="@string/app_bar_collapsible" />

                    <com.google.android.material.tabs.TabLayout
                        android:id="@+id/tabLayout"
                        android:layout_width="match_parent"
                        android:layout_height="60dp"
                        android:background="@color/white"
                        app:tabTextAppearance="@style/TabTextAppearance" />

                </LinearLayout>

            </com.google.android.material.appbar.CollapsingToolbarLayout>

        </com.google.android.material.appbar.AppBarLayout>

        <androidx.viewpager.widget.ViewPager
            android:id="@+id/viewPager"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="@string/appbar_scrolling_view_behavior" />

    </androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

以下のようなFragmentを書きます。

class AppBarTabsFragment : Fragment() {

    private lateinit var binding: FragmentAppBarTabsBinding

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentAppBarTabsBinding.inflate(inflater)

        val viewPager = binding.viewPager
        viewPager.adapter = AppBarTabsPagerAdapter(requireContext(), childFragmentManager)
        binding.tabLayout.setupWithViewPager(viewPager)
        binding.tabLayoutCollapsed.setupWithViewPager(viewPager)

        binding.appBarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener {

            private var isCollapsed: Boolean? = null

            override fun onOffsetChanged(appBar: AppBarLayout?, verticalOffset: Int) {
                val appBarHeight = appBar?.height ?: return
                val collapsedTabHeight = binding.tabLayoutCollapsed.height

                val shouldCollapse = appBarHeight + verticalOffset == collapsedTabHeight
                if (shouldCollapse == isCollapsed) {
                    // TabLayoutを切り替える必要がない場合は何もしない。
                    // レイアウト操作が頻繁に実行され過ぎて出るWarningを回避するため。
                    return
                }

                toggleTabVisibility(shouldCollapse)
                isCollapsed = shouldCollapse
            }
        })

        return binding.root
    }

    private fun toggleTabVisibility(shouldCollapse: Boolean) {
        if (shouldCollapse) {
            binding.tabLayout.visibility = View.INVISIBLE
            binding.tabLayoutCollapsed.apply {
                visibility = View.VISIBLE
                getTabAt(0)?.text = getString(R.string.tab_title_1)
                getTabAt(1)?.text = getString(R.string.tab_title_2)
            }
        } else {
            binding.tabLayout.visibility = View.VISIBLE
            binding.tabLayoutCollapsed.visibility = View.INVISIBLE
        }
    }
}

PagerAdapterとViewPagerで管理するFragmentでは特別なことはしていないので割愛します(強いて注意点を挙げるなら、ViewPagerで管理するFragmentはNestedScrollViewRecyclerViewじゃないとAppBarLayoutと連動しません)。

以下、ポイントの説明です。

CollapsingToolbarLayoutを利用しつつ高さが違う2つのTabLayoutを用意する

f:id:PEEE:20200110111419p:plain

CollapsingToolbarLayoutapp:layout_scrollFlags="scroll|exitUntilCollapsed"を設定し、子ViewのToolbarapp:layout_collapseMode="pin"を設定すると、Toolbarが上部に固定されたままAppBarLayoutがその裏に隠れるようにスクロールします。スクロールし切った状態ではToobarだけが表示された状態になります。

さらに、Toolbarの子Viewに低い方のタブを配置します。Toolbarはメニューやタイトルを使わずともただのViewGroupとしても利用できます。通常の最上部固定のアクションバーとしてToolbarを利用する以外ではわざわざただのViewGroupとして利用する理由はありませんが、アクションバー以外のUI要素に対してCollapsingToolbarLayoutを作用させたい場合は例外です。一つ注意することがあって、Toolbarはアクションバーとして利用する前提でデフォルトで先頭に余白が入るため、app:contentInsetStart="0dp"を設定してやる必要があります。

このようにレイアウトを組むと、初期状態からスクロールしていくと高い方のタブが低い方のタブの下に隠れて、最終的に低い方のタブが最上部に固定されたままViewPagerのコンテンツをスクロールできます。

AppBarLayoutのスクロール位置を監視してタブのタイトルとTabLayoutの可視性を切り替える

Toolbarapp:layout_collapseMode="pin"を設定していると、そのToolbarすなわち今回の場合低い方のタブは常に画面上部に固定されて表示されます。AppBarLayoutがスクロールし切った状態以外ではView.INVISIBLEにする必要があります。

AppBarLayoutがスクロールし切った状態」を検出するには、AppBarLayout.addOnOffsetChangedListener(AppBarLayout.OnOffsetChangedListener)を利用します。AppBarLayout.OnOffsetChangedListenerをセットすると、AppBarLayoutがスクロールする度にonOffsetChanged(appBar: AppBarLayout?, verticalOffset: Int)が呼ばれます。verticalOffsetAppBarLayoutの初期位置で0になり、上にスクロールされると移動分の負のpx値が渡されてきます。下の図のように、「AppBarLayoutの高さ+verticalOffset == 低い方のタブの高さ」となった時が、ちょうどAppBarLayoutがスクロールし切って低い方のタブだけが表示された状態となり、そのタイミングで高い方のタブと低い方のタブの可視性を入れ替えるとうまくいきます。AppBarLayoutTabLayoutの高さ(px)はheightで取得できます。

f:id:PEEE:20200110111425p:plain

なお、2つのTabLayoutには同じViewPagerを連携させていて共通のPagerAdapterが使われており、タブのタイトルはそこで設定されています。TabLayoutの可視性を変えただけではPagerAdapterのgetPageTitle()は呼ばれず、タブのタイトルを切り替える処理は別で書く必要があるため、onOffsetChanged()内でTabLayout.getTabAt(0).textを呼んでいます(あまりスマートではないですが…)。

もう1点AppBarLayout.OnOffsetChangedListenerで注意したいことは、タブの可視性を切り替える必要がない時はViewの操作は行わない方がいい、ということです。onOffsetChanged()AppBarLayoutがスクロール中に立て続けに呼ばれ、その度に毎回Viewの操作をしていると以下のような警告が大量に出ます。

W/View: requestLayout() improperly called by com.google.android.material.appbar.CollapsingToolbarLayout{43923e8 V.ED..... ........ 0,0-720,363 #7f080055 app:id/collapsingToolbarLayout} during second layout pass: posting in next frame
W/View: requestLayout() improperly called by androidx.appcompat.widget.AppCompatTextView{2b52a01 V.ED..... ..S..A.. 101,27-259,60} during second layout pass: posting in next frame
W/View: requestLayout() improperly called by androidx.appcompat.widget.AppCompatTextView{7b560a6 V.ED..... ........ 101,27-259,60} during second layout pass: posting in next frame

パフォーマンスにも影響しかねないので、前回変更した可視性の状態を保持しておいて、前回と同じ状態であれば即returnすることでViewの変更頻度を最低限に抑えられます。

完成した画面

f:id:PEEE:20200110111434g:plain:w360

まとめ

本記事では、OpenWorkアプリの企業画面におけるスクロールとタブの連携の実装について紹介しました。

  • CollapsingToolbarLayoutはアクションバーに対して使うだけでなく、Toolbarを単なるViewGroupと見なせば使いどころが広がる
  • AppBarLayoutAppBarLayout.OnOffsetChangedListenerでスクロールを監視して任意の処理を書ける

この仕様のように既存のAppBarLayout.ScrollingViewBehaviorだけでは実現できない仕様を実現するには、CoordinatorLayout.Behaviorを自分で実装するというアプローチもあります。最初はそのアプローチを検討したのですが、本記事のようにCollapsingToolbarLayoutを使う方がシンプルに書けたのでこちらを採用しました。一つの方法に固執するのでなく、複数の方法を検討して比較した上で採用することは重要で、複数思いつくにはある程度幅広い知識と経験が必要になってくるので、日々の学習を怠らないようにしたいものです。