Android LollipopでToolbarが導入されていますが、
Toolbarはビューの操作と連動した動きをさせたいケースが出てくると思います。
実際、Material Designになった新しいPlayストアアプリなどでは、以下のような挙動になっています。
- スクロールに連動してバーの表示が切り替わる
- スクロールによってバーは消えるがタブは常に残る
- バーの表示/非表示が完全に切り替わらない位置で手を離すと、アニメーションで完全に切り替わる
これをAppCompat-v7とAndroid-ObservableScrollViewで実装する方法の説明です。
ライブラリの導入
スクロールと連動させたいときに困るのが、ListView
/ScrollView
/WebView
といったスクロール可能な部品は、いずれもスクロール位置を外部から取得できないことです。
romannurik-codeのscrolltricksというサンプルを参考にすると、スクロールの扱い方が分かってきますが…やはり複雑です。
しかも、ListView
/ScrollView
/WebView
は独立した実装のため、それぞれ制御する必要があります。
そこで、これらの制御をまとめたライブラリAndroid-ObservableScrollViewを作ってみましたので、これを使って実現します。
dependencies {
compile 'com.android.support:appcompat-v7:21.0.0'
compile 'com.github.ksoichiro:android-observablescrollview:1.0.0'
}
※以下のサンプルコードは、(日本語コメント以外は)すべて上記のライブラリに含まれています。
実装例
ActionBarの表示を切り替える
概要
下にスクロールするとActionBar
が隠れ、上にスクロールすると表示されるパターンです。
正確には、スクロールと完全に同期している訳ではなく、スクロール後にActionBar
のshow()
/hide()
を実行しています。
レイアウト
ライブラリのListView
を定義します。
※ScrollView
/WebView
は、それぞれ対応するクラスがありますので読み替えてください。
<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にコールバックを設定し、スクロール方向に応じた処理を実装します。
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
の場合はさらに工夫が必要です。(後述)
レイアウト
以下のように、ライブラリのObservableListView
とAppCompat-v7のToolbar
をFrameLayout
で並べて定義します。
ただしToolbar
の方は、固定で表示するビューとセットで操作するため、LinearLayout
で囲っています。
また、Material Designにおける"影"はElevationで設定するのでしょうが、通常のビューでの表示ができない(たぶん..)ためImageViewで影を付けています。
<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
を表示または非表示にします。
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
ではListView
やScrollView
と違う実装になります。
上記のListView
やScrollView
は、位置調整のためにヘッダの高さに相当する余白を入れているのがポイントなのですが、WebViewの場合は上部に余白を入れることができないようです。
そのため、ObservableScrollView
でObservableWebView
を包み、2つのコールバックを使って制御することで実現します。
※ObservableWebView
単独でスクロール中に位置(translationY
)を動かす方法も考えられますが、これをやるとスクロールがガタガタになってしまいます…。
レイアウト
<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
のそれぞれのコールバックを実装してドラッグ状態を判定します。
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の場合と同じ
:
}
};
}
以上です。