GoogleI/O 2014 公式アプリ iosched の SessionDetailActivity はどのように実装されているのか


GoogleI/O 2014 公式アプリでの、この画面です。


![device-2014-11-02-022859.png](https://qiita-image-store.s3.amazonaws.com/0/35834/302c986d-9672-0019-3c6f-5cc2ba5a0180.png "device-2014-11-02-022859.png")


中央左の FloatingActionButton 、ヘッダーの座標計算、スクロール時の画像のパララックスエフェクト…

どうやって実装しているんでしょうね。

`android.support.v7.widget.Toolbar` も使われているようです。

すごく知りたくなりました。知りたいですよね…？

https://github.com/google/iosched

## レイアウトファイル

ざっくり見ると以下のレイアウト構成のようです。分かりやすくするため一部の属性は etc... で省いてます。

```XML:activity_session_detail.xml

<com.google.samples.apps.iosched.ui.widget.ObservableScrollView
    android:id="@+id/scroll_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    etc...>


    <FrameLayout
        android:id="@+id/scroll_view_child"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        etc...>


        <!-- 背景上部に使用する画像 -->
        <FrameLayout android:id="@+id/session_photo_container"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
            <ImageView
                android:id="@+id/session_photo"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:scaleType="centerCrop" />
        </FrameLayout>


        <!-- セッションの詳細情報やコメントの View を複数並べる -->
        <LinearLayout android:id="@+id/details_container"
            android:orientation="vertical"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            etc...>
            <!-- 省略 -->
        </LinearLayout>


        <!-- ヘッダー -->
        <LinearLayout
            android:id="@+id/header_session"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            etc...>

            <!-- ここに android.support.v7.widget.Toolbar を定義 -->
            <include layout="@layout/toolbar_actionbar" />

            <TextView 
                android:id="@+id/session_title"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="@string/placeholder_session_title"
                android:maxLines="4"
                android:ellipsize="end" />

            <TextView 
                android:id="@+id/session_subtitle"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="@string/placeholder_session_subtitle"
                android:maxLines="2"
                android:ellipsize="end" />

        </LinearLayout>

        <!-- FloatingActionButton -->
        <include layout="@layout/include_add_schedule_fab" />

    </FrameLayout>

</com.google.samples.apps.iosched.ui.widget.ObservableScrollView>
```

***
`toolbar_actionbar.xml` はそのままコピペしてます

```XML:toolbar_actionbar.xml
<android.support.v7.widget.Toolbar 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:iosched="http://schemas.android.com/apk/res-auto"
    iosched:theme="@style/ActionBarThemeOverlay"
    iosched:popupTheme="@style/ActionBarPopupThemeOverlay"
    android:id="@+id/toolbar_actionbar"
    android:background="@null"
    iosched:titleTextAppearance="@style/ActionBar.TitleText"
    iosched:contentInsetStart="?actionBarInsetStart"
    android:layout_width="match_parent"
    android:layout_height="?actionBarSize" />
```

***
FloatingActionButton は `Checkable` インターフェースを実装した `FrameLayout` 継承クラスに内包されてます
こちらもそのままコピペしてます

```XML:include_add_schedule_fab.xml
<com.google.samples.apps.iosched.ui.widget.CheckableFrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/add_schedule_button"
    android:layout_width="@dimen/add_to_schedule_button_height"
    android:layout_height="@dimen/add_to_schedule_button_height"
    android:layout_marginLeft="@dimen/keyline_1_minus_8dp"
    android:visibility="invisible"
    android:clickable="true"
    android:focusable="true"
    android:background="@drawable/add_schedule_fab_background"
    android:contentDescription="@string/add_to_schedule">

    <ImageView android:id="@+id/add_schedule_icon"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scaleType="center"
        android:src="@drawable/add_schedule_button_icon_unchecked"
        android:contentDescription="@null"
        android:layout_gravity="center" />

</com.google.samples.apps.iosched.ui.widget.CheckableFrameLayout>
```
以上がレイアウト構成です。
***
まず全体を `ScrollView` で囲み、その配下に１つの `FrameLayout` が存在し、その中には以下の４つが上から順に分けられてます。

* FloatingActionButton
* ヘッダー
* セッション詳細View
* セッション画像View

![materialdesign_floatingactionbutton.png](https://qiita-image-store.s3.amazonaws.com/0/35834/ed3ff9c1-25d7-4a59-10c0-274f4f9db62f.png "materialdesign_floatingactionbutton.png")



ここで注目するのは `android.support.v7.widget.Toolbar` の位置でしょうか。

`android.support.v7.widget.Toolbar` は `android.view.ViewGroup` を継承していて、ただの `View` としても使えつつ `ActionBar` の振る舞いも出来ます。iosched では、セッションタイトルとサブタイトルを表示する `TextView` と一緒に、１つの `LinearLayout` でまとめてます。

しかし、このままでは単純に重なって表示されるだけですが、内部コンテンツに応じて動的に計算する必要があり、そこはコードで行っています。


## 初期表示時のレイアウト調整

`ViewTreeObserver` を使用して、この画面の親Viewとなる `ScrollView` のレイアウト計測完了時に、セッション画像View、セッション詳細View、ヘッダー、FloatingActionButton のレイアウト調整を行ってます。

```Java:SessionDetailActivity.java

private ObservableScrollView mScrollView;
private CheckableFrameLayout mAddScheduleButton;

private View mHeaderBox;
private View mDetailsContainer;

private int mPhotoHeightPixels;
private int mHeaderHeightPixels;
private int mAddScheduleButtonHeightPixels;

private View mPhotoViewContainer;
private ImageView mPhotoView;

@Override
protected void onCreate(Bundle savedInstanceState) {
    // 省略

    // 各 View のインスタンス取得、ViewTreeObserver リスナー登録

    mAddScheduleButton = (CheckableFrameLayout) findViewById(R.id.add_schedule_button);

    mScrollView = (ObservableScrollView) findViewById(R.id.scroll_view);
    mScrollView.addCallbacks(this);
    ViewTreeObserver vto = mScrollView.getViewTreeObserver();
    if (vto.isAlive()) {
        vto.addOnGlobalLayoutListener(mGlobalLayoutListener);
    }

    mDetailsContainer = findViewById(R.id.details_container);
    mHeaderBox = findViewById(R.id.header_session);
    mPhotoViewContainer = findViewById(R.id.session_photo_container);
    mPhotoView = (ImageView) findViewById(R.id.session_photo);

    // 省略
}

private ViewTreeObserver.OnGlobalLayoutListener mGlobalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() {
    @Override
    public void onGlobalLayout() {

        // ScrollView のレイアウト計測完了時に各 View のレイアウト調整を開始する

        mAddScheduleButtonHeightPixels = mAddScheduleButton.getHeight();
        recomputePhotoAndScrollingMetrics();
    }
};
```
***
レイアウト調整を開始するメソッドです。

```Java:
private void recomputePhotoAndScrollingMetrics() {
    mHeaderHeightPixels = mHeaderBox.getHeight();

    mPhotoHeightPixels = 0;
    if (mHasPhoto) {
        mPhotoHeightPixels = (int) (mPhotoView.getWidth() / PHOTO_ASPECT_RATIO);
        mPhotoHeightPixels = Math.min(mPhotoHeightPixels, mScrollView.getHeight() * 2 / 3);
    }

    ViewGroup.LayoutParams lp;
    lp = mPhotoViewContainer.getLayoutParams();
    if (lp.height != mPhotoHeightPixels) {
        lp.height = mPhotoHeightPixels;
        mPhotoViewContainer.setLayoutParams(lp);
    }

    ViewGroup.MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams)
            mDetailsContainer.getLayoutParams();
    if (mlp.topMargin != mHeaderHeightPixels + mPhotoHeightPixels) {
        mlp.topMargin = mHeaderHeightPixels + mPhotoHeightPixels;
        mDetailsContainer.setLayoutParams(mlp);
    }

    onScrollChanged(0, 0); // trigger scroll handling
}
```
***

このメソッドでセッション画像Viewとセッション詳細Viewのレイアウト調整を行ってます。

* セッション画像を内包する `LinearLayout` の `height` を指定
* ヘッダーとセッション画像Viewの `height` の合計値をセッション詳細Viewの `topMargin` に指定

しかし、ここではまだヘッダーと FloatingActionButton のレイアウト調整はしていません。この２つはスクロール時に動的にレイアウト調整を行う必要があるので、`onScrollChanged` の中で調整してます。よって、このメソッド内で `onScrollChanged(0, 0)` を呼び出しています。

## スクロール時のレイアウト調整

この `SessionDetailActivity` ではスクロール時に `onScrollChanged` が呼ばれ、その中でヘッダーと FloatingActionButton のレイアウト調整、セッション画像のパララックスエフェクトを処理してます。

```Java:
@Override
public void onScrollChanged(int deltaX, int deltaY) {
    // Reposition the header bar -- it's normally anchored to the top of the content,
    // but locks to the top of the screen on scroll
    int scrollY = mScrollView.getScrollY();

    float newTop = Math.max(mPhotoHeightPixels, scrollY);
    mHeaderBox.setTranslationY(newTop);
    mAddScheduleButton.setTranslationY(newTop + mHeaderHeightPixels - mAddScheduleButtonHeightPixels / 2);

    float gapFillProgress = 1;
    if (mPhotoHeightPixels != 0) {
        gapFillProgress = Math.min(Math.max(UIUtils.getProgress(scrollY,
                0,
                mPhotoHeightPixels), 0), 1);
    }

    ViewCompat.setElevation(mHeaderBox, gapFillProgress * mMaxHeaderElevation);
    ViewCompat.setElevation(mAddScheduleButton, gapFillProgress * mMaxHeaderElevation + mFABElevation);

    // Move background photo (parallax effect)
    mPhotoViewContainer.setTranslationY(scrollY * 0.5f);
}
```

***

`Math.max(mPhotoHeightPixels, scrollY)` で求めた値をヘッダーに `setTranslationY` で指定することで、セッション詳細View上部に追従、もしくは画面上部に常に表示されます。FloatingActionButton についても、コードを見れば一目瞭然ですね。

一見、コード量が少なくはないので複雑そうですが、読み解いていけばそんなにトリッキーなことはしていないかなと感じました。

ただ、`onScrollChanged` での

```
ViewCompat.setElevation(mHeaderBox, gapFillProgress * mMaxHeaderElevation);
ViewCompat.setElevation(mAddScheduleButton, gapFillProgress * mMaxHeaderElevation + mFABElevation);
```

このコードをコメントアウト/解除しても特に動作に違いが見られなかったのが謎でした…
***
以上です。