Google Playストアアプリのようにスクロール時にActionBarやToolbarの表示を切り替える


Android LollipopでToolbarが導入されていますが、
Toolbarはビューの操作と連動した動きをさせたいケースが出てくると思います。
実際、Material Designになった新しいPlayストアアプリなどでは、以下のような挙動になっています。

* スクロールに連動してバーの表示が切り替わる
* スクロールによってバーは消えるがタブは常に残る
* バーの表示/非表示が完全に切り替わらない位置で手を離すと、アニメーションで完全に切り替わる

これをAppCompat-v7と[Android-ObservableScrollView](https://github.com/ksoichiro/Android-ObservableScrollView)で実装する方法の説明です。

![play.gif](https://qiita-image-store.s3.amazonaws.com/0/29368/c5aaedc5-7ec7-178b-71d7-cdbd8754c584.gif "play.gif")

# ライブラリの導入

スクロールと連動させたいときに困るのが、`ListView`/`ScrollView`/`WebView`といったスクロール可能な部品は、いずれもスクロール位置を外部から取得できないことです。
[romannurik-code](https://code.google.com/p/romannurik-code/)のscrolltricksというサンプルを参考にすると、スクロールの扱い方が分かってきますが…やはり複雑です。
しかも、`ListView`/`ScrollView`/`WebView`は独立した実装のため、それぞれ制御する必要があります。

そこで、これらの制御をまとめたライブラリ[Android-ObservableScrollView](https://github.com/ksoichiro/Android-ObservableScrollView)を作ってみましたので、これを使って実現します。

```groovy:build.gradle
dependencies {
    compile 'com.android.support:appcompat-v7:21.0.0'
    compile 'com.github.ksoichiro:android-observablescrollview:1.0.0'
}
```

※以下のサンプルコードは、(日本語コメント以外は)すべて上記のライブラリに含まれています。

# 実装例

## ActionBarの表示を切り替える

### 概要

下にスクロールすると`ActionBar`が隠れ、上にスクロールすると表示されるパターンです。
正確には、スクロールと完全に同期している訳ではなく、スクロール後に`ActionBar`の`show()`/`hide()`を実行しています。

![demo1.gif](https://qiita-image-store.s3.amazonaws.com/0/29368/77e432e4-9e07-65da-84ec-28339bcd182e.gif "demo1.gif")
![demo2.gif](https://qiita-image-store.s3.amazonaws.com/0/29368/a302a84a-0568-edd6-f353-7b727163368e.gif "demo2.gif")
![demo3.gif](https://qiita-image-store.s3.amazonaws.com/0/29368/f6a823d2-49ce-c111-88d3-b57ebd2b9bd1.gif "demo3.gif")

### レイアウト

ライブラリの`ListView`を定義します。
※`ScrollView`/`WebView`は、それぞれ対応するクラスがありますので読み替えてください。

```xml:res/layout/activity_actionbarcontrollistview.xml
<com.github.ksoichiro.android.observablescrollview.ObservableListView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/list"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
```

### Activityでの制御

Activityでは、そのListViewにコールバックを設定し、スクロール方向に応じた処理を実装します。

```java:ActionBarControlListViewActivity.java
public class ActionBarControlListViewActivity extends ActionBarActivity implements ObservableScrollViewCallbacks {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_actionbarcontrollistview);

        ObservableListView listView = (ObservableListView) findViewById(R.id.list);
        listView.setScrollViewCallbacks(this);
        :
    }

    @Override
    public void onUpOrCancelMotionEvent(ScrollState scrollState) {
        ActionBar ab = getSupportActionBar();
        if (scrollState == ScrollState.UP) {
            if (ab.isShowing()) {
                ab.hide();
            }
        } else if (scrollState == ScrollState.DOWN) {
            if (!ab.isShowing()) {
                ab.show();
            }
        }
    }
}
```

## Toolbarの表示を切り替えつつ、Toolbar下のビューは常に残す

### 概要

こちらが本題ですが、スクロール方向に応じたアニメーションだけでなく、スクロールと連動させて`Toolbar`を同じように移動させるには、さらにもう一工夫必要になります。
コンテンツと`Toolbar`の位置を一緒にスクロールさせるために、スクロール部分の上部に余白を追加することと、`FrameLayout`で重ね合わせて表示するところがポイントです。

`ListView`で説明しますが、`ScrollView`でも同じです。
`WebView`の場合はさらに工夫が必要です。(後述)

![demo4.gif](https://qiita-image-store.s3.amazonaws.com/0/29368/ef5c30ac-3786-48cd-aad8-84e2a8ea5169.gif "demo4.gif")
![demo5.gif](https://qiita-image-store.s3.amazonaws.com/0/29368/7f8aced3-9e87-bc55-8768-4a5844b882a5.gif "demo5.gif")

### レイアウト

以下のように、ライブラリの`ObservableListView`とAppCompat-v7の`Toolbar`を`FrameLayout`で並べて定義します。
ただし`Toolbar`の方は、固定で表示するビューとセットで操作するため、`LinearLayout`で囲っています。
また、Material Designにおける"影"はElevationで設定するのでしょうが、通常のビューでの表示ができない(たぶん..)ためImageViewで影を付けています。

```xml:res/layout/activity_toolbarcontrollistview.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <!-- ライブラリの部品を使用 -->
    <com.github.ksoichiro.android.observablescrollview.ObservableListView
        android:id="@+id/list"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <!-- Toolbarと固定の部品を包含するレイアウト -->
    <LinearLayout
        android:id="@+id/header"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <!-- backgroundはここで設定しないとちらつきが発生する -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/primary"
            android:orientation="vertical">

            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:background="?attr/colorPrimary"
                android:minHeight="?attr/actionBarSize"
                app:popupTheme="@style/Theme.AppCompat.Light.DarkActionBar"
                app:theme="@style/Toolbar" />

            <!-- スクロールしても表示し続けるビュー -->
            <TextView
                android:id="@+id/sticky"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:gravity="center_vertical"
                android:paddingLeft="@dimen/margin_standard"
                android:paddingRight="@dimen/margin_standard"
                android:text="@string/sticky_header"
                android:textAppearance="?android:attr/textAppearanceMedium"
                android:textColor="@android:color/white" />
        </LinearLayout>

        <!-- ActionBar等と同様に影をつける(Elevationの代わり) -->
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="@dimen/toolbar_elevation"
            android:src="@drawable/shadow" />
    </LinearLayout>
</FrameLayout>
```

### Activityでの制御

このレイアウトに対し、`Activity`ではスクロールのコールバックを使った位置変更やアニメーションを以下のように実装します。
ドラッグ中はスクロールに連動してヘッダ部分を動かし、手を離した後はアニメーションで`Toolbar`を表示または非表示にします。

```java:ToolbarControlListViewActivity.java
public class ToolbarControlListViewActivity extends ActionBarActivity implements ObservableScrollViewCallbacks {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_toolbarcontrollistview);

        // ToolbarをActionBarとして設定(.NoActionBarのテーマが必要)
        setSupportActionBar((Toolbar) findViewById(R.id.toolbar));
        :
        mListView = (ObservableListView) findViewById(R.id.list);
        mListView.setScrollViewCallbacks(this);

        // ListViewのヘッダにHeader(Toolbar + StickyView)分の余白を追加
        LayoutInflater inflater = LayoutInflater.from(this);
        mListView.addHeaderView(inflater.inflate(R.layout.padding, null)); // toolbar
        mListView.addHeaderView(inflater.inflate(R.layout.padding, null)); // sticky view
        :
    }

    @Override
    public void onScrollChanged(int scrollY, boolean firstScroll, boolean dragging) {
        // ドラッグ中のみ反応させ、手を離した後はアニメーションに任せる
        if (dragging) {
            int toolbarHeight = mToolbarView.getHeight();
            if (firstScroll) {
                // ある程度スクロールした状態から動かすときは現在のスクロール位置を基準にする
                float currentHeaderTranslationY = ViewHelper.getTranslationY(mHeaderView);
                if (-toolbarHeight < currentHeaderTranslationY && toolbarHeight < scrollY) {
                    mBaseTranslationY = scrollY;
                }
            }
            // Toolbarの可動範囲を-toolbarHeightから0までに制限する
            int headerTranslationY = Math.min(0, Math.max(-toolbarHeight, -(scrollY - mBaseTranslationY)));

            // 動作中のアニメーションをキャンセルして移動
            ViewPropertyAnimator.animate(mHeaderView).cancel();
            ViewHelper.setTranslationY(mHeaderView, headerTranslationY);
        }
    }
    :
    @Override
    public void onUpOrCancelMotionEvent(ScrollState scrollState) {
        mBaseTranslationY = 0;

        float headerTranslationY = ViewHelper.getTranslationY(mHeaderView);
        int toolbarHeight = mToolbarView.getHeight();
        if (scrollState == ScrollState.UP) {
            // Toolbarを隠す
            if (toolbarHeight < mListView.getCurrentScrollY()) {
                if (headerTranslationY != -toolbarHeight) {
                    ViewPropertyAnimator.animate(mHeaderView).cancel();
                    ViewPropertyAnimator.animate(mHeaderView).translationY(-toolbarHeight).setDuration(200).start();
                }
            }
        } else if (scrollState == ScrollState.DOWN) {
            // Toolbarを表示する
            if (toolbarHeight < mListView.getCurrentScrollY()) {
                if (headerTranslationY != 0) {
                    ViewPropertyAnimator.animate(mHeaderView).cancel();
                    ViewPropertyAnimator.animate(mHeaderView).translationY(0).setDuration(200).start();
                }
            }
        }
    }
}
```

なお、Android 3.0未満も対象にしたコードになっているため、アニメーションや位置変更には[JakeWharton/NineOldAndroids](https://github.com/JakeWharton/NineOldAndroids/)を使用しています。
Android 3.0+ のみであれば、`setTranslationY()`などは`View`に対して直接呼び出せますので、読み替えてください。

## Toolbarの表示を切り替えつつ、Toolbar下のビューは常に残す(WebViewの場合)

### 概要

上述の通り、`WebView`では`ListView`や`ScrollView`と違う実装になります。
上記の`ListView`や`ScrollView`は、位置調整のためにヘッダの高さに相当する余白を入れているのがポイントなのですが、WebViewの場合は上部に余白を入れることができないようです。
そのため、`ObservableScrollView`で`ObservableWebView`を包み、2つのコールバックを使って制御することで実現します。
※`ObservableWebView`単独でスクロール中に位置(`translationY`)を動かす方法も考えられますが、これをやるとスクロールがガタガタになってしまいます…。

![demo6.gif](https://qiita-image-store.s3.amazonaws.com/0/29368/2dcf12f4-574c-836e-14e0-0d905c1ae2a2.gif "demo6.gif")

### レイアウト

```xml:res/layout/activity_toolbarcontrolwebview.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/root"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.github.ksoichiro.android.observablescrollview.ObservableScrollView
        android:id="@+id/scroll"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <!-- WebView上部に余白が入れられないため、ScrollViewにheader分の余白を追加 -->
            <View
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:minHeight="?attr/actionBarSize" />
            <View
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:minHeight="?attr/actionBarSize" />

            <com.github.ksoichiro.android.observablescrollview.ObservableWebView
                android:id="@+id/web"
                android:layout_width="match_parent"
                android:layout_height="wrap_content" />
        </LinearLayout>
    </com.github.ksoichiro.android.observablescrollview.ObservableScrollView>

    <!-- 以下はListView/ScrollViewの場合と同じ -->
    <LinearLayout
        android:id="@+id/header"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/primary"
            android:orientation="vertical">

            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:background="?attr/colorPrimary"
                android:minHeight="?attr/actionBarSize"
                app:popupTheme="@style/Theme.AppCompat.Light.DarkActionBar"
                app:theme="@style/Toolbar" />

            <TextView
                android:id="@+id/sticky"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:gravity="center_vertical"
                android:paddingLeft="@dimen/margin_standard"
                android:paddingRight="@dimen/margin_standard"
                android:text="@string/sticky_header"
                android:textAppearance="?android:attr/textAppearanceMedium"
                android:textColor="@android:color/white" />
        </LinearLayout>

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="@dimen/toolbar_elevation"
            android:src="@drawable/shadow" />
    </LinearLayout>
</FrameLayout>
```

### Activityでの制御

`ListView`/`ScrollView`の場合は、`ACTION_DOWN`のイベントでドラッグの開始を判定していました。
`WebView`と`ScrollView`を組み合わせた場合は、`ACTION_DOWN`イベントが`ScrollView`で発生し、スクロールイベントとドラッグ終了(`ACTION_UP`/`ACTION_CANCEL`)は`WebView`で発生してしまいます。
そのため、`ObservableScrollView`と`ObservableWebView`のそれぞれのコールバックを実装してドラッグ状態を判定します。

```java:ToolbarControlWebViewActivity.java
public class ToolbarControlWebViewActivity extends ActionBarActivity {
    :
    private ObservableScrollViewCallbacks mWebViewScrollCallbacks = new ObservableScrollViewCallbacks() {
        @Override
        public void onScrollChanged(int scrollY, boolean firstScroll, boolean dragging) {
        }

        @Override
        public void onDownMotionEvent() {
            // ScrollViewにWebViewを入れた場合、ACTION_DOWNのイベントが
            // WebViewにしかないため、WebViewのコールバック内で
            // ドラッグ開始を判定する
            mFirstScroll = mDragging = true;
        }

        @Override
        public void onUpOrCancelMotionEvent(ScrollState scrollState) {
        }
    };

    private ObservableScrollViewCallbacks mScrollViewScrollCallbacks = new ObservableScrollViewCallbacks() {
        @Override
        public void onScrollChanged(int scrollY, boolean firstScroll, boolean dragging) {
            // ライブラリでなくこのクラスで定義したフラグ(mDragging, mFirstScroll)で制御する
            if (mDragging) {
                int toolbarHeight = mToolbarView.getHeight();
                if (mFirstScroll) {
                    mFirstScroll = false;
                    float currentHeaderTranslationY = ViewHelper.getTranslationY(mHeaderView);
                    if (-toolbarHeight < currentHeaderTranslationY && toolbarHeight < scrollY) {
                        mBaseTranslationY = scrollY;
                    }
                }
                int headerTranslationY = Math.min(0, Math.max(-toolbarHeight, -(scrollY - mBaseTranslationY)));
                ViewPropertyAnimator.animate(mHeaderView).cancel();
                ViewHelper.setTranslationY(mHeaderView, headerTranslationY);
            }
        }

        @Override
        public void onDownMotionEvent() {
        }

        @Override
        public void onUpOrCancelMotionEvent(ScrollState scrollState) {
            // ドラッグ終了はWebViewでのハンドリングが必要
            mDragging = false;
            // 以下はListView/ScrollViewの場合と同じ
            :
        }
    };
}
```


以上です。