Androidエンジニアの藤樫です。
OpenWorkアプリの企業画面では、「企業トップ」「社員クチコミ」などが並ぶTabLayout
の表示がスクロール位置によって切り替わります。最初にページを開いた時は各タブに何件コンテンツが存在するかを表示して、最上部にある時は件数表示を省略してタブの高さを低くしています。ユーザーが特定のタブのコンテンツを見ている時は余分な情報を省いてコンテンツ領域を広げて見やすくしようという狙いがあります。
本記事では、この画面をどのように実装しているかを紹介します。
実装
以下のように単純化した画面で説明します。
CoordinatorLayout
と AppBarLayout
とAppBarLayout.ScrollingViewBehavior
を使えば、AppBarLayout
とTabLayout
のスクロールをViewPager
のスクロールと連動させるAndroidアプリお馴染みのレイアウトが実現可能ですが、その際にTabLayout
の高さを変えるのは既存クラスを単純に組み合わせただけでは実現できなさそうです。例えばTabLayout
にandroid: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はNestedScrollView
かRecyclerView
じゃないとAppBarLayout
と連動しません)。
以下、ポイントの説明です。
CollapsingToolbarLayout
を利用しつつ高さが違う2つのTabLayout
を用意する
CollapsingToolbarLayout
にapp:layout_scrollFlags="scroll|exitUntilCollapsed"
を設定し、子ViewのToolbar
にapp:layout_collapseMode="pin"
を設定すると、Toolbar
が上部に固定されたままAppBarLayout
がその裏に隠れるようにスクロールします。スクロールし切った状態ではToobar
だけが表示された状態になります。
さらに、Toolbar
の子Viewに低い方のタブを配置します。Toolbar
はメニューやタイトルを使わずともただのViewGroup
としても利用できます。通常の最上部固定のアクションバーとしてToolbar
を利用する以外ではわざわざただのViewGroup
として利用する理由はありませんが、アクションバー以外のUI要素に対してCollapsingToolbarLayout
を作用させたい場合は例外です。一つ注意することがあって、Toolbar
はアクションバーとして利用する前提でデフォルトで先頭に余白が入るため、app:contentInsetStart="0dp"
を設定してやる必要があります。
このようにレイアウトを組むと、初期状態からスクロールしていくと高い方のタブが低い方のタブの下に隠れて、最終的に低い方のタブが最上部に固定されたままViewPager
のコンテンツをスクロールできます。
AppBarLayout
のスクロール位置を監視してタブのタイトルとTabLayout
の可視性を切り替える
Toolbar
にapp:layout_collapseMode="pin"
を設定していると、そのToolbar
すなわち今回の場合低い方のタブは常に画面上部に固定されて表示されます。AppBarLayout
がスクロールし切った状態以外ではView.INVISIBLE
にする必要があります。
「AppBarLayout
がスクロールし切った状態」を検出するには、AppBarLayout.addOnOffsetChangedListener(AppBarLayout.OnOffsetChangedListener)
を利用します。AppBarLayout.OnOffsetChangedListener
をセットすると、AppBarLayout
がスクロールする度にonOffsetChanged(appBar: AppBarLayout?, verticalOffset: Int)
が呼ばれます。verticalOffset
はAppBarLayout
の初期位置で0になり、上にスクロールされると移動分の負のpx値が渡されてきます。下の図のように、「AppBarLayout
の高さ+verticalOffset
== 低い方のタブの高さ」となった時が、ちょうどAppBarLayout
がスクロールし切って低い方のタブだけが表示された状態となり、そのタイミングで高い方のタブと低い方のタブの可視性を入れ替えるとうまくいきます。AppBarLayout
とTabLayout
の高さ(px)はheight
で取得できます。
なお、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の変更頻度を最低限に抑えられます。
完成した画面
まとめ
本記事では、OpenWorkアプリの企業画面におけるスクロールとタブの連携の実装について紹介しました。
CollapsingToolbarLayout
はアクションバーに対して使うだけでなく、Toolbar
を単なるViewGroup
と見なせば使いどころが広がるAppBarLayout
はAppBarLayout.OnOffsetChangedListener
でスクロールを監視して任意の処理を書ける
この仕様のように既存のAppBarLayout.ScrollingViewBehavior
だけでは実現できない仕様を実現するには、CoordinatorLayout.Behavior
を自分で実装するというアプローチもあります。最初はそのアプローチを検討したのですが、本記事のようにCollapsingToolbarLayout
を使う方がシンプルに書けたのでこちらを採用しました。一つの方法に固執するのでなく、複数の方法を検討して比較した上で採用することは重要で、複数思いつくにはある程度幅広い知識と経験が必要になってくるので、日々の学習を怠らないようにしたいものです。