GoogleI/O 2014 公式アプリでの、この画面です。
中央左の FloatingActionButton 、ヘッダーの座標計算、スクロール時の画像のパララックスエフェクト…
どうやって実装しているんでしょうね。
android.support.v7.widget.Toolbar
も使われているようです。
すごく知りたくなりました。知りたいですよね…?
レイアウトファイル
ざっくり見ると以下のレイアウト構成のようです。分かりやすくするため一部の属性は etc... で省いてます。
<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
はそのままコピペしてます
<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
継承クラスに内包されてます
こちらもそのままコピペしてます
<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
ここで注目するのは android.support.v7.widget.Toolbar
の位置でしょうか。
android.support.v7.widget.Toolbar
は android.view.ViewGroup
を継承していて、ただの View
としても使えつつ ActionBar
の振る舞いも出来ます。iosched では、セッションタイトルとサブタイトルを表示する TextView
と一緒に、1つの LinearLayout
でまとめてます。
しかし、このままでは単純に重なって表示されるだけですが、内部コンテンツに応じて動的に計算する必要があり、そこはコードで行っています。
初期表示時のレイアウト調整
ViewTreeObserver
を使用して、この画面の親Viewとなる ScrollView
のレイアウト計測完了時に、セッション画像View、セッション詳細View、ヘッダー、FloatingActionButton のレイアウト調整を行ってます。
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のレイアウト調整を行ってます。
- セッション画像を内包する
LinearLayout
のheight
を指定 - ヘッダーとセッション画像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);
このコードをコメントアウト/解除しても特に動作に違いが見られなかったのが謎でした…
以上です。