LoginSignup
92
92

More than 5 years have passed since last update.

Google IO 2014公式アプリioschedのソースをじっくり読んだ

Posted at

この記事はAndroid Advent Calender 2014の10日目の記事です。

背景

今年の夏にブログにGoogle IO 2014公式アプリのソースコード公開されてたのでビルドしてみたをというエントリを書いていたのですが、結局全然実装を見る機会がなかったので、この機会にソースコードリーディングをしてみました。特にUI周りで色々と勉強になったことをまとめました。ソースコード読む時の手助けになれば良いです。

ビルド方法

ブログで書いたビルド方法は古くなっています。こちらを参考にしてください。

ioschedの構成

ioschedの画面構成はざっくり以下のようになっています。それ以外もありますが、細かいものは省いています。
このうち、Google I/Oに参加してなくても利用できるのは、表で◯がついた機能になります。

画面 アクティビティ名 機能利用できる?
セッション一覧(トップ画面) BrowseSessionActivity
セッション詳細 SessionDetailActivity
マップ BaseMapActivity ?(実装上だと利用できそうだけど表示されない??)
私のスケジュール MyScheduleActivity
私が出会った人々 PeopleIveMetActivity ☓(バッチスキャンが必要)
ソーシャル SocialActivity
ビデオライブラリ VideoLibraryActivity
設定 SettingsActivity
検索 SearchActivity

多岐に渡るので、今回はUIやライブラリ部分だけピックアップしました。

UI

ボタンデザイン

button.gif

動画

ボタン押した時にくるりんってなるデザインです。セッション詳細画面で実装されています。

activity_session_detail.xml
<!-- FAB -->
<include layout="@layout/include_add_schedule_fab" />

include_add_schedule_fabはLollipopとそれ以外で別々のlayoutが定義されています。
Lollipop用のxmlでは以下の様に定義されています。

include_add_schedule_fab.xml
<com.google.samples.apps.iosched.ui.widget.AddToScheduleFABFrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/add_schedule_button"
    android:layout_width="@dimen/add_to_schedule_button_height_no_padding"
    android:layout_height="@dimen/add_to_schedule_button_height_no_padding"
    android:layout_marginLeft="@dimen/keyline_1"
    android:visibility="invisible"
    android:clickable="true"
    android:focusable="true"
    android:elevation="@dimen/fab_elevation" <!-- 影を設定 -->
    android:background="@drawable/add_schedule_fab_ripple_background_off"
    android:stateListAnimator="@anim/add_schedule_fab_state_list_anim" <!-- フローティングアニメーションを設定 -->
    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.AddToScheduleFABFrameLayout>

AddToScheduleFABFrameLayoutが呼び出されています。
android:elevationをつかって影を出しています。またstateListAnimatorでクリック時のフローティング・アニメーションが設定されています。
Animationの動きの実装はSessionDetailActivity.showStarred()内で呼び出されるgetLUtils().setOrAnimatePlusCheckIcon()で設定されています。

SessionDetailActivity.java
public void setOrAnimatePlusCheckIcon(final ImageView imageView, boolean isCheck,
                                      boolean allowAnimate) {
    if (!hasL()) {
        compatSetOrAnimatePlusCheckIcon(imageView, isCheck, allowAnimate);
        return;
    }

    Drawable drawable = imageView.getDrawable();
    if (!(drawable instanceof AnimatedStateListDrawable)) {
        drawable = mActivity.getResources().getDrawable(R.drawable.add_schedule_fab_icon_anim);
        imageView.setImageDrawable(drawable);
    }
    imageView.setColorFilter(isCheck ?
            mActivity.getResources().getColor(R.color.theme_accent_1) : Color.WHITE);
    if (allowAnimate) {
        imageView.setImageState(isCheck ? STATE_UNCHECKED : STATE_CHECKED, false);
        drawable.jumpToCurrentState();
        imageView.setImageState(isCheck ? STATE_CHECKED : STATE_UNCHECKED, false);
    } else {
        imageView.setImageState(isCheck ? STATE_CHECKED : STATE_UNCHECKED, false);
        drawable.jumpToCurrentState();
    }
}

実際の切替動作はAddToScheduleFABFrameLayout.setCheckedで実装されています。
このクラスはLollipop以前で呼び出される、CheckableFrameLayoutを継承しています。

AddToScheduleFABFrameLayout.java
@Override
public void setChecked(boolean checked, boolean allowAnimate) {
    super.setChecked(checked, allowAnimate);
    if (allowAnimate) {
        Animator animator = ViewAnimationUtils.createCircularReveal(
                mRevealView,
                (int) getWidth() / 2, (int) getHeight() / 2, 0, getWidth() / 2);
        animator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                setChecked(mChecked, false);
            }
        });
        animator.start();
        mRevealView.setVisibility(View.VISIBLE);
        mRevealView.setBackgroundColor(mChecked ? Color.WHITE : mRevealViewOffColor);
    } else {
        mRevealView.setVisibility(View.GONE);
        RippleDrawable newBackground = (RippleDrawable) getResources().getDrawable(mChecked
                ? R.drawable.add_schedule_fab_ripple_background_on
                : R.drawable.add_schedule_fab_ripple_background_off);
        setBackground(newBackground);
    }
}

ここで定義されているRippleDrawableはLollipop以降で利用可能なクラスです。
onAnimationEndの後にsetChecked(mChecked, false)を呼び出し、実際のチェックボタン画像を切替えるような実装になっています。

参考:
https://developer.android.com/reference/android/view/ViewAnimationUtils.html

CardView

スクリーンショット 2014-12-09 20.14.35.png

CardViewはその名と通りカードのようなデザインで、表示・非表示、デザインアレンジや複数のアクションがとることができるタイルとは異なるデザインとなっています。
CardViewはiosched内ではセッションフィードバックやライブ動画がある場合の通知として利用されています。
iosched内ではCardViewを継承したMessageCardViewとして実装されています。

activity_session_detail.xml
<com.google.samples.apps.iosched.ui.widget.MessageCardView
    android:id="@+id/give_feedback_card"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    iosched:messageText="@string/session_give_feedback_message"
    iosched:button1text="@string/no_thanks"
    iosched:button2text="@string/give_feedback"
    iosched:button2tag="GIVE_FEEDBACK"
    iosched:button2emphasis="true"
    iosched:cardBackgroundColor="#fff"
    android:layout_marginLeft="@dimen/keyline_2_session_detail"
    android:layout_marginBottom="16dp"
    android:visibility="gone"
    />

iosched:となっている部分はattr.xmlで定義された値です。

ソースコード側ではSessionDetailActivity.javaでメソッドを作ります。

SessionDetailActivity.java
private void showGiveFeedbackCard() {
    final MessageCardView messageCardView = (MessageCardView) findViewById(R.id.give_feedback_card);
    messageCardView.show();
    messageCardView.setListener(new MessageCardView.OnMessageCardButtonClicked() {
        @Override
        public void onMessageCardButtonClicked(String tag) {
            if ("GIVE_FEEDBACK".equals(tag)) {
                Intent intent = getFeedbackIntent();
                startActivity(intent);
            } else {
                sDismissedFeedbackCard.add(mSessionId);
                messageCardView.dismiss();
            }
        }
    });
}

Transition

遷移時のアニメーション

explode.gif
動画

画面遷移時のアニメーションは、ioschedでは実装には組み込まれているのですが、ちゃんと設定されてません。
セッション一覧からセッション詳細に移る際にTransitionアニメーションが実装されています。実際に動きを確認したい場合は以下の設定をしてください。

  • styles.xmlにwindowEnterTransitionwindowExitTransitionを追加します。
res/styles-v21.xml
<style name="Theme.IOSched" parent="Theme.IOSched.Base">
//中略
    <item name="android:windowContentTransitions">true</item>
    <item name="android:windowEnterTransition">@android:transition/explode</item>
    <item name="android:windowExitTransition">@android:transition/explode</item>
</style>
  • LUtils.startActivityWithTransition()からmakeSceneTransitionAnimation() のコメントアウトを外します。
LUtils.java
public void startActivityWithTransition(Intent intent, final View clickedView,
                                        final String transitionName) {
    ActivityOptions options = null;
    if (hasL() && clickedView != null && !TextUtils.isEmpty(transitionName)) {
        options = ActivityOptions.makeSceneTransitionAnimation(
                mActivity, clickedView, transitionName); //ここのコメントアウト外す
    }
    mActivity.startActivity(intent, (options != null) ? options.toBundle() : null);
}

画像のシームレスな遷移アニメーション

同じ画像が使われる画面間(セッション一覧とセッション詳細等)で画像を拡大縮小させたいと思い、色々試みたのですが、戻る時だけはうまく行き、クリック時はうまくいきませんでした。セッション一覧からセッション詳細に飛ぶ時のintentの問題かもです。動画は以下です。

transition.gif

動画

一応実装について書いておくと、遷移させたい画像(transitionName)の紐付けを行うことで可能となります。

渡し側:

BrowseSessionActivity.java
@Override
public void onSessionSelected(String sessionId, View clickedView) {
    String id = SessionDetailActivity.TRANSITION_NAME_PHOTO + "_" + sessionId;
    getLUtils().startActivityWithTransition(new Intent(Intent.ACTION_VIEW,
                    ScheduleContract.Sessions.buildSessionUri(sessionId)),
            clickedView,
            id); //transitionNameに画像とセッションIDを紐付けて渡してあげる
}

受け側:

SessionDetailActivity.java
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_session_detail);
    //中略
    mSessionId = ScheduleContract.Sessions.getSessionId(mSessionUri);
    mPhotoView = (ImageView) findViewById(R.id.session_photo);
    //トランジションさせたい画像にtransitionNameを設定
    ViewCompat.setTransitionName(mPhotoView, TRANSITION_NAME_PHOTO + "_" + mSessionId ); 
    //後略
}

画像の遷移についてはあんざいさんのブログが大変わかりやすかったです。

Toolbar

Toolbarはロリポップから導入されたActionbarの進化版です。当然ioschedでも利用されています。
ToolbarはViewに近い存在なので、一般的にはToolbarを利用したいactivityのxmlに以下のように記述します。

activity_hoge.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".HogeActivity">
 <!-- ToolBarをいれたいところ -->
<android.support.v7.widget.Toolbar
        android:id="@+id/tool_bar"
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:minHeight="?attr/actionBarSize"
        android:background="?attr/colorPrimary">
</android.support.v7.widget.Toolbar>

で、ActivityのonCreateで以下のように呼び出します。

HogeActivity.java
Toolbar toolbar = (Toolbar) findViewById(R.id.tool_bar);
setSupportActionBar(toolbar);

Toolbarはいたるところで利用されているので、毎回これ呼ぶんかい…って思ってたら、さすがにそこには工夫がありました。

BaseActivity.java

protected Toolbar getActionBarToolbar() {
        if (mActionBarToolbar == null) {
            mActionBarToolbar = (Toolbar) findViewById(R.id.toolbar_actionbar);
            if (mActionBarToolbar != null) {
                setSupportActionBar(mActionBarToolbar);
            }
        }
        return mActionBarToolbar;
    }

こんな感じで、ToolbarのViewが設定されている場合はToolbarを返すようになっています。
Toolbarの特徴として、柔軟にカスタマイズできる点が上げられます。例えば、セッション一覧画面ではスピナーが追加されています。

BrowseSessionsActivity.java
private void trySetUpActionBarSpinner() {
    Toolbar toolbar = getActionBarToolbar();
    mSpinnerConfigured = true;
    mTopLevelSpinnerAdapter.clear();
    mTopLevelSpinnerAdapter.addItem("", getString(R.string.all_sessions), false, 0);
    //中略    
    //スピナーコンテナの追加
    View spinnerContainer = LayoutInflater.from(this).inflate(R.layout.actionbar_spinner,
            toolbar, false);
    ActionBar.LayoutParams lp = new ActionBar.LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
    toolbar.addView(spinnerContainer, lp);

    //スピナーの設定
    Spinner spinner = (Spinner) spinnerContainer.findViewById(R.id.actionbar_spinner);
    spinner.setAdapter(mTopLevelSpinnerAdapter);
    spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
        @Override
        public void onItemSelected(AdapterView<?> spinner, View view, int position, long itemId) {
            onTopLevelTagSelected(mTopLevelSpinnerAdapter.getTag(position));
        }

        @Override
        public void onNothingSelected(AdapterView<?> adapterView) {
        }
    });
    //後略
}

ButterBar(Snackbar)

butter1.gif
動画

ユーザーにアクションを促す通知として、ButterBarというViewが導入されています。マテリアルデザインではSnackbarと呼ばれているデザインが近いものです。
ButterBarは普段は画面の上に表示されますが、画面をスクロールすると、引っ込んで画面の邪魔をしないような設計になっています。
実装は簡単には以下のようになっています。

BrowseSessionsAcitivity.java
private View mButterBar;
@Override
protected void onCreate(Bundle savedInstanceState) {
    mButterBar = findViewById(R.id.butter_bar);
    registerHideableHeaderView(mButterBar);
}

@Override
public void onResume() {
    super.onResume();
    checkShowStaleDataButterBar();
}

//ButterBarに表示するメッセージとボタン押下時のアクションを設定
private void checkShowStaleDataButterBar() {

    //mustShowBarがtrueか判定する処理(中略)
    if (!mustShowBar) {
        mButterBar.setVisibility(View.GONE);
    } else {
        UIUtils.setUpButterBar(mButterBar, getString(R.string.data_stale_warning),
                getString(R.string.description_refresh), new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        mButterBar.setVisibility(View.GONE);
                        updateFragContentTopClearance();
                        mLastDataStaleUserActionTime = UIUtils.getCurrentTime(
                                BrowseSessionsActivity.this);
                        requestDataRefresh();
                    }
                }
        );
    }
    //後略
}

registerHideableHeaderViewはスクロール時の表示を管理するViewを登録するメソッドです。スクロール時の表示、非表示は継承元のBaseActivityで定義されています。

BaseActivity.java
protected void enableActionBarAutoHide(final ListView listView) {
    initActionBarAutoHide();   //表示を隠し始める初期値を設定
    listView.setOnScrollListener(new AbsListView.OnScrollListener() {
        final static int ITEMS_THRESHOLD = 3;
        int lastFvi = 0;

        @Override
        public void onScrollStateChanged(AbsListView view, int scrollState) {
        }
        @Override
        public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
            //firstVisibleItemが4以上ある場合は開始位置にはいない(currentY はInteger.MAX_VALUE)
            //前回スクロール時よりも表示数が少ない -> バックスクロール(deltaYはInteger.MIN_VALUE)
            //前回スクロール時よりも表示数多い -> スクロール(deltaYはInteger.MAX_VALUE) 
            onMainContentScrolled(firstVisibleItem <= ITEMS_THRESHOLD ? 0 : Integer.MAX_VALUE,
                    lastFvi - firstVisibleItem > 0 ? Integer.MIN_VALUE :
                            lastFvi == firstVisibleItem ? 0 : Integer.MAX_VALUE
            );
            lastFvi = firstVisibleItem;
        }
    });
}
private void onMainContentScrolled(int currentY, int deltaY) {
    ////deltaYはmActionBarAutoHideSensivity(48dp)に変換
    if (deltaY > mActionBarAutoHideSensivity) {
        deltaY = mActionBarAutoHideSensivity;
    } else if (deltaY < -mActionBarAutoHideSensivity) {
        deltaY = -mActionBarAutoHideSensivity;
    }

    if (Math.signum(deltaY) * Math.signum(mActionBarAutoHideSignal) < 0) {
        //スクロールの向きが逆になった(符号がマイナス)ので、蓄積はリセット
        mActionBarAutoHideSignal = deltaY;
    } else {
        // スクロールの値を蓄積
        mActionBarAutoHideSignal += deltaY;
    }

    boolean shouldShow = currentY < mActionBarAutoHideMinY ||
            (mActionBarAutoHideSignal <= -mActionBarAutoHideSensivity);
            //スクロール蓄積量が一定量になったらバーのアクションを実行
    autoShowOrHideActionBar(shouldShow);
}

実装をみるとわかるのですが、onMainContentScrolledのcurrentYには0(TOP付近) or Integer.MAX_VALUE(TOPでないどこか)、deltaYにはInteger.MAX_VALUE(下スクロール)Integer.MIN_VALUE(上スクロール)という値しかはいらず、この組み合わせで表示非表示をautoShowOrHideActionBar() を経由して onActionBarAutoShowOrHide()に渡しています。

BaseActivity.java
protected void onActionBarAutoShowOrHide(boolean shown) {
   //中略
   for (View view : mHideableHeaderViews) {
        //表示を隠すビューはここで管理
         if (shown) {
                view.animate()
                        .translationY(0)
                        .alpha(1)
                        .setDuration(HEADER_HIDE_ANIM_DURATION)
                        .setInterpolator(new DecelerateInterpolator());
         } else {
                view.animate()
                        .translationY(-view.getBottom())
                        .alpha(0)
                        .setDuration(HEADER_HIDE_ANIM_DURATION)
                        .setInterpolator(new DecelerateInterpolator());
         }
    }
}

通信まわり

ioschedでは通信周りでいくつかのライブラリを使い分けていました。

imageLoader

ioschedではglideというメディア管理ライブラリを利用しています。
glideはbitmapなどのimageのみならず、videoやgifアニメーションなどのdecodeやメモリキャッシュ管理を行うことができます。デフォルトではHttpUrlConnectionで通信ですが、dependencyの設定でVolleyやokHTTPも利用可能とのことです。ioschedでは外部ライブラリとして導入しています。
imageLoaderはutil/配下にあります。重要な部分を抜粋すると、

imageLoader.java
public void loadImage(String url, ImageView imageView, RequestListener<String> requestListener,
            Drawable placeholderOverride, boolean crop) {
    BitmapRequestBuilder request = beginImageLoad(url, requestListener, crop)
            .animate(R.anim.image_fade_in);
    if (placeholderOverride != null) {
        request.placeholder(placeholderOverride);
    } else if (mPlaceHolderResId != -1) {
        request.placeholder(mPlaceHolderResId);
    }
    request.into(imageView);
}

public BitmapRequestBuilder beginImageLoad(String url,
        RequestListener<String> requestListener, boolean crop) {
    return mGlideModelRequest.load(url)
            .asBitmap() // don't allow animated GIFs
            .listener(requestListener)
            .transform(crop ? mCenterCrop : mNone);
}

loadImageitmapRequestBuilderを呼び出し、url, リクエストの結果を受け取るrequestListener, ロード時に背景色として表示するplaceholderOverride, placeholderにあわせたcropの有無を設定しています。設定した値をglideのmGlideModelRequestに指定しています。

このクラスはこのまま利用できそうな位良く出来てますので、ソースコード直接見ると勉強になると思います。感覚的にはPicassoよりも表示が速いかも。

Volley

言わずと知れたマルチ対応した通信ライブラリです。去年のGoogle I/Oで発表されました。このアプリでは検出されたBLEを受信するために利用されています。(MetadataResolverで利用されています)
BLEのリクエストデータがjson形式であるために、Volleyが利用されているようです。

BasicHttpClient

カンファレンスデータ等と言った一般的なデータ取得はBasicHttpClientというライブラリが利用されています。java.net.HttpURLConnectionを使ったミニマルなリクエストが実行できます。

まとめ

  • UI、とくにアクション周りはこのアプリの実装参考にすれば、ちゃんとしたマテリアルデザインができそう。
  • Handler周りとかもガッツリ実装してあってやっぱgoogleスゲー。データの管理周りも色々見たのですが、1つの記事じゃまとめきれん。
  • Map周りもBLEとか使っている機能とかあって面白そうだったのですが、表示でない&調査時間もなかったので今回は見送り。
  • タブレットでの実装とかWearableとかも見たかったのですがそれはいずれ。
92
92
2

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