原文
How to hide/show Toolbar when list is scrolling (part 2)
訳文を掲載する承諾は得ております。
まえがき
このシリーズの2回目(そして最後)の投稿です。もしあなたがパート1を読んでいないなら、読むことをお勧めします。前のパートではGoogle+のようにToolbarを隠す効果の実装を行うことができました。今日はGoogle PlayストアのToolbarのような振る舞いをどのように作れるかを見ていきましょう。はじまりはじまり。
始める前に、このプロジェクトを少しリファクタリングしたことを述べておきます。MainActivity
から呼び出される2つのActivity
、PartOneActivity
とPartTwoActivity
に分離しました。あなたが参照したいクラスを簡単に見つけられるように、コードはpartone
とparttwo
のパッケージにあります。
最終的に完成した効果をPlay StoreのToolbarと比較してみます。
まず最初に
build.gradle
はパート1と同じなので、そちらを参照してもらうことにして、Activity
のレイアウトを作るところから始めたいと思います。
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="?attr/actionBarSize"
android:clipToPadding="false"/>
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"/>
</FrameLayout>
RecyclerView
とToolbar
(後ほどTabs
を追加)だけでできています。前の投稿では(RecyclerView
にpaddingを追加するという) 2つ目の方法を用いていたことに気を付けてください。
リストアイテムのレイアウトファイルは、前とまったく同じなので省略します。RecyclerAdapter
も同様に飛ばします。(こちらを参照してください。シンプルでヘッダーのないアダプターです。)
PartTwoActivity
のコードを見てみます。
public class PartTwoActivity extends ActionBarActivity {
private Toolbar mToolbar;
@Override
protected void onCreate(Bundle savedInstanceState) {
setTheme(R.style.AppThemeGreen);
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_part_two);
initToolbar();
initRecyclerView();
}
private void initToolbar() {
mToolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(mToolbar);
setTitle(getString(R.string.app_name));
mToolbar.setTitleTextColor(getResources().getColor(android.R.color.white));
}
private void initRecyclerView() {
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
RecyclerAdapter recyclerAdapter = new RecyclerAdapter(createItemList());
recyclerView.setAdapter(recyclerAdapter);
recyclerView.setOnScrollListener(new HidingScrollListener(this));
}
private List<String> createItemList() {
List<String> itemList = new ArrayList<>();
for(int i=0;i<20;i++) {
itemList.add("Item "+i);
}
return itemList;
}
}
RecyclerView
とToolbar
の基本的な初期化をしています。27行目でOnScrollListener
をセットしていることに注意してください。
最も重要な部分はHidingScrollListener
です。これを作っていきます。
public abstract class HidingScrollListener extends RecyclerView.OnScrollListener {
private int mToolbarOffset = 0;
private int mToolbarHeight;
public HidingScrollListener(Context context) {
mToolbarHeight = Utils.getToolbarHeight(context);
}
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
clipToolbarOffset();
onMoved(mToolbarOffset);
if((mToolbarOffset <mToolbarHeight && dy>0) || (mToolbarOffset >0 && dy<0)) {
mToolbarOffset += dy;
}
}
private void clipToolbarOffset() {
if(mToolbarOffset > mToolbarHeight) {
mToolbarOffset = mToolbarHeight;
} else if(mToolbarOffset < 0) {
mToolbarOffset = 0;
}
}
public abstract void onMoved(int distance);
}
もしあなたが前のパートを読んだのなら、見覚えがあることでしょう。(実際には、よりシンプルになっています。)スクロールされたToolbar
の間接的なオフセットを保存しておくmToolbarOffset
という重要な変数のみを用いています。次のように0とToolbar
の高さの間で値を変化させる、簡単な実装です。
if((mToolbarOffset <mToolbarHeight && dy>0) || (mToolbarOffset >0 && dy<0)) {
mToolbarOffset += dy;
}
上にスクロールした時に(Toolbar
の高さより大きくならないように)値を増加させ、下にスクロールした時に(0より小さくならないように)値を減少させます。これらの値を制限する理由がだんだん見えてくると思います。(例えばフリックした時に)短時間で範囲外の値を撮る可能性があり、ちらつきを起こす可能性があるため、mToolbarOffset
をクリップするのです。また、onMoved()
をスクロール時に呼び出す抽象メソッドを定義しておきます。ここでは不思議に思われることでしょう。
PartTwoActivity
に戻って、メソッドonMoved()
をスクロールリスナーの中に実装します。
private void initRecyclerView() {
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
RecyclerAdapter recyclerAdapter = new RecyclerAdapter(createItemList());
recyclerView.setAdapter(recyclerAdapter);
recyclerView.setOnScrollListener(new HidingScrollListener(this) {
@Override
public void onMoved(int distance) {
mToolbarContainer.setTranslationY(-distance);
}
});
}
まあまあです。予想通り、Toolbar
はリストとともに動いて隠れ、また戻ってきます。変数mToolbarOffset
に実装した制限のおかげです。もし0より大きくmToolbarOffset
より小さいかのチェックを省略していたなら、Toolbar
は画面の外のどこか遠くへ行ってしまうことでしょう。mToolbarHeight
より上にスクロールしたとき、それより上にスクロールさせず、リストの上で停止させることで、下にスクロールしたとき、すぐ表示することができます。
まあまあ良く動きますが、望んでいたものではありません。スクロールを途中で止めたときにToolbar
が半分だけ見てているのは気持ち悪いです。実際に、Google Playゲームアプリで見られるこの動きはバグではないでしょうか。
Toolbarのスナップ
Chromeアプリのロゴ・検索バーやPlayストアアプリのToolbar
のように、Viewは所定の位置にスムーズにスナップするべきです。マテリアルデザインのガイドライン・チェックリストのどこかで見たか、Google I/Oのプレゼンの中で聞いたはずです。
もう一度、HidingScrollListener
のコードを見てみましょう。
public abstract class HidingScrollListener extends RecyclerView.OnScrollListener {
private static final float HIDE_THRESHOLD = 10;
private static final float SHOW_THRESHOLD = 70;
private int mToolbarOffset = 0;
private boolean mControlsVisible = true;
private int mToolbarHeight;
public HidingScrollListener(Context context) {
mToolbarHeight = Utils.getToolbarHeight(context);
}
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if(newState == RecyclerView.SCROLL_STATE_IDLE) {
if (mControlsVisible) {
if (mToolbarOffset > HIDE_THRESHOLD) {
setInvisible();
} else {
setVisible();
}
} else {
if ((mToolbarHeight - mToolbarOffset) > SHOW_THRESHOLD) {
setVisible();
} else {
setInvisible();
}
}
}
}
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
clipToolbarOffset();
onMoved(mToolbarOffset);
if((mToolbarOffset <mToolbarHeight && dy>0) || (mToolbarOffset >0 && dy<0)) {
mToolbarOffset += dy;
}
}
private void clipToolbarOffset() {
if(mToolbarOffset > mToolbarHeight) {
mToolbarOffset = mToolbarHeight;
} else if(mToolbarOffset < 0) {
mToolbarOffset = 0;
}
}
private void setVisible() {
if(mToolbarOffset > 0) {
onShow();
mToolbarOffset = 0;
}
mControlsVisible = true;
}
private void setInvisible() {
if(mToolbarOffset < mToolbarHeight) {
onHide();
mToolbarOffset = mToolbarHeight;
}
mControlsVisible = false;
}
public abstract void onMoved(int distance);
public abstract void onShow();
public abstract void onHide();
}
少し複雑になりましたが、そこまで身構えないで大丈夫です。クラスRecyclerView.OnScrollListener
の2つ目のメソッドonScrollStateChanged()
をオーバーライドしただけです。このメソッドの中では次のようなことをしています。
リストがスクロール中でもフリック中でもない状態
RecyclerView.SCROLL_STATE_IDLE
にあるかどうかを確認します。(もしスクロール中かフリック中であるなら、前のようにToolbar
のY座標を手動で変化させていくためです。)-
指を離してリストが止まって(状態
RecyclerView.SCROLL_STATE_IDLE
に)いるなら、表示されているかを確認します。表示されている場合にはmToolbarOffset
がHIDE_THRESHOLD
より大きければ非表示にし、非表示の場合にはmToolbarOffset
がSHOW_THRESHOLD
より小さければ再び表示させます。HidingScrollListener.javaif (mControlsVisible) { if (mToolbarOffset > HIDE_THRESHOLD) { setInvisible(); } else { setVisible(); } }
-
そして非表示の場合には逆の動きをします。(
mToolbarHeight - mToolbarOffset
により上の座標を求めて)mToolbarOffset
がSHOW_THRESHOLD
より大きければ表示し、HIDE_THRESHOLD
より小さければ再び非表示にします。HidingScrollListener.javaelse { // it's not visible if ((mToolbarHeight - mToolbarOffset) > SHOW_THRESHOLD) { setVisible(); } else { setInvisible(); } }
onScrolled()
は以前のままで、これら以外には書き換える箇所はありません。最後にクラスPartTwoActivity
の中に新しく2つの抽象メソッドを実装する必要があります。
private void initRecyclerView() {
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
RecyclerAdapter recyclerAdapter = new RecyclerAdapter(createItemList());
recyclerView.setAdapter(recyclerAdapter);
recyclerView.setOnScrollListener(new HidingScrollListener(this) {
@Override
public void onMoved(int distance) {
mToolbarContainer.setTranslationY(-distance);
}
@Override
public void onShow() {
mToolbarContainer.animate().translationY(0).setInterpolator(new DecelerateInterpolator(2)).start();
}
@Override
public void onHide() {
mToolbarContainer.animate().translationY(-mToolbarHeight).setInterpolator(new AccelerateInterpolator(2)).start();
}
});
}
良く動いています。
タブを追加するとコードが複雑になると思われるかもしれませんが、そうではないことをお見せしましょう。
タブの追加
Activity
のレイアウトを修正する必要があります。
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"/>
<LinearLayout
android:id="@+id/toolbarContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"/>
<include layout="@layout/tabs"/>
</LinearLayout>
</FrameLayout>
そしてtabs.xml
は次のようにします。
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/tabsHeight"
android:background="?attr/colorPrimary">
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" >
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/tab_1"
android:gravity="center"
style="@style/Base.TextAppearance.AppCompat.Body2"
android:textColor="@android:color/white"
android:background="@android:color/transparent" />
<View
android:layout_width="match_parent"
android:layout_height="6dp"
android:layout_gravity="bottom"
android:background="@android:color/white" />
</FrameLayout>
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:text="@string/tab_2"
android:gravity="center"
style="@style/Base.TextAppearance.AppCompat.Body2"
android:textColor="@android:color/white"
android:background="@android:color/transparent" />
</LinearLayout>
御覧のように、本物のTabs
ではなく、見かけを似せたレイアウトだけを追加しています。実装を変更するのはここだけです。ここに色々なViewを置くことができます。マテリアルデザインに基づいたTabs
の実装がgithubにはいくつかあるので、ご自分でそれらを実装してください。
Tabs
を追加するということは、リストを少し覆うということで、paddingを増やす必要があります。自由に設定するため、xmlではこの設定を行いません。(part_two_activity.xml
のRecyclerView
からpaddingを削除することに注意してください。)Toolbar
は向きや端末(例えばタブレットなど)により異なる高さを持つことがあり、すべてのケースに対応するには大量のxmlを記述する必要があるからです。その代り、コードでpaddingを設定します。
private void initRecyclerView() {
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
int paddingTop = Utils.getToolbarHeight(PartTwoActivity.this) + Utils.getTabsHeight(PartTwoActivity.this);
recyclerView.setPadding(recyclerView.getPaddingLeft(), paddingTop, recyclerView.getPaddingRight(), recyclerView.getPaddingBottom());
recyclerView.setLayoutManager(new LinearLayoutManager(this));
// ...
}
非常に簡単で、Toolbar
の高さとTabs
の高さの合計をpaddingに設定するだけです。実行した結果、このようになります。
大丈夫、リストの一つ目の項目は完全に表示されており、移動することもできています。実際に、クラスHidingScrollListener
に何も変更を加えていません。PartTwoActivity
に変更を加えるだけです。
public class PartTwoActivity extends ActionBarActivity {
private LinearLayout mToolbarContainer;
private int mToolbarHeight;
@Override
protected void onCreate(Bundle savedInstanceState) {
setTheme(R.style.AppThemeGreen);
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_part_two);
mToolbarContainer = (LinearLayout) findViewById(R.id.toolbarContainer);
initToolbar();
initRecyclerView();
}
private void initToolbar() {
Toolbar mToolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(mToolbar);
setTitle(getString(R.string.app_name));
mToolbar.setTitleTextColor(getResources().getColor(android.R.color.white));
mToolbarHeight = Utils.getToolbarHeight(this);
}
private void initRecyclerView() {
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
int paddingTop = Utils.getToolbarHeight(PartTwoActivity.this) + Utils.getTabsHeight(PartTwoActivity.this);
recyclerView.setPadding(recyclerView.getPaddingLeft(), paddingTop, recyclerView.getPaddingRight(), recyclerView.getPaddingBottom());
recyclerView.setLayoutManager(new LinearLayoutManager(this));
RecyclerAdapter recyclerAdapter = new RecyclerAdapter(createItemList());
recyclerView.setAdapter(recyclerAdapter);
recyclerView.setOnScrollListener(new HidingScrollListener(this) {
@Override
public void onMoved(int distance) {
mToolbarContainer.setTranslationY(-distance);
}
@Override
public void onShow() {
mToolbarContainer.animate().translationY(0).setInterpolator(new DecelerateInterpolator(2)).start();
}
@Override
public void onHide() {
mToolbarContainer.animate().translationY(-mToolbarHeight).setInterpolator(new AccelerateInterpolator(2)).start();
}
});
}
private List<String> createItemList() {
List<String> itemList = new ArrayList<>();
for(int i=0;i<20;i++) {
itemList.add("Item "+i);
}
return itemList;
}
}
変更点がお分かりでしょうか。mToolbarContainer
がToolbar
の代わりにLinearLayout
を参照するようになり、メソッドonMove()
、onHide()
の中ではToolbar
の代わりにこのViewを移動させ、アニメーションさせています。ここではToolbar
とTabs
を含むコンテナを移動させる必要があるのです。
実行してみると予想通りに動く気がしますが、目を凝らすと小さなバグがることに気付くでしょう。たまにコンマ何秒の間、Tabs
とToolbar
の間に白い線が現れます。アニメーションが完全に同期していないために生じていると思われます。不幸なことにこれ自体を直すことは無理でしょう。
修正は簡単で、Toolbar
とTabs
の背景を親のレイアウトに移すだけです。
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"/>
<LinearLayout
android:id="@+id/toolbarContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="?attr/colorPrimary"> <!-- added here -->
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"/> <!-- removed from here and tabs.xml -->
<include layout="@layout/tabs"/>
</LinearLayout>
</FrameLayout>
これにより、たとえViewのアニメーションが完全に同期していなくても、白い線は現われません。もう一つバグがあり、こちらはパート1と同じものです。HIDE_THRESHOLD
が十分に小さい場合には、リストの一番上で少し上にスクロールした際に、Toolbar
が隠れて何もない空間(padding)がリストの上に表示されてしまいます。前回同様に、非常に簡単に直すことができます。
public abstract class HidingScrollListener extends RecyclerView.OnScrollListener {
private static final float HIDE_THRESHOLD = 10;
private static final float SHOW_THRESHOLD = 70;
private int mToolbarOffset = 0;
private boolean mControlsVisible = true;
private int mToolbarHeight;
private int mTotalScrolledDistance;
public HidingScrollListener(Context context) {
mToolbarHeight = Utils.getToolbarHeight(context);
}
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if(newState == RecyclerView.SCROLL_STATE_IDLE) {
if(mTotalScrolledDistance < mToolbarHeight) {
setVisible();
} else {
if (mControlsVisible) {
if (mToolbarOffset > HIDE_THRESHOLD) {
setInvisible();
} else {
setVisible();
}
} else {
if ((mToolbarHeight - mToolbarOffset) > SHOW_THRESHOLD) {
setVisible();
} else {
setInvisible();
}
}
}
}
}
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
//...
mTotalScrolledDistance += dy;
}
//...
}
リストがどれだけスクロールされたかを格納する変数を追加しただけで、Toolbar
の表示・非表示すべきかをチェックする際に、まずToolbar
の高さよりスクロールされているかをチェック(そうでない場合には再びToolbar
を表示)します。
とてもうまく動作するようになりました。恐らく他のLayoutManagersでも変更を加えることなく動作するでしょう。
private void initRecyclerView() {
final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
int paddingTop = Utils.getToolbarHeight(PartTwoActivity.this) + Utils.getTabsHeight(PartTwoActivity.this);
recyclerView.setPadding(recyclerView.getPaddingLeft(), paddingTop, recyclerView.getPaddingRight(), recyclerView.getPaddingBottom());
recyclerView.setLayoutManager(new GridLayoutManager(this, 3));
RecyclerAdapter recyclerAdapter = new RecyclerAdapter(createItemList());
//...
}
スクロールの状態を保存することについてコメントで質問があり、実際に少しの問題があります。項目のテキストが十分に長く、縦長の画面では2行に、横長の画面では1行になる場合に、アイテムの高さが縦か横かで変化するという点です。縦の状態で100の位置にスクロールしており、mTotalScrolledDistance
にその値を保存していた場合、端末を回転させ一番上までスクロールすると、mTotalScrolledDistance
は0にならないのです。簡単な修正方法はありませんが、その場合でも特に問題になりません。本当にしたいことを思い出してみると、回転した後にmTotalScrolledDistance
を0にリセットしToolbar
を表示させれば良いのです。
これでこのシリーズの最後の投稿です。前のパートからいくつかの点を学べたことをうれしく思います。ありがたい言葉に感謝します。このブログを書き続けていこうと思いますが、次は何を書くかはまだわかりません。
今回と前回の投稿で述べた方法はとてもよく動くと思いますが、十分にはテストできていませんし、(前に述べた状態保存の問題に見られるように)製品のコードで用いるのに十分かは保証できません。このシリーズのゴールは難しいように見える効果であっても、簡単な方法と標準のAPIで達成できるという点を証明することです。また、この方法は他の多くの事柄(例えば、Google+のプロフィール画面にみられるような、視差効果を伴い、指についてくるTabsを作るなど)にも利用できることでしょう。楽しくコーディングしましょう。
コード
この投稿で用いたプロジェクトのソースコードはGitHubのレポジトリにあります。
Michal Z