パララックスイメージのAppBarをListViewを使って実装しようとしてハマった話

AppBar(Toolbar、ActionBar)の部分が大きめの画像になっていて、コンテンツをスクロールするとそれに合わせて画像が縮んでいき、最終的にToolbarだけが残る(もしくは全部隠れる)みたいなデザインがありますよね。あれを実装しようと思って試行錯誤してみました。

試行錯誤になってしまった原因は、スクロール可能なコンテンツ部分を横着してListViewで作ってしまったからでした。見かけるサンプルはだいたいRecyclerViewを使っていたのですが、使ったことがないため使い慣れているListViewでやろうとしたのが間違いでした。

ListViewで実装すると、ListViewをスクロールしてもAppBarは連動して動いてくれません。AppBarの部分をスクロールすると伸縮してはくれますが、巷にあふれるパララックスAppBarはこんな残念な動きはしていません。

コードで何か手を加えないといけないのだろうかと調べるうちに、なぜListViewではAppBarが連動して動かないのか原因が分かりました。今回はそのお話です。

Patterns– Scrolling techniques

layout.xmlの設定

基本的にパララックスなAppBarを実装するには、レイアウトXMLの記述のみで実装できます。

サンプルコード – GitHub

このMaterial Design(Android desgin support library)による階層構造を初めて見ると、なんだかややこしく感じてしまいますが、1つずつ紐解いていけばそう難しい構造ではありません。

正確にはandroid.support.design.widget.〜とFQCN(パッケージ名を含めたクラス指定)になりますが、ここでは長くなるので省略しています。

CoordinatorLayout
├AppBarLayout
│└CollapsingToolbarLayout
│ ├ImageView
│ └Toolbar
├ListView(などスクロール可能なコンテンツ)
└FABなどお好みで

基本的にXML上でちゃんと必要な指定さえ行えば動きます。コードは不要です。

CoordinatorLayout

今回の例ではListViewのスクロールにあわせてAppBarLayoutを伸縮させるために存在しています(FABをToolbarとListViewの中間に配置する役割も担っていますが)。このCoordinatorLayout自体は内包したView同士を連携させたりする単なる入れ物です。全然「単なる」ではないですけど。

AppBarLayout

AppBar部分のLayoutを管理するコンテナで、AppBarの部分に表示するViewをこの中に入れてやります。Blank Activityを作成すると、この中にはToolbarだけが入っていると思います。

ここではAppBarの高さを指定してやります。android:layout_height="192dp"

CollapsingToolbarLayout

折りたためるToolbarのための入れ物です。スクロールによるAppBarの動き方を指定することができます。ここではapp:layout_scrollFlags="scroll|exitUntilCollapsed"と指定しています。

ImageView

AppBarが全開のときに表示されるイメージ画像です。コンテンツのスクロールに合わせて縮み、最終的にToolbarだけが残ります。ここではapp:layout_collapseMode="parallax"を指定しています。

Toolbar

Toolbarです。ここではapp:layout_collapseMode="pin"を指定しています。この指定でToolbar自体は隠れずに残ります。

ListView

よく見かけるサンプルではRecyclerViewやNestedScrollViewが利用されています。しかし私はRecyclerViewの使い方がよくわからなかったのでListViewで代用しています。

ここでは必ずapp:layout_behavior="@string/appbar_scrolling_view_behavior"の指定が必要です。

この@string/〜はAndroid Support Libraryのstringリソースを参照していて、その中身はandroid.support.design.widget.AppBarLayout$ScrollingViewBehaviorとなっています。つまりこのListViewの振る舞いとして、AppBarLayoutのScrollingViewBehaviorを指定しているわけです。

ListViewを使うと、そのままではListViewがスクロールされるだけでAppBarが伸縮しません。ListViewのスクロールと連動させるためには、ListViewにandroid:nestedScrollingEnabled="true"を指定する必要があります。

なぜか。スクロール可能なコンテンツとAppBarの伸縮を連携させるためには、ListViewがスクロールされたということをCoordinatorLayoutに伝える必要があります。RecyclerViewやNestedScrollViewは標準でこれをやってくれるわけですが、ListViewは何もしません。そこでCoordinatorLayoutにスクロールイベントを通知するための設定を有効にしてやる必要があるのです。

android:nestedScrollingEnabled="true"(NestedScrollに関する処理)はAPI21以上のViewに実装されています。

余談:なぜAppBarが動くのか

仕組みを完全に理解したわけではないので、ざっくりとした説明です。

ListViewの上でスクロールを行うと、ListViewの中身がスクロールされます。これはListViewのonTouchEventで処理されています。これだけではListViewの中でスクロールイベントが処理されるだけで、AppBarの変形にはつながりません。

そこで登場するのがCoordinatorLayoutです。こいつが子Viewのスクロールと、別の子Viewを連携させるわけです。

連携させるためにはCoordinatorLayoutにスクロールイベントを通知する必要があり、その仕組がNestedScrollです。RecyclerViewやNestedScrollViewは初めからCoordinatorLayoutと連携する前提で作られていますし、ListViewなどでもSDK21からNestedScrollに関する処理が追加されています。ただし初期状態では無効化されているので、NestedScrollの処理を有効化してやる必要があり、それがandroid:nestedScrollingEnabled="true"になります。

NestedScrollの処理は子ViewのonTouchEvent(onTouchMove)でCoordinatorLayoutに伝わります。CoodinatorLayoutはonNestedPreScroll内でBehaviorが設定されている子Viewを探し、見つかったBehaviorに対してdispatchOnDependentViewChangedを呼び出します。今回の例ではScrollingViewBehaviorです。

最終的にAppBarのサイズを伸縮させる処理は、このBehaviorのonDependentViewChangedで行われているみたいです。

参考

Handling Scrolls with CoordinatorLayout

Amazonのほしいものリストを公開しています。仕事で欲しいもの、単なる趣味としてほしいもの、リフレッシュのために欲しいものなどを登録しています。 寄贈いただけると泣いて喜びます。大したお礼はできませんが、よりよい情報発信へのモチベーションに繋がりますので、ご検討いただければ幸いです。