2015年12月18日

Android

AndroidのCoordinatorLayoutを使いこなして、モダンなスクロールを実装しよう

  • このエントリーをはてなブックマークに追加

Yahoo! JAPAN Tech Advent Calendar 2015の18日目の記事です。一覧はこちら

こんにちは。
ヤフー株式会社メディアカンパニー スタートページ事業本部 エントリーポイント開発部でAndroidアプリの開発を行っている毛受(めんじゅ) (takahirom)です。
皆さんはChromeやGoogle Playアプリなどで、アプリ内でスクロールした時にツールバーが見え隠れするのを見たことがあるでしょうか? 現在自分の担当しているアプリでそのようなレイアウトを検討しているので詳細を調べてみました。

はじめに

Google I/O 2015でAndroid Design Support Libraryが発表されました。Android Design Support Libraryには、Material Designを実装するためのさまざまなコンポーネントが存在します。それらのコンポーネントの中にCoordinatorLayoutというレイアウトが存在します。これはスクロールした時にバーを隠すなどさまざまなことができるレイアウトです。今回は、そのCoordinatorLayoutに焦点を当て、仕組みを含めて紹介していきます。

前提条件
・Android Design Support Library 23.1.1
・Android Support Library v7 23.1.1
・minSdkVersion 14(Android 4.0以上)

モジュールのbuild.gradleファイルに以下の記述を追記してdependenciesを追加します。

dependencies {
    compile "com.android.support:appcompat-v7:23.1.1"
    compile "com.android.support:design:23.1.1"
}

CoordinatorLayoutとは

CoordinatorLayoutのCoordinatorには調整者やまとめ役という意味があります。
CoordinatorLayoutはAndroidのLinearLayoutなどのViewGroupの種類の一つで、
CoordinatorLayoutを使うことで、子Viewの大きさや位置を動的に管理できます。
デフォルトのプロジェクトのテンプレートなどの例を見ながらこのレイアウトを理解していきましょう。

Android StudioのScrollingActivityのレイアウト

まずはAndroid Studioのデフォルトのテンプレートで作成できるScrollingActivityを見ていきましょう。
こちらを作成するにはAndroidStudioを起動し、
File -> New -> Activity -> Scrolling Activity
で作成することができます。

loop_scroll_exitUntilCollapsed.gif

レイアウトファイルは以下のファイルとなります。

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    >

    <android.support.design.widget.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="@dimen/app_bar_height"
        android:fitsSystemWindows="true"
        android:theme="@style/AppTheme.AppBarOverlay">

        <android.support.design.widget.CollapsingToolbarLayout
            android:id="@+id/toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"
                app:popupTheme="@style/AppTheme.PopupOverlay" />

        </android.support.design.widget.CollapsingToolbarLayout>
    </android.support.design.widget.AppBarLayout>

    <include layout="@layout/content_scrolling" />

    <android.support.design.widget.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="@dimen/fab_margin"
        android:src="@android:drawable/ic_dialog_email"
        app:layout_anchor="@id/app_bar"
        app:layout_anchorGravity="bottom|end" />

</android.support.design.widget.CoordinatorLayout>

シンプルに構造だけ記述すると以下のようなレイアウトになっています。

<CoordinatorLayout>
    <AppBarLayout>
        <CollapsingToolbarLayout>
            <Toolbar />
        </CollapsingToolbarLayout>
    </AppBarLayout>
    <include layout="@layout/content_scrolling" />
    <FloatingActionButton/>
</CoordinatorLayout>

本格的にCoordinatorLayoutの説明をする前に、このレイアウトの中を説明してきます。

AppBarLayout

AppBarLayoutは画像中の青色の部分のレイアウトになります。
AppBarLayoutはLinearLayoutを継承しており、縦並びのレイアウトとなっています。
AppBarLayoutの子View(今回はCollapsingToolbarLayout)にレイアウトxmlでlayout_scrollFlagsを設定することで、スクロールした時の動きを変えることができます。
ではこれから、それらの動きを見ていきましょう。

exitUntilCollapsed

AppBarLayoutの子Viewにlayout_scrollFlags=”scroll|exitUntilCollapsed”をつけた場合、スクロール可能なViewが一番上までスクロールした時に、スクロールに応じて子ViewのminHeightまでAppBarLayoutは小さくなります。

今回のサンプルで使用されているCollapsingToolbarLayoutでは内部で子ViewであるToolbarの高さを利用して自身のminHeightを決定しています。
このminHeightを基にAppBarLayoutの表示上の最小の高さが決定されています。

loop_scroll_exitUntilCollapsed.gif

enterAlways

layout_scrollFlags=”scroll|enterAlways”をつけた場合、minHeightなどと関係なく、スクロールに応じてAppBarLayoutは小さくなります。
また下にスクロールしようとするとすぐに表示されるようになります。この動きはクイックリターンと呼ばれています。
loop_scroll_enterAlways.gif

enterAlwaysCollapsed

layout_scrollFlags=”scroll|enterAlwaysCollapsed”をつけた場合 、こちらもminHeightなどと関係なく小さくなりますが、スクロール可能なViewで一番上までスクロールしていくと表示されます。
loop_scroll_enterAlwaysCollapsed.gif

組み合わせる

例えばタブはすぐに出てきて、ツールバーは上にスクロールするまでは出さないというレイアウトを行いたいとします。このような場合は、上記のフラグを組み合わせて実現します。

loop_scroll_mix.gif

AppBarLayout内を以下のように設定して、AppBarLayout内にTabLayoutを追加することで可能になります。
CollapsingToolbarLayoutにenterAlwaysCollapsedをつけることによって上までスクロールした時に表示され、TabLayoutにenterAlwaysをつけることによってスクロールに応じてすぐに表示されるレイアウトを実装できます。

    <android.support.design.widget.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="@dimen/app_bar_height"
        android:fitsSystemWindows="true"
        android:theme="@style/AppTheme.AppBarOverlay">

        <android.support.design.widget.CollapsingToolbarLayout
            android:id="@+id/toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|enterAlwaysCollapsed">

            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"
                app:popupTheme="@style/AppTheme.PopupOverlay" />
        </android.support.design.widget.CollapsingToolbarLayout>

        <android.support.design.widget.TabLayout
            android:id="@+id/tabs"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:fitsSystemWindows="true"
            app:layout_scrollFlags="scroll|enterAlways" />
    </android.support.design.widget.AppBarLayout>

app:layout_scrollFlags周辺と構造だけ取り出すと以下の様になっています。

<CoordinatorLayout>
    <AppBarLayout>
        <CollapsingToolbarLayout
            app:layout_scrollFlags="scroll|enterAlwaysCollapsed">
            <Toolbar />
        </CollapsingToolbarLayout>
        <TabLayout
            app:layout_scrollFlags="scroll|enterAlways" />
    </AppBarLayout>
<!-- 省略 -->
</CoordinatorLayout>

このように、普通に使っていく場合は以上のようにCoordinatorLayoutと便利なViewやフラグを使っていくことでリッチなレイアウトの動きを実現できます。

もう少し深掘りして、自分でもそういったレイアウトの動きを実現できるようにしてみましょう。

CoordinatorLayoutのBehavior

Behaviorとは直訳すると振る舞いですが、Viewに対して振る舞いを記述することができるクラスです。
具体的にはAndroid Design Support Library内のFloatingActionButtonのBehaviorなどの例を見ながら説明していきます。

FloatingActionButtonのBehavior

これまでの画像中に出てきた丸型の赤いボタン(メールアイコン)はFloatingActionButton(以下FAB)といいます。
スクロール時にFABが隠れたり現れたりする理由はBehaviorというCoordinatorLayoutの仕組みによって実現されています。
現在のFloatingActionButtonの振る舞いを整理してみましょう。

Snackbar(下の黒いバー)が表示されるとFABが一緒に上に移動する layout_anchor(後述)で指しているAppBarLayoutが小さくなるとFABが消える
loop_floating.gif loop_scroll_exitUntilCollapsed.gif

この振る舞いはどのように実装されているでしょうか。
Behaviorの特徴的な点として依存関係を定義できる点があります。
例えばCoordinatorLayoutに子ViewAと子ViewBがあったとし、
子ViewAが子ViewBに依存させたとします。
子ViewAは子ViewBの位置などが変わった時に、変更を受け取って、子ViewAの大きさを変えたりする事が簡単にできます。

さきほどまでのサンプル実装では以下のようにFABをレイアウトに記述していました。

    <android.support.design.widget.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="@dimen/fab_margin"
        android:src="@android:drawable/ic_dialog_email"
        app:layout_anchor="@id/app_bar"
        app:layout_anchorGravity="bottom|end" />

この中にapp:layout_anchor=”@id/app_bar”という記述があります。
このような指定をすることでFABは@id/app_bar(AppBarLayout)のViewの位置や大きさが変わったことを受け取ることができます。

他に依存するViewはあるのか、依存するViewが変わったのをどうやって受け取るのかを見るためにFloatingActionButtonの実装を見てみましょう。
FloatingActionButtonのクラスは以下のように宣言されています。

@CoordinatorLayout.DefaultBehavior(FloatingActionButton.Behavior.class)
public class FloatingActionButton extends ImageButton {

@CoordinatorLayout.DefaultBehaviorのアノテーションで、 FloatingActionButton.Behavior.classを指定しています。

そのBehaviorクラスの実装を見ていきましょう。

FloatingActionButton.Behaviorのソースコード

    public static class Behavior extends CoordinatorLayout.Behavior<FloatingActionButton> {

        @Override
        public boolean layoutDependsOn(CoordinatorLayout parent,
                FloatingActionButton child, View dependency) {
            return SNACKBAR_BEHAVIOR_ENABLED && dependency instanceof Snackbar.SnackbarLayout;
        }
        @Override
        public boolean onDependentViewChanged(CoordinatorLayout parent, FloatingActionButton child,
                View dependency) {
            if (dependency instanceof Snackbar.SnackbarLayout) {
                updateFabTranslationForSnackbar(parent, child, dependency);
            } else if (dependency instanceof AppBarLayout) {
                updateFabVisibility(parent, (AppBarLayout) dependency, child);
            }
            return false;
        }
        // 省略

CoordinatorLayout.Behaviorを継承して、Behavior#layoutDependsOn()とBehavior#onDependentViewChanged()を実装しています。それぞれを見ていきましょう。

Behavior#layoutDependsOn()

位置などの変更を受け取りたいViewであればtrueを返すことによって変更受け取ることができるようになります。
ここではSnackbar.SnackbarLayoutの位置や大きさが変わったのを受け取るというものになっています。

Behavior#onDependentViewChanged()

Behavior#layoutDependsOn()によってSnackbar.SnackbarLayoutの位置などが変わった時と、layout_anchorによってAppBarLayoutの位置などが変わった時に呼ばれます。
updateFabTranslationForSnackbar()はSnackbarの位置が変わった時に呼ばれ、FABの位置を上に上げる動作が行われます。
updateFabVisibility()はAppBarLayoutの高さによってFABの表示と非表示を切り替えます。

これを図に表すと以下のようになります。
image

AppBarLayoutのスクロールの仕組み

Behaviorは、他にも様々なイベントを受け取ることができます。
例えば、スクロールイベントを受け取る仕組みです。
CoordinatorLayoutの子Viewに、NestedScrollViewやRecyclerViewなどのCoordinatorLayoutに対応したスクロール可能なViewがある場合に、Behaviorでスクロールイベントを受け取ることができます。
先ほど紹介した青い部分の伸縮などを行うレイアウトのAppBarLayoutのコードを見ていきましょう。

BehaviorとしてAppBarLayout.Behaviorが定義されています。

AppBarLayoutのソースコード (抜粋)

@CoordinatorLayout.DefaultBehavior(AppBarLayout.Behavior.class)
public class AppBarLayout extends LinearLayout {
    public static class Behavior extends HeaderBehavior<AppBarLayout> {
        @Override
        public boolean onStartNestedScroll(CoordinatorLayout parent, AppBarLayout child,
                View directTargetChild, View target, int nestedScrollAxes) {
            return started;
        }
        @Override
        public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child,
                View target, int dx, int dy, int[] consumed) {
                            consumed[1] = scroll(coordinatorLayout, child, dy, min, max);
            }
        }
        @Override
        public void onNestedScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child,
                View target, int dxConsumed, int dyConsumed,
                int dxUnconsumed, int dyUnconsumed) {
                scroll(coordinatorLayout, child, dyUnconsumed,
                        -child.getDownNestedScrollRange(), 0);
        }

Behavior#onNestedScroll()などのメソッドを実装することで、 NestedScrollViewのスクロールのイベントを受け取って、Viewを操作することが可能になります。
AppBarLayout.Behaviorはこのようなメソッドを実装することによって特殊な振る舞いをするレイアウトを実装していました。
loop_scroll_exitUntilCollapsed.gif

これを図にすると以下の様な流れになります。

image

では、少し違った例を見ていきましょう。

SwipeDismissBehavior

他にもBehaviorにはタッチイベントを受け取る仕組みがあるので、それを利用したSwipeDismissBehaviorというBehaviorがあり、これを利用することでスワイプすることで消えるような振る舞いを実装することが可能となります。

loop_swipe.gif

実装方法

        final SwipeDismissBehavior behavior = new SwipeDismissBehavior();
        behavior.setStartAlphaSwipeDistance(0.1f);
        behavior.setEndAlphaSwipeDistance(0.6f);
        behavior.setSwipeDirection(SwipeDismissBehavior.SWIPE_DIRECTION_START_TO_END);
        behavior.setListener(new SwipeDismissBehavior.OnDismissListener() {
            @Override
            public void onDismiss(View view) {
            }

            @Override
            public void onDragStateChanged(int state) {
                switch (state) {
                    case SwipeDismissBehavior.STATE_DRAGGING:
                        // ドラッグ開始時
                        Toast.makeText(SwipeDismissActivity.this, "STATE_DRAGGING", Toast.LENGTH_SHORT).show();
                        break;
                    case SwipeDismissBehavior.STATE_SETTLING:
                        // ドラッグ終了してDismissするかしないか決まった後
                        Toast.makeText(SwipeDismissActivity.this, "STATE_SETTLING", Toast.LENGTH_SHORT).show();
                        break;
                    case SwipeDismissBehavior.STATE_IDLE:
                        // ドラッグが終わって、Viewが移動し終わった後
                        Toast.makeText(SwipeDismissActivity.this, "STATE_IDLE", Toast.LENGTH_SHORT).show();
                        break;
                }
            }
        });
        final ViewGroup.LayoutParams cardViewLayoutParams = findViewById(R.id.card_view).getLayoutParams();
        ((CoordinatorLayout.LayoutParams) cardViewLayoutParams).setBehavior(behavior);

では、これまで見てきた知識を使って自分でBehaviorを実装してみましょう。

カスタムBehaviorを実装してみよう

AppBarLayoutが出てくるに従って、スクロールすると画面の下から出てくるボトムバーを実装するには以下のように行います。
Behavior#layoutDependsOn()でAppBarLayoutの位置の変更を受け取れるようにして、
Behavior#onDependentViewChanged()でAppBarLayoutの現在の位置を受け取って、下のバーの位置を変更します。

loop_bottombarbehavior.gif

public class BottomBarBehavior extends CoordinatorLayout.Behavior<Toolbar> {

    private int defaultDependencyTop = -1;

    public BottomBarBehavior(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent, Toolbar bottomBar, View dependency) {
        return dependency instanceof AppBarLayout;
    }

    @Override
    public boolean onDependentViewChanged(CoordinatorLayout parent, Toolbar bottomBar, View dependency) {
        if (defaultDependencyTop == -1) {
            defaultDependencyTop = dependency.getTop();
        }
        ViewCompat.setTranslationY(bottomBar, -dependency.getTop() + defaultDependencyTop);
        return true;
    }

}

レイアウト(一部省略)

<android.support.design.widget.CoordinatorLayout>
    <android.support.design.widget.AppBarLayout >
        <android.support.v7.widget.Toolbar
            app:layout_scrollFlags="scroll|enterAlways"/>
    </android.support.design.widget.AppBarLayout>

<!-- 省略 -->
    <android.support.v7.widget.Toolbar
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:layout_gravity="bottom"
        android:background="?attr/colorPrimary"
        app:layout_behavior="jp.co.yahoo.sample.BottomBarBehavior"
        />
</android.support.design.widget.CoordinatorLayout>

さいごに

Android Design Support LibraryのCoordinatorLayoutを利用することで動的なレイアウトを比較的簡単に実装することができました。
みなさんもアプリにスクロール時の動きを実装したいときは、CoordinatorLayoutを検討してみるのはいかがでしょうか。
また、Androidのソースコードはオープンソースになっており、中身を見れば分かることがたくさんあり、なにより楽しいです。
ヤフーではワイルドにチャレンジしていく事ができる文化があります。調べて勉強してそれをプロダクトに反映していけた時はとても嬉しいです。
これらのレイアウトを利用して実装するときの助けになれば幸いです!

引用・参考

  • Android Open Source Project
    • CoordinatorLayout(android.support.design.widget.CoordinatorLayout)
    • AppBarLayout(android.support.design.widget.AppBarLayout)
    • CollapsingToolbarLayout(android.support.design.widget.CollapsingToolbarLayout)
    • SnackBar(android.support.design.widget.Snackbar)
    • FloatingActionButton(android.support.design.widget.FloatingActionButton)

※ 引用したAndroidのフレームワーク、サンプルのコードのライセンスは、どちらも Apache 2.0 に準じます。
https://source.android.com/source/licenses.html


※ Google、Nexus、Androidは、Google Inc.の登録商標または商標です。

Yahoo! JAPANでは情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。

  • このエントリーをはてなブックマークに追加