LoginSignup
37
37

More than 5 years have passed since last update.

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

Posted at

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

device-2014-11-02-022859.png

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

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

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

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

レイアウトファイル

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

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 はそのままコピペしてます

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 継承クラスに内包されてます
こちらもそのままコピペしてます

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 で囲み、その配下に1つの FrameLayout が存在し、その中には以下の4つが上から順に分けられてます。

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

materialdesign_floatingactionbutton.png

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

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

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

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

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

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

レイアウト調整を開始するメソッドです。

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のレイアウト調整を行ってます。

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

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

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

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

@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);

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


以上です。

37
37
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
37
37