LoginSignup
30
33

More than 5 years have passed since last update.

[翻訳] リストをスクロールしたときToolbarを表示・非表示にする方法(パート2)

Last updated at Posted at 2015-05-14

原文

How to hide/show Toolbar when list is scrolling (part 2)
訳文を掲載する承諾は得ております。

まえがき

このシリーズの2回目(そして最後)の投稿です。もしあなたがパート1を読んでいないなら、読むことをお勧めします。前のパートではGoogle+のようにToolbarを隠す効果の実装を行うことができました。今日はGoogle PlayストアのToolbarのような振る舞いをどのように作れるかを見ていきましょう。はじまりはじまり。

始める前に、このプロジェクトを少しリファクタリングしたことを述べておきます。MainActivityから呼び出される2つのActivityPartOneActivityPartTwoActivityに分離しました。あなたが参照したいクラスを簡単に見つけられるように、コードはpartoneparttwoのパッケージにあります。

最終的に完成した効果をPlay StoreのToolbarと比較してみます。
goal
playstore

まず最初に

build.gradleパート1と同じなので、そちらを参照してもらうことにして、Activityのレイアウトを作るところから始めたいと思います。

activity_part_two.xml
<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>

RecyclerViewToolbar (後ほどTabsを追加)だけでできています。前の投稿では(RecyclerViewにpaddingを追加するという) 2つ目の方法を用いていたことに気を付けてください。

リストアイテムのレイアウトファイルは、とまったく同じなので省略します。RecyclerAdapterも同様に飛ばします。(こちらを参照してください。シンプルでヘッダーのないアダプターです。)

PartTwoActivityのコードを見てみます。

ActivityPartTwo.java
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;
    }
}

RecyclerViewToolbarの基本的な初期化をしています。27行目でOnScrollListenerをセットしていることに注意してください。

最も重要な部分はHidingScrollListenerです。これを作っていきます。

HidingScrollListener.java
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の高さの間で値を変化させる、簡単な実装です。

HidingScrollListener.java
if((mToolbarOffset <mToolbarHeight && dy>0) || (mToolbarOffset >0 && dy<0)) {
    mToolbarOffset += dy;
} 

上にスクロールした時に(Toolbarの高さより大きくならないように)値を増加させ、下にスクロールした時に(0より小さくならないように)値を減少させます。これらの値を制限する理由がだんだん見えてくると思います。(例えばフリックした時に)短時間で範囲外の値を撮る可能性があり、ちらつきを起こす可能性があるため、mToolbarOffsetをクリップするのです。また、onMoved()をスクロール時に呼び出す抽象メソッドを定義しておきます。ここでは不思議に思われることでしょう。

PartTwoActivityに戻って、メソッドonMoved()をスクロールリスナーの中に実装します。

PartTwoActivity.java
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);
        }
    });
}

はい、動きました。アプリを実行すると、実装通りに動きます。
nosnap

まあまあです。予想通り、Toolbarはリストとともに動いて隠れ、また戻ってきます。変数mToolbarOffsetに実装した制限のおかげです。もし0より大きくmToolbarOffsetより小さいかのチェックを省略していたなら、Toolbarは画面の外のどこか遠くへ行ってしまうことでしょう。mToolbarHeightより上にスクロールしたとき、それより上にスクロールさせず、リストの上で停止させることで、下にスクロールしたとき、すぐ表示することができます。

まあまあ良く動きますが、望んでいたものではありません。スクロールを途中で止めたときにToolbarが半分だけ見てているのは気持ち悪いです。実際に、Google Playゲームアプリで見られるこの動きはバグではないでしょうか。

Toolbarのスナップ

Chromeアプリのロゴ・検索バーやPlayストアアプリのToolbarのように、Viewは所定の位置にスムーズにスナップするべきです。マテリアルデザインのガイドライン・チェックリストのどこかで見たか、Google I/Oのプレゼンの中で聞いたはずです。

もう一度、HidingScrollListenerのコードを見てみましょう。

HidingScrollListener.java
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に)いるなら、表示されているかを確認します。表示されている場合にはmToolbarOffsetHIDE_THRESHOLDより大きければ非表示にし、非表示の場合にはmToolbarOffsetSHOW_THRESHOLDより小さければ再び表示させます。

    HidingScrollListener.java
    if (mControlsVisible) {
        if (mToolbarOffset > HIDE_THRESHOLD) {
            setInvisible();
        } else {
           setVisible();
        }
    } 
    
  • そして非表示の場合には逆の動きをします。(mToolbarHeight - mToolbarOffsetにより上の座標を求めて)mToolbarOffsetSHOW_THRESHOLDより大きければ表示し、HIDE_THRESHOLDより小さければ再び非表示にします。

    HidingScrollListener.java
    else { // it's not visible
        if ((mToolbarHeight - mToolbarOffset) > SHOW_THRESHOLD) {
            setVisible();
        } else {
            setInvisible();
        }
    }
    

onScrolled()は以前のままで、これら以外には書き換える箇所はありません。最後にクラスPartTwoActivityの中に新しく2つの抽象メソッドを実装する必要があります。

PartTwoActivity.java
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();
        }

    });
}

ビルドして実装した効果を見てみましょう。
snapnotabs

良く動いています。:smile:

タブを追加するとコードが複雑になると思われるかもしれませんが、そうではないことをお見せしましょう。

タブの追加

Activityのレイアウトを修正する必要があります。

part_two_activity.xml
<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は次のようにします。

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にはいくつかあるので、ご自分でそれらを実装してください。:smile:

Tabsを追加するということは、リストを少し覆うということで、paddingを増やす必要があります。自由に設定するため、xmlではこの設定を行いません。(part_two_activity.xmlRecyclerViewからpaddingを削除することに注意してください。)Toolbarは向きや端末(例えばタブレットなど)により異なる高さを持つことがあり、すべてのケースに対応するには大量のxmlを記述する必要があるからです。その代り、コードでpaddingを設定します。

PartTwoActivity.java
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に設定するだけです。実行した結果、このようになります。

withtabs

大丈夫、リストの一つ目の項目は完全に表示されており、移動することもできています。実際に、クラスHidingScrollListenerに何も変更を加えていません。PartTwoActivityに変更を加えるだけです。

PartTwoActivity.java
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;
    }
}

変更点がお分かりでしょうか。mToolbarContainerToolbarの代わりにLinearLayoutを参照するようになり、メソッドonMove()onHide()の中ではToolbarの代わりにこのViewを移動させ、アニメーションさせています。ここではToolbarTabsを含むコンテナを移動させる必要があるのです。

実行してみると予想通りに動く気がしますが、目を凝らすと小さなバグがることに気付くでしょう。たまにコンマ何秒の間、TabsToolbarの間に白い線が現れます。アニメーションが完全に同期していないために生じていると思われます。不幸なことにこれ自体を直すことは無理でしょう。

修正は簡単で、ToolbarTabsの背景を親のレイアウトに移すだけです。

part_two_activity.xml
<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)がリストの上に表示されてしまいます。前回同様に、非常に簡単に直すことができます。

HidingScrollListener.java
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を表示)します。

これで終わりです。実行してみましょう。
goal

とてもうまく動作するようになりました。:smile:恐らく他のLayoutManagersでも変更を加えることなく動作するでしょう。

PartTwoActivity.java
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());

   //...
}

grid

スクロールの状態を保存することについてコメントで質問があり、実際に少しの問題があります。項目のテキストが十分に長く、縦長の画面では2行に、横長の画面では1行になる場合に、アイテムの高さが縦か横かで変化するという点です。縦の状態で100の位置にスクロールしており、mTotalScrolledDistanceにその値を保存していた場合、端末を回転させ一番上までスクロールすると、mTotalScrolledDistanceは0にならないのです。簡単な修正方法はありませんが、その場合でも特に問題になりません。本当にしたいことを思い出してみると、回転した後にmTotalScrolledDistanceを0にリセットしToolbarを表示させれば良いのです。

これでこのシリーズの最後の投稿です。前のパートからいくつかの点を学べたことをうれしく思います。ありがたい言葉に感謝します。:smile:このブログを書き続けていこうと思いますが、次は何を書くかはまだわかりません。:smile:

今回と前回の投稿で述べた方法はとてもよく動くと思いますが、十分にはテストできていませんし、(前に述べた状態保存の問題に見られるように)製品のコードで用いるのに十分かは保証できません。このシリーズのゴールは難しいように見える効果であっても、簡単な方法と標準のAPIで達成できるという点を証明することです。また、この方法は他の多くの事柄(例えば、Google+のプロフィール画面にみられるような、視差効果を伴い、指についてくるTabsを作るなど)にも利用できることでしょう。楽しくコーディングしましょう。

コード

この投稿で用いたプロジェクトのソースコードはGitHubのレポジトリにあります。

Michal Z

30
33
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
33