2016年12月25日日曜日

RecyclerView の SnapHelper を調べてみた

この投稿は GeekWomenJapan Advent Calendar 2016 の25日目です。

2016年の11月に droid girls というAndroidの技術に特化した女性コミュニティを立ち上げました。
第2回 Meetup では私が講師を担当してRecyclerViewを取り上げたのですが、その時に SnapHelper というものを発見してしまいました。
今日はこの SnapHelper についての話です。

ちなみに第3回 Meetupでは vector drawable を取り上げます。開催は2017年1月下旬を予定しています。


本題

以下の検証は v25.1.0 で行っています。

RecyclerView.OnFlingListener および関連する SnapHelper などは v24.2.0 で追加されました。


SnapHelper の abstract メソッド

  • public abstract int findTargetSnapPosition(LayoutManager layoutManager, int velocityX, int velocityY)
    • snap 対象の Adapter での位置を返す
  • public abstract View findSnapView(LayoutManager layoutManager)
  • public abstract int[] calculateDistanceToFinalSnap(@NonNull LayoutManager layoutManager, @NonNull View targetView)
    • snap する位置までの距離を返す

PagerSnapHelper

PagerSnapHelper は ViewPager みたいな挙動を実現するのに使います。そのため、RecyclerView も RecyclerView.Adapter が提供する子 View も height と width が MATCH_PARENT である必要があります。

使用例 public class PagerSnapHelperActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_snap_helper); final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view); recyclerView.setHasFixedSize(true); recyclerView.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)); recyclerView.setAdapter(new MyPagerAdapter()); final PagerSnapHelper pagerSnapHelper = new PagerSnapHelper(); pagerSnapHelper.attachToRecyclerView(recyclerView); } private static class MyPagerAdapter extends RecyclerView.Adapter<ViewHolder> { private static final int[] colors = { Color.WHITE, Color.RED, Color.YELLOW, Color.GREEN, Color.CYAN, Color.BLUE, Color.MAGENTA, Color.LTGRAY }; @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { return ViewHolder.create(parent); } @Override public void onBindViewHolder(ViewHolder holder, int position) { holder.textView.setText(String.valueOf(position)); holder.textView.setBackgroundColor(colors[position]); } @Override public int getItemCount() { return colors.length; } } private static class ViewHolder extends RecyclerView.ViewHolder { static ViewHolder create(@NonNull ViewGroup parent) { final TextView textView = new TextView(parent.getContext()); textView.setTextSize(32); textView.setGravity(Gravity.CENTER); textView.setLayoutParams(new RecyclerView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); return new ViewHolder(textView); } final TextView textView; private ViewHolder(@NonNull TextView itemView) { super(itemView); this.textView = itemView; } } } PagerSnapHelper のコード解説

findSnapView() では、RecyclerView の中心位置と各子 View の中心位置との距離を比較して、一番近い子 View を snap 対象としています。
findTargetSnapPosition() では、上端または左端にある子 View を基準に、fling 時の velocity の正負に応じて隣の子 View の位置を返しています。velocity の絶対値は使っていないので、弱く fling しても強く fling しても隣のページに移動するだけです。
calculateDistanceToFinalSnap() では RecyclerView の中心と snap 対象の View の中心との差を返しています。


LinearSnapHelper

LinearSnapHelper は snap 対象の View の中心が RecyclerView の中心に来るように snap します。

使用例 public class LinearSnapHelperActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_snap_helper); final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view); recyclerView.setHasFixedSize(true); recyclerView.setLayoutManager(new LinearLayoutManager(this)); recyclerView.setAdapter(new MyPagerAdapter()); final LinearSnapHelper linearSnapHelper = new LinearSnapHelper(); linearSnapHelper.attachToRecyclerView(recyclerView); } private static class MyPagerAdapter extends RecyclerView.Adapter<ViewHolder> { private static final int[] colors = { Color.WHITE, Color.RED, Color.YELLOW, Color.GREEN, Color.CYAN, Color.BLUE, Color.MAGENTA, Color.LTGRAY, Color.WHITE, Color.RED, Color.YELLOW, Color.GREEN, Color.CYAN, Color.BLUE, Color.MAGENTA, Color.LTGRAY }; @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { return ViewHolder.create(parent); } @Override public void onBindViewHolder(ViewHolder holder, int position) { holder.textView.setText(String.valueOf(position)); holder.textView.setBackgroundColor(colors[position]); } @Override public int getItemCount() { return colors.length; } } private static class ViewHolder extends RecyclerView.ViewHolder { static ViewHolder create(@NonNull ViewGroup parent) { final TextView textView = new TextView(parent.getContext()); textView.setTextSize(32); textView.setGravity(Gravity.CENTER); int height = parent.getMeasuredHeight() / 4; textView.setLayoutParams(new RecyclerView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, height)); return new ViewHolder(textView); } final TextView textView; private ViewHolder(@NonNull TextView itemView) { super(itemView); this.textView = itemView; } } } LinearSnapHelper のコード解説

findSnapView() では、RecyclerView の中心位置と各子 View の中心位置との距離を比較して、一番近い子 View を snap 対象としています。
findTargetSnapPosition() では、velocity の大きさから対応するスクロール量を計算し、1子ビューあたりの大きさからスクロールで移動する子ビューの数を計算し、現在の位置から移動する子ビューの数だけ離れた位置を返しています。
calculateDistanceToFinalSnap() では RecyclerView の中心と snap 対象の View の中心との差を返しています。


上端に snap する SnapHelper

LinearSnapHelper が中心に snap するので、そのコードを参考に center を計算する部分を top に変えれば、上端(start)に snap するようにできます。 public class MyLinearSnapHelper extends SnapHelper { ... @Override public int[] calculateDistanceToFinalSnap( @NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) { int[] out = new int[2]; if (layoutManager.canScrollHorizontally()) { out[0] = distanceToTop(layoutManager, targetView, getHorizontalHelper(layoutManager)); } else { out[0] = 0; } if (layoutManager.canScrollVertically()) { out[1] = distanceToTop(layoutManager, targetView, getVerticalHelper(layoutManager)); } else { out[1] = 0; } return out; } ... @Override public View findSnapView(RecyclerView.LayoutManager layoutManager) { if (layoutManager.canScrollVertically()) { return findTopView(layoutManager, getVerticalHelper(layoutManager)); } else if (layoutManager.canScrollHorizontally()) { return findTopView(layoutManager, getHorizontalHelper(layoutManager)); } return null; } private int distanceToTop(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView, OrientationHelper helper) { final int childTop = helper.getDecoratedStart(targetView); final int containerTop; if (layoutManager.getClipToPadding()) { containerTop = helper.getStartAfterPadding(); } else { containerTop = 0; } return childTop - containerTop; } ... @Nullable private View findTopView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) { int childCount = layoutManager.getChildCount(); if (childCount == 0) { return null; } View closestChild = null; final int top; if (layoutManager.getClipToPadding()) { top = helper.getStartAfterPadding(); } else { top = 0; } int absClosest = Integer.MAX_VALUE; for (int i = 0; i < childCount; i++) { final View child = layoutManager.getChildAt(i); int childTop = helper.getDecoratedStart(child); int absDistance = Math.abs(childTop - top); /** if child top is closer than previous closest, set it as closest **/ if (absDistance < absClosest) { absClosest = absDistance; closestChild = child; } } return closestChild; } ... }


2016年11月24日木曜日

BottomNavigationView で画面回転時に位置を保持するようにしてみた

注意:以下の内容は Design Support Library v25.0.1 時点でのものです

v25.0.0 から Design Support Library に BottomNavigationView が追加されましたが、最新版(v25.0.1)でも画面回転時に選択アイテムの位置を保持してくれず、選択が一番最初のアイテムに戻ってしまう問題があります。しかも選択中のアイテムを変更するAPIも現状では用意されていません。

いちを以下の方法で選択アイテムを変更することはできます。 final View view = findViewById(menuId); if (view != null) { view.performClick(); } でももにょるよね...

本家が対応するまでの間、上記の苦し紛れの方法を駆使した CustomBottomNavigationView を用意しました。これで画面回転時も位置が保持されます。

CustomBottomNavigationView

ついでにこれを使って fragment の入れ替えもちゃんと実装したサンプルを用意したので、ぜひ参考にしてください。

https://github.com/yanzm/BottomNavigationSample


本家で早く対応してください。


2016年11月18日金曜日

SharedElement をフェードインさせたい

やりたいことは次の動画を見てもらうのが早いです。



言葉にすると、
1. Activity1 から Activity2 に遷移するときに、
2. ある View を sharedElement としてアニメーション(移動)させたい
3. Activity2 では sharedElement が移動している間に表示している内容を変更したい

Activity1 の方のレイアウトは ImageView 一つだけで、これが sharedElement の対象。 <?xml version="1.0" encoding="utf-8"?> <FrameLayout 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"> <ImageView android:id="@+id/image" android:layout_width="128dp" android:layout_height="128dp" android:layout_gravity="bottom" android:src="@drawable/sample_image1" android:transitionName="image" tools:ignore="ContentDescription"/> </FrameLayout> Activity2 の方は ImageView が2つ重なっていて、一つ目の ImageView には Activity1 と同じ画像、二つ目の ImageView には Transition 後に表示したい画像がセットされている。二つ目の ImageView は非表示(INVISIBLE)。二つの ImageView の container である FrameLayout が sharedElement の対象。 <?xml version="1.0" encoding="utf-8"?> <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" android:orientation="vertical"> <net.yanzm.sample.SquareFrameLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:transitionName="image"> <ImageView android:id="@+id/image" android:layout_width="match_parent" android:layout_height="match_parent" android:src="@drawable/sample_image1" tools:ignore="ContentDescription"/> <ImageView android:id="@+id/image2" android:layout_width="match_parent" android:layout_height="match_parent" android:src="@drawable/sample_image2" android:visibility="invisible" tools:ignore="ContentDescription"/> </net.yanzm.sample.SquareFrameLayout> </LinearLayout> Activity1側で ActivityOptionsCompat.makeSceneTransitionAnimation() を使って Activity2 を呼び出すと、画面遷移時に SharedElement が移動します。 当たり前ですがこの段階では Activity2 側の二つ目の ImageView は出てきません(INVISIBLEなので)。 public class TransitionActivity extends AppCompatActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_transition); findViewById(R.id.image).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { move(view); } }); } private void move(View view) { Intent intent = new Intent(this, TransitionActivity2.class); final Bundle options = ActivityOptionsCompat .makeSceneTransitionAnimation(this, view, "image") .toBundle(); startActivity(intent, options); } }



そこで、独自の SharedElement 用 TransitionSet を作ります。

デフォルトはプラットフォームの @transition/move が指定されており、中身は次のようになっています。 <transitionSet xmlns:android="http://schemas.android.com/apk/res/android"> <changeBounds/> <changeTransform/> <changeClipBounds/> <changeImageTransform/> </transitionSet> そこで、以下のようなクラスを用意しました。 public class CustomTransitionSet extends TransitionSet { public CustomTransitionSet() { addTransition(new ChangeBounds()); addTransition(new ChangeTransform()); addTransition(new ChangeClipBounds()); addTransition(new ChangeImageTransform()); addTransition(new CustomTransition().addTarget(R.id.image2)); } } デフォルトの設定 + CustomTransition を追加しています。 CustomTransition は Activity2 の二つ目の ImageView だけを対象にしたいので、addTarget で対象を絞っています。
この CustomTransitionSet を Activity2 で SharedElement 用の Transition としてセットします。 public class TransitionActivity2 extends AppCompatActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); getWindow().setSharedElementEnterTransition(new CustomTransitionSet()); setContentView(R.layout.activity_transition2); } } CustomTransition では Transition 開始時の view の Visibility を持っておいて、それが VISIBLE 以外だったらフェードアウト、VISIBLE だったらフェードインのアニメーションをするようにしました。 public class CustomTransition extends Transition { // TransitionValues に追加するときのキーは パッケージ名:クラス名:プロパティ名 private static final String PROP_NAME_VISIBILITY = "net.yanzm.sample:CustomTransition:visibility"; @Override public void captureStartValues(TransitionValues transitionValues) { // visibility の値を持っておく final View view = transitionValues.view; transitionValues.values.put(PROP_NAME_VISIBILITY, view.getVisibility()); } @Override public void captureEndValues(TransitionValues transitionValues) { // end の値は使わないので何もしない } @Override public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues, TransitionValues endValues) { if (startValues == null || startValues.view == null) { return null; } final View view = startValues.view; final int visibility = (int) startValues.values.get(PROP_NAME_VISIBILITY); final boolean isEnter = visibility != View.VISIBLE; view.setVisibility(View.VISIBLE); view.setAlpha(isEnter ? 0f : 1f); final ObjectAnimator anim = ObjectAnimator.ofFloat(view, "alpha", isEnter ? 1f : 0f); anim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { view.setAlpha(1f); view.setVisibility(isEnter ? View.VISIBLE : View.INVISIBLE); super.onAnimationEnd(animation); } }); return anim; } } これで一番上に載せた動画のような動作になりました!


2016年11月14日月曜日

ViewAnimationUtils.createCircularReveal() を使って FAB の transforming を実現する - with Transition API -

ViewAnimationUtils.createCircularReveal() を使って FAB の transforming を実現する
では、直接Activityに複雑なアニメーションを記述しました。それにより、本質的なコード(toolsContainer と fab の visibility の切り替え)がアニメーションのコードに埋もれてしまい、何をやっているのかわかりずらい状況になっていました。
そこで Transition API を使ってアニメーション部分を Activity から引き剥がしました。

完全な実装は
https://github.com/yanzm/FabTransformingSample
にあります。

MainActivity からは Animator オブジェクトが完全になくなり、RecyclerViewやバックキー部分のコードを追加しても前回より短くなっています。 visibility の切り替えなど view のパラメータ値の変更だけになり、何をやっているのかがわかりやすくなりました。 アニメーション部分は FabTransformation というクラスにまとめています。 public class MainActivity extends AppCompatActivity { private static final int HORIZONTAL_FACTOR = 2; private float diff; private ViewGroup sceneRoot; private View toolsContainer; private View tools; private FloatingActionButton fab; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); toolsContainer = findViewById(R.id.tools_container); tools = findViewById(R.id.tools); fab = (FloatingActionButton) findViewById(R.id.fab); sceneRoot = (ViewGroup) findViewById(R.id.scene_root); sceneRoot.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { int[] toolsLocation = new int[2]; toolsContainer.getLocationInWindow(toolsLocation); int[] fabLocation = new int[2]; fab.getLocationInWindow(fabLocation); diff = (toolsLocation[1] + toolsContainer.getHeight() / 2) - (fabLocation[1] + fab.getHeight() / 2); final float pivotX = fabLocation[0] + fab.getWidth() / 2 - toolsLocation[0] - diff * HORIZONTAL_FACTOR; toolsContainer.setPivotX(pivotX); tools.setPivotX(pivotX); sceneRoot.getViewTreeObserver().removeOnGlobalLayoutListener(this); } }); fab.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { changeFabMode(true, true); } }); changeFabMode(false, false); // recycler view setup final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view); recyclerView.setAdapter(new AndroidVersionAdapter()); recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { @Override public void onScrollStateChanged(RecyclerView recyclerView, int newState) { super.onScrollStateChanged(recyclerView, newState); if (newState != RecyclerView.SCROLL_STATE_IDLE) { if (fab.getVisibility() != View.VISIBLE) { changeFabMode(false, true); } } } }); } @Override public void onBackPressed() { if (fab.getVisibility() != View.VISIBLE) { changeFabMode(false, true); return; } super.onBackPressed(); } private void changeFabMode(boolean transformed, boolean animate) { if (animate) { final TransitionSet transition = new FabTransformation(transformed, fab.getHeight() / 2f); TransitionManager.beginDelayedTransition(sceneRoot, transition); } final float baseMargin = getResources().getDimension(R.dimen.fab_margin); final FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) fab.getLayoutParams(); params.bottomMargin = (int) (baseMargin - (transformed ? diff : 0)); params.setMarginEnd((int) (baseMargin + (transformed ? diff * HORIZONTAL_FACTOR : 0))); fab.setLayoutParams(params); toolsContainer.setVisibility(transformed ? View.VISIBLE : View.INVISIBLE); tools.setVisibility(transformed ? View.VISIBLE : View.INVISIBLE); tools.setScaleX(transformed ? 1f : 0.8f); fab.setVisibility(transformed ? View.INVISIBLE : View.VISIBLE); } } FabTransformation では複数の Transition を組み合わせて FAB の transforming を実現するアニメーションを構築しています。 ここでは Transition API で用意されている ChangeTransformFadeChangeBounds に加えて、ViewAnimationUtils.createCircularReveal() を利用する CircularRevealTransition を作って利用しています。





ViewAnimationUtils.createCircularReveal() を使って FAB の transforming を実現する

ViewAnimationUtils.createCircularReveal()

ViewAnimationUtils.createCircularReveal() は、Viewを円形にくり抜くアニメーション(Animator)を作るユーティリティメソッドです。 例えばこんな感じ。 public class SampleActivity extends AppCompatActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_sample); findViewById(R.id.button).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { final View container = findViewById(R.id.container); final int width = container.getWidth(); final int height = container.getHeight(); float startRadius = (float) Math.sqrt(width * width + height * height) / 2; float endRadius = 0; final Animator animator = ViewAnimationUtils.createCircularReveal(container, width / 2, height / 2, startRadius, endRadius); animator.setDuration(3000); animator.start(); } }); } }



FAB の transforming

https://material.google.com/components/buttons-floating-action-button.html#buttons-floating-action-button-transitions の真ん中あたり、toolbar という項目のやつです。

アニメーション以外の本質的なコードは toolsContainer と fab の visibility の切り替えだけ(以下の部分)なんですけど、アニメーションのコード入れると長い... toolsContainer.setVisibility(View.VISIBLE); fab.setVisibility(View.INVISIBLE); これが全体のコードなのですが、長いですね... public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); final View toolsContainer = findViewById(R.id.tools_container); final View tools = findViewById(R.id.tools); final FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab); final ToggleButton toggleButton = (ToggleButton) findViewById(R.id.button); toggleButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { final int fabWidth = fab.getWidth(); final int fabHeight = fab.getHeight(); final int toolsWidth = toolsContainer.getWidth(); final int toolsHeight = toolsContainer.getHeight(); float startRadius = fabHeight / 2f; float endRadius = (float) (Math.sqrt(toolsWidth * toolsWidth + toolsHeight * toolsHeight)); int[] outLocation = new int[2]; toolsContainer.getLocationInWindow(outLocation); int[] fabOutLocation = new int[2]; fab.getLocationInWindow(fabOutLocation); float diff = isChecked ? (outLocation[1] + toolsHeight / 2) - (fabOutLocation[1] + fabHeight / 2) : 0; int centerX = (int) (fabOutLocation[0] + fabWidth / 2 - outLocation[0] - diff); int centerY = toolsHeight / 2; final int FAB_DURATION = 100; final int TOOLS_DURATION = 300; if (isChecked) { final Animator fabAnimator1 = ObjectAnimator.ofFloat(fab, "translationY", diff); fabAnimator1.setDuration(FAB_DURATION); fabAnimator1.setInterpolator(new DecelerateInterpolator()); final Animator fabAnimator2 = ObjectAnimator.ofFloat(fab, "translationX", -diff); fabAnimator2.setDuration(FAB_DURATION); fabAnimator2.setInterpolator(new AccelerateInterpolator()); final ValueAnimator fabAnimator3 = ValueAnimator.ofInt(255, 0); fabAnimator3.setDuration(FAB_DURATION); fabAnimator3.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { final int alpha = (int) animation.getAnimatedValue(); final Drawable drawable = fab.getDrawable(); drawable.setAlpha(alpha); } }); final Animator toolsContainerAnimator = ViewAnimationUtils.createCircularReveal(toolsContainer, centerX, centerY, startRadius, endRadius); toolsContainerAnimator.setDuration(TOOLS_DURATION); toolsContainerAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { super.onAnimationStart(animation); toolsContainer.setVisibility(View.VISIBLE); fab.setVisibility(View.INVISIBLE); } }); tools.setPivotX(centerX); final Animator toolsAnimator = ObjectAnimator.ofPropertyValuesHolder(tools, PropertyValuesHolder.ofFloat("alpha", 0f, 1f), PropertyValuesHolder.ofFloat("scaleX", 0.8f, 1f)); toolsAnimator.setDuration(TOOLS_DURATION); AnimatorSet set = new AnimatorSet(); set.play(toolsContainerAnimator).with(toolsAnimator) .after(fabAnimator1).after(fabAnimator2).after(fabAnimator3); set.start(); } else { final Animator fabAnimator1 = ObjectAnimator.ofFloat(fab, "translationY", 0); fabAnimator1.setDuration(FAB_DURATION); fabAnimator1.setInterpolator(new AccelerateInterpolator()); final Animator fabAnimator2 = ObjectAnimator.ofFloat(fab, "translationX", 0); fabAnimator2.setDuration(FAB_DURATION); fabAnimator2.setInterpolator(new DecelerateInterpolator()); final ValueAnimator fabAnimator3 = ValueAnimator.ofInt(0, 255); fabAnimator3.setDuration(FAB_DURATION); fabAnimator3.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { final int alpha = (int) animation.getAnimatedValue(); final Drawable drawable = fab.getDrawable(); drawable.setAlpha(alpha); } }); final Animator toolsContainerAnimator = ViewAnimationUtils.createCircularReveal( toolsContainer, centerX, centerY, endRadius, startRadius); toolsContainerAnimator.setDuration(TOOLS_DURATION); toolsContainerAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); toolsContainer.setVisibility(View.INVISIBLE); fab.setVisibility(View.VISIBLE); } }); tools.setPivotX(centerX); final Animator toolsAnimator = ObjectAnimator.ofPropertyValuesHolder(tools, PropertyValuesHolder.ofFloat("alpha", 0f), PropertyValuesHolder.ofFloat("scaleX", 0.8f)); toolsAnimator.setDuration(TOOLS_DURATION); AnimatorSet set = new AnimatorSet(); set.play(toolsContainerAnimator).with(toolsAnimator) .before(fabAnimator1).before(fabAnimator2).before(fabAnimator3); set.start(); } } }); toolsContainer.setVisibility(toggleButton.isChecked() ? View.VISIBLE : View.INVISIBLE); tools.setAlpha(toggleButton.isChecked() ? 1f : 0f); } } アニメーションの長いコードがあるために、ここでやっていること(つまり toolsContainer と fab の visibility を切り替えること)がわかりにくくなっています。
それを解消するために Transition API が使えます(Transition API はもともとそういうための用意されたもののようです)。それは次回に。

↓実行結果



2016年11月4日金曜日

RecyclerView のクリックをどこで処理するか

RecyclerView.Adapter の責務はデータと ViewHolder の紐付けなので、View がタップされたときの処理(リスナーをセットすることではない)を Adapter 内に書くべきではないと思っています。

ではどうしているかというと、View がタップされたときに呼び出すメソッドを Adapter 内に定義しておき、Activity や Fragment で Adapter を生成するときにそのメソッドを Override してタップされたときの処理を記述するようにしています。 public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view); final VersionAdapter adapter = new VersionAdapter() { @Override protected void onVersionClicked(@NonNull String version) { super.onVersionClicked(version); // Activity 側でタップされたときの処理を行う Toast.makeText(MainActivity.this, version, Toast.LENGTH_SHORT).show(); } }; recyclerView.setAdapter(adapter); } public static class VersionAdapter extends RecyclerView.Adapter<VersionViewHolder> { // タップされたときに呼び出されるメソッドを定義 protected void onVersionClicked(@NonNull String version) { } private final List<String> versions = new ArrayList<>(); @Override public VersionViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { final LayoutInflater inflater = LayoutInflater.from(parent.getContext()); final VersionViewHolder holder = VersionViewHolder.create(inflater, parent); // onCreateViewHolder でリスナーをセット holder.itemView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { final int position = holder.getAdapterPosition(); final String version = versions.get(position); onVersionClicked(version); } }); return holder; } ... } } 完全なサンプルは
https://github.com/yanzm/RecyclerViewSample
にあります。


2016年10月25日火曜日

Fragment に Toolbar を持たせるのはやめなさい

NavigationDrawer や BottomNavigation パターンを実現するために、各画面を Fragment で実装することがあります。 Fragment によって ActionBar に持たせる機能が違うからか、Fragment のレイアウトに Toolbar を持たせて、Fragment で ((AppCompatActivity) getActivity()).setSupportActionBar(toolbar); のような処理をさせているコードを見かけることがあります。

やめなさい

(Toolbar をただの View として使って、ActionBar としては使わない(setSupportActionBar()しない)というのであればまだ許容できるが、それならそもそも Toolbar を使う必要がない)

ViewPager のように複数の Fragment を一度に attach する場合、これでは予期しない動作になることがありえます。ちゃんと Fragment に用意されている機能を使ってください。 Fragment で setHasOptionsMenu(true) を呼ぶと onCreateOptionsMenu() が呼ばれるので、Fragment 用の Menu を inflate します。

ViewPager はこの機能を適切に処理しており、現在のページの Fragment の Menu だけ inflate されるようになっています。 また、FragmentTransaction の show() / hide() で Fragment の表示・非表示を切り替える際も OptionsMenu であれば一緒に適切に処理されます。


Fragment にこのような機能があることを知っているにもかかわらず、上記のようなひどいコードを実装してしまう要因として、Menu ではなく View を置きたいという状況があります。
よくあるのが ActionBar に検索用の入力フィールド(EditText)を持たせたい場合です。
Menu ではなく View を置きたいのだから OptionsMenu の機能は使えないと思ってしまうのでしょうか。

OptionsMenu にはこのような用途のために ActionView という機能があります。MenuItem に独自のレイアウト/Viewを設定できる機能です。 Menu リソースの item で android:actionLayout(app:actionLayout)を使ってレイアウトを指定することもできます。
また、android:actionViewClass(app:actionViewClass) で View クラスを指定することもできます。

この ActionView 用に用意されているクラスとして SearchView があります。 SearchView は ActionBar に検索用の入力フィールド(EditText)を持たせてくれるそのものずばりの機能です。文字が入力されているときにクリアボタン(xボタン)が出る機能も実装されています。 これを利用せずにわざわざ自分で実装する意味はあまりないと思いますが、独自でやりたいのであればそれ用のViewクラスを自分で用意して android:actionViewClass で指定すればよいのです。

まとめると、
  • Fragment のレイアウトに toolbar を持たせない
  • Fragment 独自の機能を ActionBar に入れたいときは OptionsMenu の機能を使う
  • OptionsMenu の機能なら ViewPager で適切に処理される
  • OptionsMenu の機能なら FragmentTransaction の show() / hide() で適切に処理される
  • OptionsMenu には独自の View を配置できる ActionView 機能がある


追記1

Toolbar の中に複雑な View を入れること自体をダメだと言っているわけではありません。 Activityのレイアウトで <android.support.v7.widget.Toolbar> に子ビュー持たせるのは別にいいと思います。 このエントリはあくまで Fragment に toolbar を持たせることについての話です。

2016年10月21日金曜日

BehaviorSubject を使って Activity と Fragment のデータの読み込みを待ち合わせる

画面構成は
  • MainActivity
    • TabLayout + ViewPager
    • ViewPagerの各ページは MainFragment
  • MainFragment
    • RecyclerView

やりたいことは
  • MainActivity
    • 各ページで共通のデータ(以後 CommonData)をサーバーから取得する
  • MainFragment
    • ページ特有のデータ(以後 SpecificData)をサーバーから取得する
    • MainActivity から CommonData をもらう
    • CommonData と SpecificData 両方の読み込みが終わったら RecyclerView にデータを追加する

キモになるのが、CommonData と SpecificData 両方の読み込みが終わるのを待ち合わせたいというところです。
CommonData の読み込みが終わる前に生成された MainFragment なら両方の読み込みを待ち合わせるし、 CommonData の読み込みが終わった後に生成された MainFragment なら SpecificData の読み込みだけ待てばいいわけです。
でも、この2つの状態をわけて処理すると煩雑になってしまいます。

そこで、BehaviorSubject を使って、CommonData の値を MainFragment 側に渡せるようにします。 BehaviorSubject は onNext() が呼ばれたときにその値を通知し、さらにその値をキャッシュします。新しく subscribe されると、最新のキャッシュした値があればその時点で通知します。

public class MainActivity extends AppCompatActivity implements MainFragment.MainFragmentListener { private final BehaviorSubject<CommonData> commonDataBehaviorSubject = BehaviorSubject.create(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ... loadCommonData(); } private void loadCommonData() { subscription = DataRetriever.getInstance().getCommonData() .onErrorReturn(new Func1<Throwable, CommonData>() { @Override public CommonData call(Throwable throwable) { // エラーのときはデータがないものとして扱う return CommonData.empty(); } }) .subscribe(new Action1<CommonData>() { @Override public void call(CommonData commonData) { commonDataBehaviorSubject.onNext(commonData); } }); } @NonNull @Override public Observable<CommonData> getCommonDataObservable() { return commonDataBehaviorSubject; } } public class MainFragment extends Fragment { public interface MainFragmentListener { @NonNull Observable<CommonData> getCommonDataObservable(); } @Nullable private MainFragmentListener listener; ... @Override public void onAttach(Context context) { super.onAttach(context); if (context instanceof MainFragmentListener) { listener = (MainFragmentListener) context; } } @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); if (adapter == null) { adapter = new DataAdapter(); load(); } recyclerView.setAdapter(adapter); } void load() { recyclerView.setVisibility(View.GONE); progressView.setVisibility(View.VISIBLE); // 共通データとタブ独自のデータ両方揃うまで待ち合わせ subscription = Observable .combineLatest( getCommonDataObservable(), getSpecificDataObservable(), new Func2<CommonData, SpecificData, Pair<CommonData, SpecificData>>() { @Override public Pair<CommonData, SpecificData> call(CommonData commonData, SpecificData specificData) { return new Pair<>(commonData, specificData); } }) .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(new Subscriber<Pair<CommonData, SpecificData>>() { @Override public void onCompleted() { } @Override public void onError(Throwable e) { recyclerView.setVisibility(View.VISIBLE); progressView.setVisibility(View.GONE); } @Override public void onNext(Pair<CommonData, SpecificData> combinedData) { recyclerView.setVisibility(View.VISIBLE); progressView.setVisibility(View.GONE); final List<String> list = new ArrayList<>(); final CommonData commonData = combinedData.first; list.add("CommonData : " + (commonData.isEmpty() ? "empty" : commonData.getData())); final SpecificData specificData = combinedData.second; list.addAll(specificData.getData()); adapter.addAll(list); } }); } /** * 共通のデータを取得 */ private Observable<CommonData> getCommonDataObservable() { return listener != null // first() を介して onComplete()が呼ばれるようにしている ? listener.getCommonDataObservable().first() : Observable.just(CommonData.empty()); } /** * このタブ独自のデータを取得 */ private Observable<SpecificData> getSpecificDataObservable() { final int position = getArguments() == null ? -1 : getArguments().getInt(ARGS_POSITION); return DataRetriever.getInstance().getSpecificData(position); } ... }

さらに、MainActivity に SwipeRefreshLayout を追加して、PullToRefresh で共通データを取り直し、各 MainFragment にもデータを取り直させる処理を追加したサンプルが
https://github.com/yanzm/BehaviorSubjectSample
です。


2016年6月12日日曜日

AutoValue ライブラリを試してみた

https://github.com/google/auto/blob/master/value/userguide/index.md

immutable value class を生成してくれるライブラリ。abstract クラスを用意して @AutoValue をつけると、equals() や hashCode() などの boilerplate なコードを実装したクラスを用意してくれる。

設定

dependencies { compile 'com.google.auto.value:auto-value:1.2' apt 'com.google.auto.value:auto-value:1.2' }

使い方

例えば @AutoValue abstract class Animal { abstract String name(); abstract int numberOfLegs(); } のようなクラスを定義すると、AutoValue_Animalというクラスが生成される。 final class AutoValue_Animal extends Animal { private final String name; private final int numberOfLegs; AutoValue_Animal( String name, int numberOfLegs) { if (name == null) { throw new NullPointerException("Null name"); } this.name = name; this.numberOfLegs = numberOfLegs; } @Override String name() { return name; } @Override int numberOfLegs() { return numberOfLegs; } @Override public String toString() { return "Animal{" + "name=" + name + ", " + "numberOfLegs=" + numberOfLegs + "}"; } @Override public boolean equals(Object o) { if (o == this) { return true; } if (o instanceof Animal) { Animal that = (Animal) o; return (this.name.equals(that.name())) && (this.numberOfLegs == that.numberOfLegs()); } return false; } @Override public int hashCode() { int h = 1; h *= 1000003; h ^= this.name.hashCode(); h *= 1000003; h ^= this.numberOfLegs; return h; } } コンスタクタでは name と numberOfLegs を引数に取り、equals() や hasCode() ではこれらを使った実装になっている。 immutable value class なのでコンスタクタで受け取った引数は final として保持される。

abstract クラスに static な生成メソッドを用意して利用する。 @AutoValue abstract class Animal { static Animal create(String name, int numberOfLegs) { return new AutoValue_Animal(name, numberOfLegs); } abstract String name(); abstract int numberOfLegs(); } コンスタクタの引数は null チェックされる。これを止めたいときは abstract メソッドの戻り値に @Nullable をつける。 @AutoValue abstract class Animal { @Nullable abstract String name(); abstract int numberOfLegs(); } final class AutoValue_Animal extends Animal { private final String name; private final int numberOfLegs; AutoValue_Animal( @Nullable String name, int numberOfLegs) { this.name = name; this.numberOfLegs = numberOfLegs; } ... } Builder を用意することも可能。@AutoValue をつけるクラスにインナークラスとして abstract static な Builder クラスを定義し、@AutoValue.Builder をつける。 @AutoValue abstract class Animal { abstract String name(); abstract int numberOfLegs(); static Builder builder() { return new AutoValue_Animal.Builder(); } @AutoValue.Builder abstract static class Builder { abstract Builder name(String value); abstract Builder numberOfLegs(int value); abstract Animal build(); } } final class AutoValue_Animal extends Animal { ... static final class Builder extends Animal.Builder { private String name; private Integer numberOfLegs; Builder() { } Builder(Animal source) { this.name = source.name(); this.numberOfLegs = source.numberOfLegs(); } @Override public Animal.Builder name(String name) { this.name = name; return this; } @Override public Animal.Builder numberOfLegs(int numberOfLegs) { this.numberOfLegs = numberOfLegs; return this; } @Override public Animal build() { String missing = ""; if (name == null) { missing += " name"; } if (numberOfLegs == null) { missing += " numberOfLegs"; } if (!missing.isEmpty()) { throw new IllegalStateException("Missing required properties:" + missing); } return new AutoValue_Animal( this.name, this.numberOfLegs); } } }

2016年5月23日月曜日

Project Tango - Google I/O 2016

What's New with Project Tango

デモとビデオがたくさんあり、Tango の Keynote っぽいセッション。恐竜のアプリ楽しそう。
  • Standard Android development and publishing
  • C/C++, Java, Unity, Unreal
  • Detect if Tango features are available on device
Googel Store で $512.00 で新しい Project Tango タブレット開発キットが買えるようになった
https://store.google.com/product/project_tango_tablet_development_kit
(*日本では買えません)

追記:わざわざセッションで言及してたので new かと勘違いしてしまいました。new じゃなかった。残念...

Consumer 向けの Tango Phone を Lenovo と開発中。2016年の後半に出る予定。詳しくは 2016年6月9日の Lenovo Tech World で発表される。
http://www.lenovo.com/registerfortechworld/
http://www.lenovo.com/projecttango/


Introducing Project Tango Area Learning



Project Tango の主な3技術として Motion Tracking と Depth Perception と Area Learning がある。 Motion Tracking によってデバイスが最初のいちからどれだけ動いたかがわかる。 Tango 用の Tablet と Phone には特別な3Dカメラがついており、Tango はこのカメラを使って実世界の 3D geometry を検出できる。

Area Learning は Tango デバイスに記憶を与える。 Tango には広域カメラが付いていて、実世界の特徴のあるものをランドマークとして覚える。ランドマークの位置がカメラ内で移動したらデバイスが移動したとして Motion Tracking している。 目を閉じて見た目の全然異なる所に移動すると前にいた場所がわからなくなるのと同じように、これまで Tango には記憶がなかったので Motion Tracking を開始するたびに同じ状況になっていた。 Area Learning ではランドマークがどこに見えたかと、ランドマークの見た目についての mathmatical description の2つを記憶する。

AR でバーチャル椅子をダイニングに配置するとする。Motion Tracking だけだと、椅子の位置が徐々にずれてきてしまう。Area Learning を使うとこの drift がなくなる。椅子の位置を記憶するので Motion Tracking の drift を補正して正しい場所に表示できる。

複数人VRゲームでは、ゲームしている場所を記憶してその記憶をすべてのデバイスで共有することで、それぞれの位置が正しく補正されゲーム体験を改善できる。

Tango は
- 片付いているときと散らかっているときの部屋
- 異なる時間帯(朝・夜)の部屋
- 異なるライティング
- 観客がいるときといないときのスタジアム
- ランドマークの少ない部屋(全部白くてものが何もない部屋とか)
- 異なる季節(木に葉がある/ないなど)
などで同じ場所だと認識するのが難しい。

解決するキーは時間。 実世界には時間をかけてゆっくり変わるものと、短い時間で変わるものがある。地下鉄の駅では人や広告が入れ替わるので、記憶したものは短い間だけ正しくなる。 そこで、短い期間の記憶に頼るようにアプリをつくるのが良い戦略になる。

AR drift correction // Configure drift-free motion tracking mConfig = new TangoConfig(); mConfig = mTango.getConfig(TangoConfig.CONFIG_TYPE_CURRENT); mConfig.putBoolean(TangoConfig.KEY_BOOLEAN_ENABLE_DRIFT_CORRECTION, true); // ← // Query drift-free motion tracking TangoCoordinateFramePair frame_pair; frame_pair.base = TANGO_COORDINATE_FRAME_AREA_DESCRIPTION; // ← frame_pair.target = TANGO_COORDINATE_FRAME_DEVICE; TangoService_getPoseAtTime(timestamp, frame_pair, &area_description_T_device); Multiplayer game

Create Memory → Share Memory → Multi Player

Create Memory // Learning an area description mConfig = new TangoConfig(); mConfig = mTango.getConfig(TangoConfig.CONFIG_TYPE_CURRENT); mConfig.putBoolean(TangoConfig.KEY_BOOLEAN_LEARNINGMODE, true); // ← // Saving an area description mMemory = new String(); mMemory = mTango.saveAreaDescription(); // ← Share Memory between devices // Export an area description Intent mExportIntent = new Intent(); mExportIntent.setClassName("com.projecttango.tango" "com.google.atap.tango.RequestImportExportActivity"); mExportIntent.putExtra(EXTRA_KEY_SOURCEUUID, mMemory); mExportIntent.putExtra(EXTRA_KEY_DESTINATIONFILE, "/sdcard/area_description"); mActivity.startActivityForResult(mExportIntent, Tango.TANGO_INTENT_ACTIVITYCODE); Multi Player // Loading an area description mConfig = new TangoConfig(); mConfig = mTango.getConfig(TangoConfig.CONFIG_TYPE_CURRENT); mConfig.putBoolean(TangoConfig.KEY_STRING_AREADESCRIPTION, mMemory); // ← // Query device pose in are description TangoCoordinateFramePair frame_pair; frame_pair.base = TANGO_COORDINATE_FRAME_AREA_DESCRIPTION; // ← frame_pair.target = TANGO_COORDINATE_FRAME_DEVICE; TangoService_getPoseAtTime(timestamp, frame_pair, &area_description_T_device);


6 Degrees of Freedom Gaming in Android with Project Tango

Tango の3つの technology (Motion Tracking と Depth Perception と Area Learning)を一通り解説。

ARで何ができるか何ができないのかを見せるために猫のアプリを作ることにしたそうだ。virtual cat を実世界に。
このページをチェックすると楽しいらしい。
https://en.wikipedia.org/wiki/Cats_and_the_Internet

フレーム変換 TangoErrorType TangoService_getPoseAtTime( double timestamp, TangoCoordinateFramePair frame, TangoPoseData* pose); TangoCoordinateFramePair frame_pair; frame_pair.base = TANGO_COORDINATE_FRAME_START_OF_SERVICE; frame_pair.target = TANGO_COORDINATE_FRAME_DEVICE; TangoPoseData start_device_T_device; TangoService_getPoseAtTime(timestamp, frame_pair, &start_service_T_device); 猫を実世界の geometry に合わせないと、猫が宙に浮いたりしてしまう。 Tango ならこれを解決できる。 TangoPoseData pose_color_camera_t0_T_depth_camera_t1; TangoSupport_calculateRelativePose( last_color_time_, TANGO_COORDINATE_FRAME_CAMERA_COLOR, last_cloud_->timestamp, TANGO_COORDINATE_FRAME_CAMERA_DEPTH, &pose_color_camera_t0_T_depth_camera_t1); TangoSupport_fitPlaneModelNearClick( last_cloud_, &color_camera_intrinsics_, &pose_color_camera_t0_T_depth_camera_t1, glm::value_ptr(uv), glm::value_ptr(double_depth_position); glm::value_ptr(double_depth_plane_equation)); 猫アプリのデモ必見。

猫が家具の後ろにいったときに透けて見えてしまうのは現実っぽくない。 Tango は実世界の 3D データを作ることができる。ここからメッシュを作ると、実世界の家具の向こうにいった virtual の猫を非表示にしたり半透明にできる。 これのための処理はSDKで抽象化されていて、利用できる messing library もある。

あれこれやってデバイスのパワーを使いすぎると、デバイスがすごく熱くなったりする。Tango チームは速く効率的になるようにコードを最適化している。


Project Tango Developer Panel

3rd party の Tango アプリ開発者による Panel Session


2016年5月22日日曜日

Image compression for Android developers - Google I/O 2016

(テンション高いし、聞き取りやすい英語なのでオススメです)

Before we start

その他便利ツールなど

Four formats you care about

  • PNG
  • VectorDrawable
  • JPG
  • WebP

PNG

144x144 の単色の I/O アイコンが github では 6k だがストアの apk では 2k。でもまだ 144x144 にしてはでかい。8 color indexed image にすれば 932bytes になる。
  • palette や indexed にできるように調整する
  • 透明なピクセルのRGBチャネルを処理する
  • preprocess するなら gradle で cruncherEnabled = false
詳しくは Smaller PNGs : goo.gl/1yp3Bj

VectorDrawable

  • VectorDrawable使おう
  • 特に単色のピクトグラムに最適
  • POTrace (goo.gl/TG5z) は画像から vector に変換するツール

JPEG

  • qualityには何を指定すればいいの?
  • ImgMin project (goo.gl/OSvkMS) が参考になる
  • Butteraugli (goo.gl/1ehQOi) も参考になる

JPG Optimizer

  • JPEGMini (Lossy)
  • MozJPEG (Lossy)
  • cJPEG (Lossless)
  • packJPG (Lossless, Custom format)
  • Web solutions
どれ使ってもいいからやろう

image format 選択フロー

VectorDrawable にできる - yes -> VectorDrawable
 |
 no
↓
WebP をサポートしてる? - yes -> WebP
 |
 no
↓
透明が必要? - yes -> PNG
 |
 no
↓
simple or complex ? - simple -> PNG
 |
 complex
↓
JPG

PNG -> Use a tool, Resuce Colors, Hand Optimize
JPG -> Use a tool, Correct Quality, Hand Optimize


PROFILE YOUR CODE!!

Introducing the Awareness API, an easy way to make your apps context aware - Google I/O 2016

いままで

Where you are
  • Fused Location
  • Places API
  • Geofencing
What you're doing
  • Activity Recognition
  • Google Fit Platform
  • Sensors Platform
What's around you
  • Nearby Messages
  • Nearby Connections
  • Nearby Notifications
それぞれの API を組み合わせて使うのが大変だった。 例えば、複数の条件(Geofencingの中にいて、かつ車に乗っているなど)の組み合わせで処理のトリガーとするのが大変だった。電池への影響も考えないといけない。 そこで、組み合わせて使うのが簡単になる Awareness API をリリースした。

Awareness API

unified sensing platform

7 context types natively supported

  • Location (Latitude and longitude)
  • Places ("Starbucks", Coffee shop)
  • Beacons (What beacons or devices are nearby?)
  • Time (Local time)
  • Activity (Walking, running, biking, or driving)
  • Headphones (Headphones plugged or not?)
  • Weather (Current temperature and conditions)

Fance API

Callback style AreanessFence startDriving = DetectedActivityFence.staring( DetectedActivityFence.IN_VEHICLE); AwarenessFence areaAroundStore = LocationFence.in( STORE_LATITUDE, STORE_LONGITUDE, 1000 /* radius in meters */, 0L /* dwell time */); AwarenessFence duringDriving = DetectedActivityFence.during( DetectedActivityFence.IN_VEHICLE); AwarenessFence openHours = TimeFence.inDailyInterval( TimeZone.getDefault(), 10 * HOURS_IN_MILLIS, 18 * HOURS_IN_MILLIS); AwarenessFence drivingNearStore = AwarenessFence.and( areaAroundStore, duringDriving, openHours); // Create FenceUpdateRequest and register. FenceUdateRequest fenceUpdateRequest = new FenceUpdateRequest.Builder() .addFence("startDriving", startDriving, pendingIntent) .addFence("drivingNearStore", drivingNearStore, pendingIntent) .build(); Awareness.FenceApi.updateFences(googleApiClient, fenceUpdateRequest); void onReceive(Context context, Intent intent) { FenceState fenceState = FenceState.extract(intent); if (fenceState.getFenceKey().equals("startDriving")) { if (fenceSate.getCurrentState() == FenceState.TRUE) { // show map apps } } else if (fenceState.getFenceKey().equals("drivingNearStore")) { if (fenceSate.getCurrentState() == FenceState.TRUE) { // show reminder } } }

Snapshot API

PlacesResult placesResult = Awareness.SnapshotApi.getPlaces(googleApiClient).await(); WeatherResult weatherResult = Awareness.SnapshotApi.getWeather(googleApiClient).await();

Permission

  • Location, Place, Beacon, Weather -> ACCESS_FINE_LOCATION
  • Activity -> ACTIVITY_RECOGNITION
  • Headphones -> no permission

2016年3月16日水曜日

Migrate from Retrofit to Retrofit2 (Retrofit から Retrofit2 に移行する)

retrofit2 が正式リリースされました。

retrofit/CHANGELOG.md at master · square/retrofit

retrofit との後方互換性はありません。そのため maven の group id が com.squareup.retrofit2 になっています。 compile 'com.squareup.retrofit2:retrofit:2.0.0'
Converter

GSON などの Converter は別のモジュールに分割されました。

retrofit new RestAdapter.Builder() .setConverter(new GsonConverter(gson)) ... retrofit2 compile 'com.squareup.retrofit2:converter-gson:2.0.0' new Retrofit.Builder() .addConverterFactory(GsonConverterFactory.create(gson)) ... gson 以外の Converter については square.github.io/retrofit/ の CONVERTERS 部分に記載があります。


RxJava

retrofit で RxJava を使うためのクラスは別のモジュールに分割されました。

retrofit2 compile 'com.squareup.retrofit2:adapter-rxjava:2.0.0' new Retrofit.Builder() .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) ...
AndroidLog

retrofit.android.AndroidLog はなくなりました。 https://github.com/square/okhttp/tree/master/okhttp-logging-interceptor
を使って okhttp3 の interceptor で行います。

retrofit AndroidLog logger = new AndroidLog(MyService.class.getSimpleName()); new RestAdapter.Builder() .setLogLevel(RestAdapter.LogLevel.FULL) .setLog(logger) ... retrofit2 compile 'com.squareup.okhttp3:logging-interceptor:3.0.0' HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BODY); new OkHttpClient.Builder(); .addInterceptor(logging) ...
ErrorHandler, RetrofitError

retrofit.ErrorHandler, retrofit.RetrofitError はなくなりました。
okhttp3 の interceptor で行うとか https://github.com/square/retrofit/issues/1102
CallAdapterFactory を作るとか https://github.com/square/retrofit/pull/1277/files
いくつか方法があります。

adapter-rxjava を使っているなら HttpException が onError() で返されるので、ここから status code, message, response が取れます。

retrofit new RestAdapter.Builder() .setErrorHandler(new MyErrorHandler()) ... public class MyErrorHandler implements ErrorHandler { @Override public Throwable handleError(RetrofitError cause) { if (cause.getKind() == RetrofitError.Kind.NETWORK) { // network error ... } else if (cause.getResponse() != null) { final int statusCode = cause.getResponse().getStatus(); try { MyErrorResponse errorResponse = (MyErrorResponse) cause.getBodyAs(MyErrorResponse.class); } catch (Exception e) { ... } } else { ... } } } retrofit2 @Override public void onError(Throwable e) { if (e instanceof IOException) { // network error ... } else if (e instanceof HttpException) { HttpException he = (HttpException) e; final int statusCode = he.code(); final ResponseBody errorBody = e.response().errorBody(); if (errorBody != null) { try { MyErrorResponse errorResponse = (MyErrorResponse) GsonConverterFactory .create() .responseBodyConverter(MyErrorResponse.class, new Annotation[0], null) .convert(errorBody); // retrofit インスタンスが利用できる場合 // MyErrorResponse errorResponse = retrofit // .responseConverter(MyErrorResponse.class, new Annotation[0]) // .convert(errorBody); } catch (Exception re) { ... } } } else { ... } }
RequestInterceptor

retrofit.RequestInterceptor はなくなりました。
okhttp3 の interceptor を使います。https://github.com/square/retrofit/issues/1082

retrofit public class MyRequestInterceptor implements RequestInterceptor { @Override public void intercept(RequestFacade request) { request.addHeader(HEADER_KEY, headerValue); } } new RestAdapter.Builder() .setRequestInterceptor(new MyRequestInterceptor()) ... retrofit2 public class MyRequestInterceptor implements Interceptor { @Override public Response intercept(Chain chain) throws IOException { final Request.Builder builder = chain.request().newBuilder(); builder.addHeader(HEADER_KEY, headerValue); return chain.proceed(builder.build()); } } new OkHttpClient.Builder() .addInterceptor(new MyRequestInterceptor()) ...
Response

import 先を変更する必要があります。

retrofit import retrofit.client.Response retrofit2 import retrofit2.Response
Observable<Response>

ResponseBody を指定する必要があります。

retrofit @DELETE("/delete") Observable<Response> delete(); retrofit2 @DELETE("/delete") Observable<Response<ResponseBody>> delete();
@Body, @GET, @Post など

import 先を変更する必要があります。

retrofit import retrofit.http.Body retrofit2 import retrofit2.http.Body Query の encodeValue は encoded に名前が変わっています。

retrofit @Query(value = "categories", encodeValue = false) @Nullable String categories retrofit2 @Query(value = "categories", encoded = false) @Nullable String categories
TypedFile

TypedFile はなくなりました。
MultipartBody.Part を使います。

retrofit @Multipart @POST("/upload") Observable<Response> uploadImage( @Part("image") TypedFile image ); final TypedFile image = new TypedFile("image/jpeg", file); retrofit2 @Multipart @PATCH("/upload") Observable<Response> uploadImage( @Part() MultipartBody.Part image ); MultipartBody.Part のときは @Part に value(上記だと "image")を設定してはいけません。設定すると実行時に落ちます。 final MultipartBody.Part image = MultipartBody.Part.createFormData("image", file.getName(), RequestBody.create(MediaType.parse("image/jpeg"), file)); もともと @Part に指定していた "image" は MultipartBody.Part.createFormData() の第1引数に指定します。


Client

retrofit.client.Client はなくなりました。

retrofit側でモック化したい場合は retrofit-mock があります。 compile 'com.squareup.retrofit2:retrofit-mock:2.0.0' 使い方は
https://github.com/square/retrofit/blob/master/samples/src/main/java/com/example/retrofit/SimpleMockService.java が参考になります。

okhttp側でモック化したい場合は mockwebserverがあります。 compile 'com.squareup.okhttp3:mockwebserver:3.0.0'


okhttp

okhttp3 を使います。

retrofit import com.squareup.okhttp.Interceptor; import com.squareup.okhttp.Request; import com.squareup.okhttp.Response; retrofit2 import okhttp3.Interceptor; import okhttp3.Request; import okhttp3.Response;
stetho-okhttp

stetho-okhttp3 を使います。https://github.com/facebook/stetho/issues/327

retrofit2 compile 'com.facebook.stetho:stetho-okhttp3:1.3.1'


java.io.InterruptedIOException: thread interrupted

Retrofit2 (というか okhttp3)にして thread interrupted java.io.InterruptedIOException: thread interrupted at okio.Timeout.throwIfReached(Timeout.java:145) at okio.Okio$1.write(Okio.java:77) ... というようなエラーが起きたら、RxJava の subscribeOn() や observeOn() の使い方があやしいので見直しましょう。
例えば以下のような、subscribeOn(Schedulers.newThread()) を指定した複数の Observable を merge して takeLast すると InterruptedIOExceptionになります。 zip や combineLatest だと問題ないです。

NG final List<Observable<ImageResponse>> observables = new ArrayList<>(); for (...) { final MultipartBody.Part image = ...; final Observable<ImageResponse> o = service.uploadImage(image) .subscribeOn(Schedulers.newThread()); observables.add(o); } Observable .merge(observables) .takeLast(1) .subscribeOn(Schedulers.newThread()); .observeOn(AndroidSchedulers.mainThread()) .subscribe(new Subscriber<ImageResponse>() { ... }); OK final List<Observable<ImageResponse>> observables = new ArrayList<>(); for (...) { final MultipartBody.Part image = ...; final Observable<ImageResponse> o = service.uploadImage(image) .subscribeOn(Schedulers.newThread()); observables.add(o); } Observable .combineLatest(observables, new FuncN<ImageResponse>() { @Override public ImageResponse call(Object... args) { return ...; } }) .subscribeOn(Schedulers.newThread()); .observeOn(AndroidSchedulers.mainThread()) .subscribe(new Subscriber<ImageResponse>() { ... });


2016年3月6日日曜日

RecyclerView で IndexOutOfBoundsException が起こる話

注意:以下の現象は support library v23.2.0 では起こりません。

support library v23.1.1 で以下のコードを実行すると落ちます。

java.lang.IndexOutOfBoundsException: Inconsistency detected. Invalid item position 0(offset:20).state:20 at android.support.v7.widget.RecyclerView$Recycler.getViewForPosition(RecyclerView.java:4405) ... public class RecyclerViewGoneActivity extends AppCompatActivity { private TextAdapter textAdapter; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_recycler_view_gone); final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view); recyclerView.setLayoutManager(new LinearLayoutManager(this)); // 1. 初期状態がGONE recyclerView.setVisibility(View.GONE); // 2. setHasFixedSize(true); recyclerView.setHasFixedSize(true); textAdapter = new TextAdapter(); recyclerView.setAdapter(textAdapter); new Handler().postDelayed(new Runnable() { @Override public void run() { ArrayList<String> data = new ArrayList<>(); for (int i = 0; i < 20; i++) { data.add("data : " + i); } // 3. notifyItemRangeInserted(); を呼ぶ textAdapter.addAll(data); // 4. visibility を VISIBLE にする recyclerView.setVisibility(View.VISIBLE); } }, 1000); } private static class TextAdapter extends RecyclerView.Adapter<ViewHolder> { private final ArrayList<String> data = new ArrayList<>(); @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { LayoutInflater inflater = LayoutInflater.from(parent.getContext()); return new ViewHolder(inflater.inflate(ViewHolder.LAYOUT_ID, parent, false)); } @Override public void onBindViewHolder(ViewHolder holder, int position) { holder.textView.setText(data.get(position)); } @Override public int getItemCount() { return data.size(); } public void add(String s) { final int positionStart = getItemCount(); data.add(s); notifyItemInserted(positionStart); } public void addAll(ArrayList<String> d) { final int positionStart = getItemCount(); final int itemCount = d.size(); data.addAll(d); notifyItemRangeInserted(positionStart, itemCount); } public void clear() { final int itemCount = getItemCount(); data.clear(); notifyItemRangeRemoved(0, itemCount); } } private static class ViewHolder extends RecyclerView.ViewHolder { public static final int LAYOUT_ID = android.R.layout.simple_list_item_1; final TextView textView; public ViewHolder(View itemView) { super(itemView); this.textView = (TextView) itemView.findViewById(android.R.id.text1); } } }


落ちなくする方法がいくつかあります。
  • 1. setHasFixedSize(true); を呼ばないようにする(ただしその分パフォーマンスに影響する)
  • 2. あらかじめデータを入れておいて VISIBLE にする前に消す
  • textAdapter = new TextAdapter(); recyclerView.setAdapter(textAdapter); textAdapter.add("dummy"); // これを追加 new Handler().postDelayed(new Runnable() { @Override public void run() { textAdapter.clear(); // これを追加 ArrayList<String> data = new ArrayList<>(); for (int i = 0; i < 20; i++) { data.add("data : " + i); } textAdapter.addAll(data); recyclerView.setVisibility(View.VISIBLE); } }, 1000);
関連


2016年3月5日土曜日

CollapsingToolbarLayout で status bar を透明にする方法

注意:以下は support library v23.2.0 での動作をもとにしています。

追記
AppBarLayout と CollapsingToolbarLayout にも android:fitsSystemWindows="true" の指定を追加するようにしました。 これにより、v25.0.1, v25.0,0, v24.2.1, v24.2.0, v24.1.1, v24.1.0, v24.0.0, v23.4.0, v23.3.0, v23.2.1 でも動作することを確認してあります。



わかりやすいように

colorPrimary : #ff0000(赤)
colorPrimaryDark : #99ff00ff(マゼンダ)

contentScrim : #990000ff(青)
statusBarScrim : #9900ffff(シアン)

Toolbar の background : #9900ff00(緑)

にしてあります。

普通に実装するとこんな感じになります。 <?xml version="1.0" encoding="utf-8"?> <android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent"> <android.support.design.widget.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:fitsSystemWindows="true" android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"> <android.support.design.widget.CollapsingToolbarLayout android:id="@+id/toolbar_layout" android:layout_width="match_parent" android:layout_height="wrap_content" android:fitsSystemWindows="true" app:contentScrim="#990000ff" app:layout_scrollFlags="scroll|exitUntilCollapsed" app:statusBarScrim="#9900ffff"> <ImageView android:layout_width="match_parent" android:layout_height="360dp" android:scaleType="centerCrop" android:src="@drawable/sample" tools:ignore="ContentDescription" /> <android.support.v7.widget.Toolbar android:id="@id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" app:layout_collapseMode="pin" tools:background="#9900ff00" /> </android.support.design.widget.CollapsingToolbarLayout> </android.support.design.widget.AppBarLayout> <android.support.v4.widget.NestedScrollView android:layout_width="match_parent" android:layout_height="match_parent" android:layout_gravity="fill_vertical" app:layout_behavior="@string/appbar_scrolling_view_behavior"> ... </android.support.v4.widget.NestedScrollView> </android.support.design.widget.CoordinatorLayout> これを実行するとこうなります。



マゼンダ色の status bar が表示されています。この status bar を透明にして ImageView をその分上にあげるにはどうすればいいか。

そのためにまずテーマに <item name="android:statusBarColor">@android:color/transparent</item> を指定して、Activity の onCreate() に以下の処理を追加します。 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { findViewById(android.R.id.content).setSystemUiVisibility( View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } すると次のようになります。



マゼンダから赤色に変わりました。これはステータスバーの色が見えているのではなく CollapsingToolbar の領域が見えています。その証拠にスクロールするとこの領域も移動します。

この領域はどこで確保されているかというと CollapsingToolbarLayout の onLayout() です。 @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); ... // Update our child view offset helpers for (int i = 0, z = getChildCount(); i < z; i++) { final View child = getChildAt(i); if (mLastInsets != null && !ViewCompat.getFitsSystemWindows(child)) { final int insetTop = mLastInsets.getSystemWindowInsetTop(); if (child.getTop() < insetTop) { // If the child isn't set to fit system windows but is drawing within the inset // offset it down ViewCompat.offsetTopAndBottom(child, insetTop); } } getViewOffsetHelper(child).onViewLayout(); } ... } CollapsingToolbarLayout の直接の子ビューで fitsSystemWindows が false のものは、ビューの配置位置を insetTop(ステータスバーの高さ)分だけ下にずらすようになっています。
つまり、fitsSystemWindows = true を子ビューにセットすれば、この処理が行われないということです。

そこで ImageView に android:fitsSystemWindows="true" を追加すると <android.support.design.widget.CollapsingToolbarLayout ... > <ImageView ... android:fitsSystemWindows="true" /> <android.support.v7.widget.Toolbar ... /> </android.support.design.widget.CollapsingToolbarLayout> 次のようになります。




この方法を取る場合、4.4 で注意が必要です。 「 StatusBar 透明化の正しい方法」で書いているように、v19 では android:windowTranslucentStatus をセットして、v21 では android:statusBarColor をセットしている場合、上記の対応を入れると次のように 4.4 で余分な領域が確保されてしまいます。



そのため、v21以降だけ android:fitsSystemWindows="true" が指定されるようにします。

values/bools.xml <?xml version="1.0" encoding="utf-8"?> <resources> <bool name="fitsSystemWindowForImage">false</bool> </resources> values-v21/bools.xml <?xml version="1.0" encoding="utf-8"?> <resources> <bool name="fitsSystemWindowForImage">true</bool> </resources> <android.support.design.widget.CollapsingToolbarLayout ... > <ImageView ... android:fitsSystemWindows="@bool/fitsSystemWindowForImage" /> <android.support.v7.widget.Toolbar ... /> </android.support.design.widget.CollapsingToolbarLayout> こうすると、4.4 でも次のような結果になります。




4.4 での実行結果をよく見ると、閉じたときに status bar 分の領域が確保されずに Toolbar が status bar の下にきてしまうことがわかります。



残念ながら 4.4 で閉じた時に status bar 分を確保するシンプルな方法はありません。 以下のように inset を margin に付け替える Toolbar を用意するとこれが解決できます。 public class CustomToolbar extends Toolbar { public CustomToolbar(Context context) { super(context); } public CustomToolbar(Context context, AttributeSet attrs) { super(context, attrs); } public CustomToolbar(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected boolean fitSystemWindows(Rect insets) { final ViewGroup.LayoutParams params = getLayoutParams(); if (params instanceof MarginLayoutParams) { ((MarginLayoutParams) params).topMargin = insets.top; } return true; } }





2016年2月28日日曜日

conent_available を true にすると foreground のときしか onMessageReceived() が呼ばれない

GcmListenerService では、特定の条件の場合 onMessageReceived() を呼ばずに自分で Notification を出すような処理になっています。

この条件が Google Play Services のバージョンによって異なり、7.8.0 から 8.4.0 に変更したときにはまったので調べた結果をまとめておきます。
(調べたのは 7.8.0 と 8.4.0 で、この間の他のバージョンは調べていません。)

7.8.0



7.8.0 では、送信する JSON に "notification" payload があり、その中の "icon" の値が null じゃない場合は onMessageReceived() が呼ばれずに GcmListenerService が Notification を出します。このときの Notification には "notification" payload で指定された "title" や "body" が使われます。"title" の値が空のときはアプリ名が利用されます。

notification payload の形式については https://developers.google.com/cloud-messaging/http-server-ref#notification-payload-support 参照

つまり
"notification" の "icon" == null → onMessageReceived()
"notification" の "icon" != null → GcmListenerService が Notification 発行


8.4.0



8.4.0 では条件が大きく変わります。
"content_available" が false (指定がない場合も false)かつ "notification" の "icon" == null の場合 onMessageReceived() が呼ばれます。そうでない場合、アプリが foreground にあれば onMessageReceived() が呼ばれ、background なら GcmListenerService が Notification を出します。

つまり
"content_available" == false && "notification" の "icon" == null → onMessageReceived()
"content_available" == true || "notification" の "icon" != null →
  アプリが foreground → onMessageReceived()
  アプリが background → GcmListenerService が Notification 発行

"content_available" を true にしていると、7.8.0 から 8.4.0 に変更したときに今まで background でも onMessageReceived() が呼ばれていたのに呼ばれなくなります。全然ドキュメントにも書いてないしはまりました。。。


7.8.0 のコード

GcmListenerService private void zzt(Bundle var1) { var1.remove("message_type"); var1.remove("android.support.content.wakelockid"); if(zza.zzu(var1)) { zza.zzay(this).zzv(var1); // "notification" のデータを使って Notification を発行している } else { String var2 = var1.getString("from"); var1.remove("from"); this.onMessageReceived(var2, var1); } } zza static boolean zzu(Bundle var0) { return zzb(var0, "gcm.n.icon") != null; } "gcm.n.icon" は "notification" payload の "icon" に対応している。


8.4.0 のコード

GcmListenerService private void zzq(Intent var1) { Bundle var2 = var1.getExtras(); var2.remove("message_type"); var2.remove("android.support.content.wakelockid"); if(zzb.zzy(var2)) { if(!zzb.zzaI(this)) { // 同じプロセスの Activity が foreground かチェック、foreground じゃない場合 if の中に入る zzb.zzc(this, this.getClass()).zzA(var2); // "notification" のデータを使って Notification を発行している return; } if(zzx(var1.getExtras())) { zza.zzh(this, var1); } zzb.zzz(var2); } String var3 = var2.getString("from"); var2.remove("from"); zzw(var2); this.onMessageReceived(var3, var2); } zzb static boolean zzy(Bundle var0) { return "1".equals(zze(var0, "gcm.n.e")) || zze(var0, "gcm.n.icon") != null; } "gcm.n.e" は "content_available" に対応している。 "gcm.n.icon" は "notification" payload の "icon" に対応している。

zzb static boolean zzaI(Context var0) { KeyguardManager var1 = (KeyguardManager)var0.getSystemService("keyguard"); if(var1.inKeyguardRestrictedInputMode()) { return false; } else { int var2 = Process.myPid(); ActivityManager var3 = (ActivityManager)var0.getSystemService("activity"); List var4 = var3.getRunningAppProcesses(); if(var4 != null) { Iterator var5 = var4.iterator(); while(var5.hasNext()) { RunningAppProcessInfo var6 = (RunningAppProcessInfo)var5.next(); if(var6.pid == var2) { return var6.importance == 100; } } } return false; } }


2016年2月21日日曜日

講演の準備について

Droid Kaigi 2016 を振り返ってふと講演前に何をしてきたのかを書いてみようと思いました。

Konifar氏に「あー」とか「えー」とかをほとんど言わずあまり噛まないとお褒めいただいたことで、どうしてそうなのか改めて考えてみたことがきっかけです。

1. CFP

直後ではなく、締め切り2週間〜1週間前くらいに出しました。
すでに提出されているCFPを確認して、かぶらないような内容を選びました。
私が確認した時点ですでにCamera2やRxJava、Kotlinなどがかぶっていました。

2. 話の流れを考える

本格的に考え出したのは本番1ヶ月前くらいからです。
細かい内容よりも全体の流れを先に考えます。
風呂に入っているときや寝る前に脳内リハーサル(イメージトレーニング)します。
マインドマップは書きません。以前の講演で試したこともあるのですが合わないのか続きませんでした。

3. 裏取りをする

人に伝える以上間違ったことは話せません。
以前やったことがあることでももう一度実際に書いて試してみます。
また、このときに調べ直して新たに知ったことも多いです。
この作業時にスライドに載せるコードやキャプチャも大まかに決めます。

4. 会場のサイズを知る

スライドの文字サイズを決めるために @mhidaka に担当部屋を下見したときの写真をみせてもらいました。
写真から
- 奥行きがかなりある
- スクリーンが左右に2枚
- スクリーンの下の方は少し見ずらい(このことは講演者へのメールにも記載されていました)
ことがわかりました。

スタッフと知り合いじゃないと難しいと思うので、できれば大きなイベントの開催者は講演者へのメールに会場の写真も添付してもらえると助かる人が多いと思います。

5. 資料作成

白地に黒の方が見やすい

特に文字が細い場合黒字だと会場を暗くしないと見るのが厳しいです。
私はスライドのビジュアル(絵的に美しいかどうか)よりも見やすいかどうかを優先しています。

会場のサイズから文字サイズはかなり悩みました。
最終的に重要なコードは 24pt 以上、文章は 32pt 以上になるようにしました。

私はスライドの作成にはほぼ Keynote を使っています。慣れによるところが大きいです。

発表者ノートを使うこともあります。今回は使っていませんが、エモーショナルなスライド(写真背景 + 格言みたいなやつ)では話す内容のヒントが少ないため、そのような内容の講演だと使うことがあります。

6. 脳内リハーサル(イメージトレーニング)

多分これが噛まない秘訣(?)だと思います。実リハーサルはほぼやらないのですが1週間前から寝る前の頭の中はほぼこれです。頭の中でしゃべってます。

7. 前日

2日目だったので、1日目の最後にお願いして実際の部屋でスライドを映すテストをさせていただけました。
コードに半透明の黄色でハイライトをいれていたのですが、実際に写してみるとハイライトをいれると元の文字が見にくいということがわかり、急遽赤線を入れる方向に変えました。

8. 本番

どうしても一部コードの文字が小さいスライドがあるので、事前にスライドを公開すると決めていました。本番10分前に公開して tweet &会場で始まるまでQRコードとURLをスライドで表示しました。

9. 終わった感想

今回は話の流れの組み立てにけっこう悩みました。AndroidフレームワークのTheme・Styleの機能と AppCompat 独自の機能は別ですが、AppCompat は Material Design の見た目を実現するのにフレームワークの機能を使っています。そこをカスタマイズするときは AppCompat がどのような設定を行っているのかを知る必要があるため、AppCompat の設定値の話が多くなりました。
後から考えるともう少しAndroidフレームワークのTheme・Styleの機能についての説明も入れたほうがよかったなと思いました。(?attr/と@style/とandroid:のありなしについて後で質問されたので)

今回は想定以上にスライドの枚数が多くなり早口だったと思います。42分くらいで話終わってしまったのでもう少しゆっくり話せばよかったと反省です。枚数が多い時の時間配分は難しいですね。


2016年2月19日金曜日

Droid Kaigi 2016 で講演してきました。

2016年2月18日、19日に開催された Droid Kaigi 2016 で講演してきました。

スタッフの皆様、講演を聞きに来てくれた参加者のみなさま、ありがとうございました。

需要の高そうな StatusBar の透明化については別途ブログのエントリーにしました。
StatusBar 透明化の正しい方法 : Y.A.M の雑記帳



'},ClipboardSwf:null,Version:'1.5.1'}};dp.SyntaxHighlighter=dp.sh;dp.sh.Toolbar.Commands={ExpandSource:{label:'+ expand source',check:function(highlighter){return highlighter.collapse;},func:function(sender,highlighter) {sender.parentNode.removeChild(sender);highlighter.div.className=highlighter.div.className.replace('collapsed','');}},ViewSource:{label:'view plain',func:function(sender,highlighter) {var code=dp.sh.Utils.FixForBlogger(highlighter.originalCode).replace(/'+code+'');wnd.document.close();}},CopyToClipboard:{label:'copy to clipboard',check:function(){return window.clipboardData!=null||dp.sh.ClipboardSwf!=null;},func:function(sender,highlighter) {var code=dp.sh.Utils.FixForBlogger(highlighter.originalCode).replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&');if(window.clipboardData) {window.clipboardData.setData('text',code);} else if(dp.sh.ClipboardSwf!=null) {var flashcopier=highlighter.flashCopier;if(flashcopier==null) {flashcopier=document.createElement('div');highlighter.flashCopier=flashcopier;highlighter.div.appendChild(flashcopier);} flashcopier.innerHTML='';} alert('The code is in your clipboard now');}},PrintSource:{label:'print',func:function(sender,highlighter) {var iframe=document.createElement('IFRAME');var doc=null;iframe.style.cssText='position:absolute;width:0px;height:0px;left:-500px;top:-500px;';document.body.appendChild(iframe);doc=iframe.contentWindow.document;dp.sh.Utils.CopyStyles(doc,window.document);doc.write('

'+highlighter.div.innerHTML+'

');doc.close();iframe.contentWindow.focus();iframe.contentWindow.print();alert('Printing...');document.body.removeChild(iframe);}},About:{label:'?',func:function(highlighter) {var wnd=window.open('','_blank','dialog,width=300,height=150,scrollbars=0');var doc=wnd.document;dp.sh.Utils.CopyStyles(doc,window.document);doc.write(dp.sh.Strings.AboutDialog.replace('{V}',dp.sh.Version));doc.close();wnd.focus();}}};dp.sh.Toolbar.Create=function(highlighter) {var div=document.createElement('DIV');div.className='tools';for(var name in dp.sh.Toolbar.Commands) {var cmd=dp.sh.Toolbar.Commands[name];if(cmd.check!=null&&!cmd.check(highlighter)) continue;div.innerHTML+=''+cmd.label+'';} return div;} dp.sh.Toolbar.Command=function(name,sender) {var n=sender;while(n!=null&&n.className.indexOf('dp-highlighter')==-1) n=n.parentNode;if(n!=null) dp.sh.Toolbar.Commands[name].func(sender,n.highlighter);} dp.sh.Utils.CopyStyles=function(destDoc,sourceDoc) {var links=sourceDoc.getElementsByTagName('link');for(var i=0;i');} dp.sh.Utils.FixForBlogger=function(str) {return(dp.sh.isBloggerMode==true)?str.replace(/
|<br\s*\/?>/gi,''):str;} dp.sh.RegexLib={MultiLineCComments:new RegExp('/\\*[\\s\\S]*?\\*/','gm'),SingleLineCComments:new RegExp('//.*$','gm'),SingleLinePerlComments:new RegExp('#.*$','gm'),DoubleQuotedString:new RegExp('"(?:\\.|(\\\\\\")|[^\\""\\n])*"','g'),SingleQuotedString:new RegExp("'(?:\\.|(\\\\\\')|[^\\''\\n])*'",'g')};dp.sh.Match=function(value,index,css) {this.value=value;this.index=index;this.length=value.length;this.css=css;} dp.sh.Highlighter=function() {this.noGutter=false;this.addControls=true;this.collapse=false;this.tabsToSpaces=true;this.wrapColumn=80;this.showColumns=true;} dp.sh.Highlighter.SortCallback=function(m1,m2) {if(m1.indexm2.index) return 1;else {if(m1.lengthm2.length) return 1;} return 0;} dp.sh.Highlighter.prototype.CreateElement=function(name) {var result=document.createElement(name);result.highlighter=this;return result;} dp.sh.Highlighter.prototype.GetMatches=function(regex,css) {var index=0;var match=null;while((match=regex.exec(this.code))!=null) this.matches[this.matches.length]=new dp.sh.Match(match[0],match.index,css);} dp.sh.Highlighter.prototype.AddBit=function(str,css) {if(str==null||str.length==0) return;var span=this.CreateElement('SPAN');str=str.replace(/ /g,' ');str=str.replace(/');if(css!=null) {if((/br/gi).test(str)) {var lines=str.split(' 
');for(var i=0;ic.index)&&(match.index/gi,'\n');var lines=html.split('\n');if(this.addControls==true) this.bar.appendChild(dp.sh.Toolbar.Create(this));if(this.showColumns) {var div=this.CreateElement('div');var columns=this.CreateElement('div');var showEvery=10;var i=1;while(i<=150) {if(i%showEvery==0) {div.innerHTML+=i;i+=(i+'').length;} else {div.innerHTML+='·';i++;}} columns.className='columns';columns.appendChild(div);this.bar.appendChild(columns);} for(var i=0,lineIndex=this.firstLine;i0;i++) {if(Trim(lines[i]).length==0) continue;var matches=regex.exec(lines[i]);if(matches!=null&&matches.length>0) min=Math.min(matches[0].length,min);} if(min>0) for(var i=0;i

Blogger Syntax Highliter

Version: {V}

http://www.dreamprojections.com/syntaxhighlighter

©2004-2007 Alex Gorbatchev.

'},ClipboardSwf:null,Version:'1.5.1'}};dp.SyntaxHighlighter=dp.sh;dp.sh.Toolbar.Commands={ExpandSource:{label:'+ expand source',check:function(highlighter){return highlighter.collapse;},func:function(sender,highlighter) {sender.parentNode.removeChild(sender);highlighter.div.className=highlighter.div.className.replace('collapsed','');}},ViewSource:{label:'view plain',func:function(sender,highlighter) {var code=dp.sh.Utils.FixForBlogger(highlighter.originalCode).replace(/'+code+'');wnd.document.close();}},CopyToClipboard:{label:'copy to clipboard',check:function(){return window.clipboardData!=null||dp.sh.ClipboardSwf!=null;},func:function(sender,highlighter) {var code=dp.sh.Utils.FixForBlogger(highlighter.originalCode).replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&');if(window.clipboardData) {window.clipboardData.setData('text',code);} else if(dp.sh.ClipboardSwf!=null) {var flashcopier=highlighter.flashCopier;if(flashcopier==null) {flashcopier=document.createElement('div');highlighter.flashCopier=flashcopier;highlighter.div.appendChild(flashcopier);} flashcopier.innerHTML='';} alert('The code is in your clipboard now');}},PrintSource:{label:'print',func:function(sender,highlighter) {var iframe=document.createElement('IFRAME');var doc=null;iframe.style.cssText='position:absolute;width:0px;height:0px;left:-500px;top:-500px;';document.body.appendChild(iframe);doc=iframe.contentWindow.document;dp.sh.Utils.CopyStyles(doc,window.document);doc.write('

'+highlighter.div.innerHTML+'

');doc.close();iframe.contentWindow.focus();iframe.contentWindow.print();alert('Printing...');document.body.removeChild(iframe);}},About:{label:'?',func:function(highlighter) {var wnd=window.open('','_blank','dialog,width=300,height=150,scrollbars=0');var doc=wnd.document;dp.sh.Utils.CopyStyles(doc,window.document);doc.write(dp.sh.Strings.AboutDialog.replace('{V}',dp.sh.Version));doc.close();wnd.focus();}}};dp.sh.Toolbar.Create=function(highlighter) {var div=document.createElement('DIV');div.className='tools';for(var name in dp.sh.Toolbar.Commands) {var cmd=dp.sh.Toolbar.Commands[name];if(cmd.check!=null&&!cmd.check(highlighter)) continue;div.innerHTML+=''+cmd.label+'';} return div;} dp.sh.Toolbar.Command=function(name,sender) {var n=sender;while(n!=null&&n.className.indexOf('dp-highlighter')==-1) n=n.parentNode;if(n!=null) dp.sh.Toolbar.Commands[name].func(sender,n.highlighter);} dp.sh.Utils.CopyStyles=function(destDoc,sourceDoc) {var links=sourceDoc.getElementsByTagName('link');for(var i=0;i');} dp.sh.Utils.FixForBlogger=function(str) {return(dp.sh.isBloggerMode==true)?str.replace(/
|<br\s*\/?>/gi,'\n'):str;} dp.sh.RegexLib={MultiLineCComments:new RegExp('/\\*[\\s\\S]*?\\*/','gm'),SingleLineCComments:new RegExp('//.*$','gm'),SingleLinePerlComments:new RegExp('#.*$','gm'),DoubleQuotedString:new RegExp('"(?:\\.|(\\\\\\")|[^\\""\\n])*"','g'),SingleQuotedString:new RegExp("'(?:\\.|(\\\\\\')|[^\\''\\n])*'",'g')};dp.sh.Match=function(value,index,css) {this.value=value;this.index=index;this.length=value.length;this.css=css;} dp.sh.Highlighter=function() {this.noGutter=false;this.addControls=true;this.collapse=false;this.tabsToSpaces=true;this.wrapColumn=80;this.showColumns=true;} dp.sh.Highlighter.SortCallback=function(m1,m2) {if(m1.indexm2.index) return 1;else {if(m1.lengthm2.length) return 1;} return 0;} dp.sh.Highlighter.prototype.CreateElement=function(name) {var result=document.createElement(name);result.highlighter=this;return result;} dp.sh.Highlighter.prototype.GetMatches=function(regex,css) {var index=0;var match=null;while((match=regex.exec(this.code))!=null) this.matches[this.matches.length]=new dp.sh.Match(match[0],match.index,css);} dp.sh.Highlighter.prototype.AddBit=function(str,css) {if(str==null||str.length==0) return;var span=this.CreateElement('SPAN');str=str.replace(/ /g,' ');str=str.replace(/');if(css!=null) {if((/br/gi).test(str)) {var lines=str.split(' 
');for(var i=0;ic.index)&&(match.index/gi,'\n');var lines=html.split('\n');if(this.addControls==true) this.bar.appendChild(dp.sh.Toolbar.Create(this));if(this.showColumns) {var div=this.CreateElement('div');var columns=this.CreateElement('div');var showEvery=10;var i=1;while(i<=150) {if(i%showEvery==0) {div.innerHTML+=i;i+=(i+'').length;} else {div.innerHTML+='·';i++;}} columns.className='columns';columns.appendChild(div);this.bar.appendChild(columns);} for(var i=0,lineIndex=this.firstLine;i0;i++) {if(Trim(lines[i]).length==0) continue;var matches=regex.exec(lines[i]);if(matches!=null&&matches.length>0) min=Math.min(matches[0].length,min);} if(min>0) for(var i=0;i

ページビューの合計