この記事は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
ボタンデザイン
ボタン押した時にくるりんってなるデザインです。セッション詳細画面で実装されています。
<!-- FAB -->
<include layout="@layout/include_add_schedule_fab" />
include_add_schedule_fabはLollipopとそれ以外で別々のlayoutが定義されています。
Lollipop用の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()
で設定されています。
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
を継承しています。
@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はその名と通りカードのようなデザインで、表示・非表示、デザインアレンジや複数のアクションがとることができるタイルとは異なるデザインとなっています。
CardViewはiosched内ではセッションフィードバックやライブ動画がある場合の通知として利用されています。
iosched内ではCardViewを継承したMessageCardViewとして実装されています。
<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でメソッドを作ります。
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
###遷移時のアニメーション
動画
画面遷移時のアニメーションは、ioschedでは実装には組み込まれているのですが、ちゃんと設定されてません。
セッション一覧からセッション詳細に移る際にTransitionアニメーションが実装されています。実際に動きを確認したい場合は以下の設定をしてください。
- styles.xmlに
windowEnterTransition
、windowExitTransition
を追加します。
<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()
のコメントアウトを外します。
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の問題かもです。動画は以下です。
一応実装について書いておくと、遷移させたい画像(transitionName)の紐付けを行うことで可能となります。
渡し側:
@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を紐付けて渡してあげる
}
受け側:
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に以下のように記述します。
<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で以下のように呼び出します。
Toolbar toolbar = (Toolbar) findViewById(R.id.tool_bar);
setSupportActionBar(toolbar);
Toolbarはいたるところで利用されているので、毎回これ呼ぶんかい…って思ってたら、さすがにそこには工夫がありました。
protected Toolbar getActionBarToolbar() {
if (mActionBarToolbar == null) {
mActionBarToolbar = (Toolbar) findViewById(R.id.toolbar_actionbar);
if (mActionBarToolbar != null) {
setSupportActionBar(mActionBarToolbar);
}
}
return mActionBarToolbar;
}
こんな感じで、ToolbarのViewが設定されている場合はToolbarを返すようになっています。
Toolbarの特徴として、柔軟にカスタマイズできる点が上げられます。例えば、セッション一覧画面ではスピナーが追加されています。
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)
ユーザーにアクションを促す通知として、ButterBarというViewが導入されています。マテリアルデザインではSnackbarと呼ばれているデザインが近いものです。
ButterBarは普段は画面の上に表示されますが、画面をスクロールすると、引っ込んで画面の邪魔をしないような設計になっています。
実装は簡単には以下のようになっています。
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で定義されています。
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()
に渡しています。
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/配下にあります。重要な部分を抜粋すると、
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);
}
loadImage
でitmapRequestBuilder
を呼び出し、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とかも見たかったのですがそれはいずれ。