Posted at

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

More than 3 years have passed since last update.

Android LollipopでToolbarが導入されていますが、

Toolbarはビューの操作と連動した動きをさせたいケースが出てくると思います。

実際、Material Designになった新しいPlayストアアプリなどでは、以下のような挙動になっています。


  • スクロールに連動してバーの表示が切り替わる

  • スクロールによってバーは消えるがタブは常に残る

  • バーの表示/非表示が完全に切り替わらない位置で手を離すと、アニメーションで完全に切り替わる

これをAppCompat-v7とAndroid-ObservableScrollViewで実装する方法の説明です。

play.gif


ライブラリの導入

スクロールと連動させたいときに困るのが、ListView/ScrollView/WebViewといったスクロール可能な部品は、いずれもスクロール位置を外部から取得できないことです。

romannurik-codeのscrolltricksというサンプルを参考にすると、スクロールの扱い方が分かってきますが…やはり複雑です。

しかも、ListView/ScrollView/WebViewは独立した実装のため、それぞれ制御する必要があります。

そこで、これらの制御をまとめたライブラリAndroid-ObservableScrollViewを作ってみましたので、これを使って実現します。


build.gradle

dependencies {

compile 'com.android.support:appcompat-v7:21.0.0'
compile 'com.github.ksoichiro:android-observablescrollview:1.0.0'
}

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


実装例


ActionBarの表示を切り替える


概要

下にスクロールするとActionBarが隠れ、上にスクロールすると表示されるパターンです。

正確には、スクロールと完全に同期している訳ではなく、スクロール後にActionBarshow()/hide()を実行しています。

demo1.gif

demo2.gif

demo3.gif


レイアウト

ライブラリのListViewを定義します。

ScrollView/WebViewは、それぞれ対応するクラスがありますので読み替えてください。


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にコールバックを設定し、スクロール方向に応じた処理を実装します。


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

demo5.gif


レイアウト

以下のように、ライブラリのObservableListViewとAppCompat-v7のToolbarFrameLayoutで並べて定義します。

ただしToolbarの方は、固定で表示するビューとセットで操作するため、LinearLayoutで囲っています。

また、Material Designにおける"影"はElevationで設定するのでしょうが、通常のビューでの表示ができない(たぶん..)ためImageViewで影を付けています。


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を表示または非表示にします。


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を使用しています。

Android 3.0+ のみであれば、setTranslationY()などはViewに対して直接呼び出せますので、読み替えてください。


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


概要

上述の通り、WebViewではListViewScrollViewと違う実装になります。

上記のListViewScrollViewは、位置調整のためにヘッダの高さに相当する余白を入れているのがポイントなのですが、WebViewの場合は上部に余白を入れることができないようです。

そのため、ObservableScrollViewObservableWebViewを包み、2つのコールバックを使って制御することで実現します。

ObservableWebView単独でスクロール中に位置(translationY)を動かす方法も考えられますが、これをやるとスクロールがガタガタになってしまいます…。

demo6.gif


レイアウト


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のイベントでドラッグの開始を判定していました。

WebViewScrollViewを組み合わせた場合は、ACTION_DOWNイベントがScrollViewで発生し、スクロールイベントとドラッグ終了(ACTION_UP/ACTION_CANCEL)はWebViewで発生してしまいます。

そのため、ObservableScrollViewObservableWebViewのそれぞれのコールバックを実装してドラッグ状態を判定します。


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の場合と同じ
:
}
};
}


以上です。