2015年5月19日火曜日

未選択状態を持つデータをどう表現するか悩んだ話、その2 - SharedPreferences -

前回 の続きです。


1. enum 版で SharedPreferences

SharedPreferences.Editor には残念ながら putSerializable() 的なものがありません。そのため、putInt() なりを使わざるを得ません。

enum 版では
・enum から int に変換して SharedPreferences に保存
・SharedPreferences から取得した int の値を enum に変換
という処理が必要になります。

@zaki50 さんの gist を参考に前回のコードに合わせました。zaki50 さんいつもありがとう。
  1. private static final String PREF_SIZE_KEY = "pref_size_key";  
  2.   
  3. public static void saveSize(@NonNull Context context, @Nullable Size size) {  
  4.     final SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context);  
  5.     final SharedPreferences.Editor editor = pref.edit();  
  6.     if (size == null) {  
  7.         editor.remove(PREF_SIZE_KEY);  
  8.     } else {  
  9.         editor.putInt(PREF_SIZE_KEY, size.getValue());  
  10.     }  
  11.     editor.apply();  
  12. }  
  13.   
  14. @Nullable  
  15. public static Size getSavedSize(@NonNull Context context) {  
  16.     final SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context);  
  17.     if (pref.contains(PREF_SIZE_KEY)) {  
  18.         int value = pref.getInt(PREF_SIZE_KEY, Size.SIZE_L);  
  19.         return Size.fromIntValue(value);  
  20.     }  
  21.     return null;  
  22. }  
  1. public enum Size {  
  2.     ...  
  3.   
  4.     private volatile static SparseArray<Size> intToEnum;  
  5.   
  6.     public static Size fromIntValue(int value) {  
  7.         if (intToEnum == null) {  
  8.             final SparseArray<Size> sizeSparseArray = new SparseArray<>();  
  9.             final Size[] values = values();  
  10.             for (Size size : values) {  
  11.                 sizeSparseArray.append(size.getValue(), size);  
  12.             }  
  13.   
  14.             // おまけ。値の重複チェックをしておく  
  15.             if (sizeSparseArray.size() != values.length) {  
  16.                 throw new IllegalStateException("duplicate values in Size enum");  
  17.             }  
  18.   
  19.             intToEnum = sizeSparseArray;  
  20.         }  
  21.   
  22.         final Size size = intToEnum.get(value);  
  23.         if (size == null) {  
  24.             throw new IllegalArgumentException("invalid value: " + value);  
  25.         }  
  26.         return size;  
  27.     }  
  28. }  
SharedPreferences から取得した int の値を enum に変換するために、新しく fromIntValue() を追加しています。



2. class 版で SharedPreferences

class で実装した方はこんな感じになります。
変換用のメソッドを新しく用意することなく、int 値からそのまま Size に変換しています(valueOf()については前回のコード参照)。
  1. private static final String PREF_SIZE_KEY = "pref_size_key";  
  2.   
  3. public static void saveSize(@NonNull Context context, @Nullable Size size) {  
  4.     final SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context);  
  5.     final SharedPreferences.Editor editor = pref.edit();  
  6.     if (size == null) {  
  7.         editor.remove(PREF_SIZE_KEY);  
  8.     } else {  
  9.         editor.putInt(PREF_SIZE_KEY, size.getValue());  
  10.     }  
  11.     editor.apply();  
  12. }  
  13.   
  14. @Nullable  
  15. public static Size getSavedSize(@NonNull Context context) {  
  16.     final SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context);  
  17.     if (pref.contains(PREF_SIZE_KEY)) {  
  18.         @ValidSize int value = pref.getInt(PREF_SIZE_KEY, Size.SIZE_L);  
  19.         return Size.valueOf(value);  
  20.     }  
  21.     return null;  
  22. }  
SharedPreferences から読み出した値が @ValidSize である保証がないので、そこをチェックしたかったら enum 版と同じようなコンバーターか値のバリデータを介す必要があります。
  1. @Nullable  
  2. public static Size getSavedSize(@NonNull Context context) {  
  3.     final SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(context);  
  4.     if (pref.contains(PREF_SIZE_KEY)) {  
  5.         int value = pref.getInt(PREF_SIZE_KEY, Size.SIZE_L);  
  6.         return Size.convertIntValue(value);  
  7.     }  
  8.     return null;  
  9. }  
  1. private static class Size {  
  2.     ...  
  3.   
  4.     public static Size convertIntValue(int value) {  
  5.         if (value < SIZE_L || value > SIZE_S) {  
  6.             return null;  
  7.         }  
  8.         return new Size(value);  
  9.     }  
  10. }  



saveSize()経由でしか保存しないようにするなら、厳密にチェックしてなくてもいいのかなという気はしますが。


未選択状態を持つデータをどう表現するか悩んだ話

*追記1:enum の場合について最後に追記しました。

*追記2:まずは int 型じゃねーよ、enumだろって vvakameさんに怒られたけど、もともとは、とあるプロジェクトでサーバーに意図しない値がきてるんだけど、、、みたいなことがあって、サーバーに渡す値を静的に制限するにはどうするのがいいのかな、というのが出発点だったのです。なんで最初が int かっつーと、そのときのコードが int だったからだよっ(つまり初心者がやりがちってこと)



例えば T シャツのサイズをユーザーに選択してもらう画面があったとします。
Tシャツのサイズは L, M, S で、サーバーに投げるときはそれぞれ int値 の 1, 2, 3 として渡します。

まずは int 型で、ってなりますよね。
  1. private int size;  
  2.   
  3. @Override  
  4. public void onSizeSelected(int size) {  
  5.     this.size = size;  
  6. }  
  7.   
  8. @OnClick(R.id.send_button)  
  9. void onSendButtonClicked() {  
  10.     send(size);  
  11. }  
  12.   
  13. @POST("/tshirt-size")  
  14. boolean send(@Query("size"int size);  
これだと、ユーザーが選択していない状態で送信ボタンを押すと 0 が送られてしまうので、メッセージを表示して送信をブロックしましょう。
そのためには未選択状態の値を定義しないといけません。よくあるのは -1 で、こんな感じになるでしょう。
  1. private int size = -1;  
  2.   
  3. @Override  
  4. public void onSizeSelected(int size) {  
  5.     this.size = size;  
  6. }  
  7.   
  8. @OnClick(R.id.send_button)  
  9. void onSendButtonClicked() {  
  10.     if (size == -1) {  
  11.         Toast.makeText(context, "サイズを選択してください", Toast.LENGTH_SHORT).show();  
  12.     } else {  
  13.         send(size);  
  14.     }  
  15. }  
悪くないんですが、-1 を弾くよりサーバーに送るデータ を 1, 2, 3 に制限したほうがよさそうです。
そこで @IntDef を使って次のようにしてみます。
  1. public static final int SIZE_L = 1;  
  2. public static final int SIZE_M = 2;  
  3. public static final int SIZE_S = 3;  
  4.   
  5. @Retention(RetentionPolicy.SOURCE)  
  6. @IntDef({SIZE_L, SIZE_M, SIZE_S})  
  7. public @interface ValidSize {  
  8. }  
  9.   
  10. private int size = -1;  
  11.   
  12. @Override  
  13. public void onSizeSelected(@ValidSize int size) {  
  14.     this.size = size;  
  15. }  
  16.   
  17. @OnClick(R.id.send_button)  
  18. void onSendButtonClicked() {  
  19.     if (size == -1) {  
  20.         Toast.makeText(this"サイズを選択してください", Toast.LENGTH_SHORT).show();  
  21.     } else {  
  22.         send(size); // ここでエラーになる  
  23.     }  
  24. }  
  25.   
  26. @POST("/tshirt-size")  
  27. boolean send(@ValidSize int size) {  
  28.     return true;  
  29. }  
これだと send(size); のところでエラーがでます。size が @ValidSize ではないからですね。
次のように値をチェックすればエラーは出なくなりますが、int から int 変換ですしどうもいまいちです。
  1. @OnClick(R.id.send_button)  
  2. void onSendButtonClicked() {  
  3.     if (size == -1) {  
  4.         Toast.makeText(this"サイズを選択してください", Toast.LENGTH_SHORT).show();  
  5.     } else {  
  6.         switch (size) {  
  7.             case SIZE_L:  
  8.                 send(SIZE_L);  
  9.                 break;  
  10.             case SIZE_M:  
  11.                 send(SIZE_M);  
  12.                 break;  
  13.             case SIZE_S:  
  14.                 send(SIZE_S);  
  15.                 break;  
  16.         }  
  17.     }  
  18. }  
そこで @ValidSize の int 値を保持するクラスを用意してみます。
未設定かどうかを保持する boolean 値も持たせます。
  1. private static class Size {  
  2.     public static final int SIZE_L = 1;  
  3.     public static final int SIZE_M = 2;  
  4.     public static final int SIZE_S = 3;  
  5.   
  6.     @Retention(RetentionPolicy.SOURCE)  
  7.     @IntDef({SIZE_L, SIZE_M, SIZE_S})  
  8.     public @interface ValidSize {  
  9.     }  
  10.   
  11.     @ValidSize  
  12.     private int size;  
  13.     private boolean isValid = false;  
  14.   
  15.     public void setSize(@ValidSize int size) {  
  16.         this.size = size;  
  17.         this.isValid = true;  
  18.     }  
  19.   
  20.     @ValidSize  
  21.     public int getSize() {  
  22.         return size;  
  23.     }  
  24.   
  25.     public boolean isValid() {  
  26.         return isValid;  
  27.     }  
  28. }  
  29.   
  30. private Size size = new Size();  
  31.   
  32. @Override  
  33. public void onSizeSelected(@Size.ValidSize int size) {  
  34.     this.size.setSize(size);  
  35. }  
  36.   
  37. @OnClick(R.id.send_button)  
  38. void onSendButtonClicked() {  
  39.     // ここのチェックを強制できない  
  40.     if (!size.isValid()) {  
  41.         Toast.makeText(this"サイズを選択してください", Toast.LENGTH_SHORT).show();  
  42.     } else {  
  43.         send(size.getSize());  
  44.     }  
  45. }  
  46.   
  47. @POST("/tshirt-size")  
  48. boolean send(@Size.ValidSize int size) {  
  49.     return true;  
  50. }  
これで不細工なswitch文にさよならできましたが、問題があります。
size.isValid() でチェックすることを利用側に強制できません。

そこで、null を未設定状態として扱うようにしてみます。
  1. private static class Size {  
  2.     public static final int SIZE_L = 1;  
  3.     public static final int SIZE_M = 2;  
  4.     public static final int SIZE_S = 3;  
  5.   
  6.     @Retention(RetentionPolicy.SOURCE)  
  7.     @IntDef({SIZE_L, SIZE_M, SIZE_S})  
  8.     public @interface ValidSize {  
  9.     }  
  10.   
  11.     public static Size valueOf(@ValidSize int size) {  
  12.         return new Size(size);  
  13.     }  
  14.   
  15.     @ValidSize  
  16.     private final int size;  
  17.   
  18.     private Size(@ValidSize int size) {  
  19.         this.size = size;  
  20.     }  
  21.   
  22.     @ValidSize  
  23.     public int getValue() {  
  24.         return size;  
  25.     }  
  26. }  
  27.   
  28. @Nullable  
  29. private Size size = null;  
  30.   
  31. @Override  
  32. public void onSizeSelected(@Size.ValidSize int size) {  
  33.     this.size = Size.valueOf(size);  
  34. }  
  35.   
  36. @Override  
  37. public void onSizeCleared() {  
  38.     this.size = null;  
  39. }  
  40.   
  41. @OnClick(R.id.send_button)  
  42. void onSendButtonClicked() {  
  43.     if (size == null) {  
  44.         Toast.makeText(this"サイズを選択してください", Toast.LENGTH_SHORT).show();  
  45.     } else {  
  46.         send(size.getValue());  
  47.     }  
  48. }  
  49.   
  50. @POST("/tshirt-size")  
  51. boolean send(@Size.ValidSize int size) {  
  52.     return true;  
  53. }  
これで null じゃないときに取得できる値を @Size.ValidSize に制限できます。

size に @Nullable をつければ、null チェックをしないで size.getValue() を呼び出そうとしたところで Lint の警告が出てくれます。
size に null を代入することでサイズ選択のクリアもできます。


未選択状態に特定の値を割り当てる場合、その値が絶対使われないならいいのですが、使われる場合もありえます。 例えば、透明度を含む色を選択してもらいたい場合では #ffffffff が -1 なので、-1を未選択状態に割り当てるのは不適当になります。
null を未選択状態に割り当てる方法はこういう場合にも適用できます。



追記 :

@vvakame から enum 使えよおらーって言われたので、@zaki50 さんの提案をもとに enum版も載せておきます。

前提として、send()に渡される引数の値を制限したいというのが目的です。 retrofit で引数のパラメータに enum を渡すと toString() の値が利用されるようで、なにかしらコンバーターを仕込まないといけなさそうです。 なので、send() に渡す値に @ValidSize をつけるのはそのままにしたいと思います。

こんな感じになります。ほぼ同じですね。
違いは、onSizeSelected() の引数が @ValidSize int size から Size になったので、この部分の値の制限が Lint からコンパイラになったという点と、 enum なので == で比較できるという点くらいでしょうか。
ちなみにこの書き方だと SIZE_L などを static import する必要があります。Ctrl + space2回 で候補がでます。
  1. public enum Size {  
  2.     L(SIZE_L), M(SIZE_M), S(SIZE_S);  
  3.   
  4.     @Retention(RetentionPolicy.SOURCE)  
  5.     @IntDef({SIZE_L, SIZE_M, SIZE_S})  
  6.     public @interface ValidSize {  
  7.         int SIZE_L = 1;  
  8.         int SIZE_M = 2;  
  9.         int SIZE_S = 3;  
  10.     }  
  11.   
  12.     @ValidSize  
  13.     private final int size;  
  14.   
  15.     Size(@ValidSize int size) {  
  16.         this.size = size;  
  17.     }  
  18.   
  19.     @ValidSize  
  20.     public int getValue() {  
  21.         return size;  
  22.     }  
  23. }  
  24.   
  25. @Nullable  
  26. private Size size = null;  
  27.   
  28. @Override  
  29. public void onSizeSelected(Size size) {  
  30.     this.size = size;  
  31. }  
  32.   
  33. @Override  
  34. public void onSizeCleared() {  
  35.     this.size = null;  
  36. }  
  37.   
  38. @OnClick(R.id.send_button)  
  39. void onSendButtonClicked() {  
  40.     if (size == null) {  
  41.         Toast.makeText(this"サイズを選択してください", Toast.LENGTH_SHORT).show();  
  42.     } else {  
  43.         send(size.getValue());  
  44.     }  
  45. }  
  46.   
  47. @POST("/tshirt-size")  
  48. boolean send(@Size.ValidSize int size) {  
  49.     return true;  
  50. }  




2015年5月18日月曜日

カスタムDrawableで複雑なプログレスを作る

ProgressBar の indeterminate にカスタムDrawableを指定すると、draw() と onLevelChange() が呼ばれ続けます。
  1. final CustomDrawable customDrawable = new CustomDrawable();  
  2. ProgressBar progressBar = (ProgressBar) findViewById(R.id.progressBar);  
  3. progressBar.setIndeterminate(true);  
  4. progressBar.setIndeterminateDrawable(customDrawable);  
ProgressBar に RotateDrawable をセットすると回転するのは、このonLevelChange() を利用して角度を変えているからです。
  1. public class CustomDrawable extends Drawable {  
  2.   
  3.     @Override  
  4.     public void draw(Canvas canvas) {  
  5.         // 繰り返し呼ばれる  
  6.     }  
  7.   
  8.     @Override  
  9.     public void setAlpha(int alpha) {  
  10.     }  
  11.   
  12.     @Override  
  13.     public void setColorFilter(ColorFilter cf) {  
  14.     }  
  15.   
  16.     @Override  
  17.     public int getOpacity() {  
  18.         return PixelFormat.TRANSLUCENT;  
  19.     }  
  20.   
  21.     @Override  
  22.     protected boolean onLevelChange(int level) {  
  23.         // 繰り返し呼ばれる  
  24.         // level は 0 〜 10000  
  25.         return super.onLevelChange(level);  
  26.     }  
  27. }  
この振る舞いは、Animatable を実装すると変わります。 onLevelChange() は呼ばれなくなり、そのままだと stop(), start() の後に draw() が1回だけ呼ばれます。
  1. public class CustomDrawable extends Drawable implements Animatable {  
  2.   
  3.     @Override  
  4.     public void draw(Canvas canvas) {  
  5.     }  
  6.   
  7.     ...  
  8.   
  9.     @Override  
  10.     public void start() {  
  11.     }  
  12.   
  13.     @Override  
  14.     public void stop() {  
  15.     }  
  16.   
  17.     @Override  
  18.     public boolean isRunning() {  
  19.         return false;  
  20.     }  
  21. }  
このままでは draw() が連続して呼ばれないのでアニメーションになりません。そのため、まず start() で内部のアニメーションを開始します。アニメーションを開始したら invalidateSelf() や scheduleSelf() を呼びます。
また、draw() 内でもアニメーション中なら invalidateSelf() や scheduleSelf() を呼ぶようにします。これにより、アニメーション中は draw() が連続して呼ばれるようになります。
  1. public class CustomDrawable extends Drawable implements Animatable {  
  2.   
  3.     ...  
  4.   
  5.     private static final Interpolator cubicBezierInterpolator = PathInterpolatorCompat.create(0.66f, 0.22f, 0.21f, 1f);  
  6.     private final int width;  
  7.     private final int height;  
  8.     private final RectF rectF;  
  9.     private final ValueAnimator valueAnimator;  
  10.     private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);  
  11.     private final Path path = new Path();  
  12.   
  13.     @Override  
  14.     public int getIntrinsicWidth() {  
  15.         return width;  
  16.     }  
  17.   
  18.     @Override  
  19.     public int getIntrinsicHeight() {  
  20.         return height;  
  21.     }  
  22.   
  23.     public CustomDrawable(Context context) {  
  24.         float density = context.getResources().getDisplayMetrics().density;  
  25.         width = (int) (density * 100);  
  26.         height = (int) (density * 100);  
  27.   
  28.         rectF = new RectF(density * 6, density * 6, density * 94, density * 94);  
  29.   
  30.         valueAnimator = ValueAnimator.ofFloat(0f, 1f);  
  31.         valueAnimator.setDuration(2000);  
  32.         valueAnimator.setRepeatCount(ValueAnimator.INFINITE);  
  33.         valueAnimator.setInterpolator(cubicBezierInterpolator);  
  34.   
  35.         paint.setColor(Color.parseColor("#0D47A1"));  
  36.         paint.setStyle(Paint.Style.FILL);  
  37.     }  
  38.   
  39.     @Override  
  40.     public void draw(Canvas canvas) {  
  41.         float factor = (float) valueAnimator.getAnimatedValue();  
  42.         paint.setAlpha((int) (255 * (1f - factor)));  
  43.         path.reset();  
  44.         path.moveTo(width * 0.5f, height * 0.5f);  
  45.         path.arcTo(rectF, -90360 * factor);  
  46.         path.moveTo(width * 0.5f, height * 0.5f);  
  47.         path.close();  
  48.         canvas.drawPath(path, paint);  
  49.   
  50.         if (isStarted()) {  
  51.             invalidateSelf();  
  52.         }  
  53.     }  
  54.   
  55.     @Override  
  56.     public void start() {  
  57.         if (isStarted()) {  
  58.             return;  
  59.         }  
  60.   
  61.         final Animator animator = valueAnimator;  
  62.         animator.start();  
  63.   
  64.         invalidateSelf();  
  65.     }  
  66.   
  67.     @Override  
  68.     public void stop() {  
  69.         final Animator animator = valueAnimator;  
  70.         animator.end();  
  71.     }  
  72.   
  73.     @Override  
  74.     public boolean isRunning() {  
  75.         final Animator animator = valueAnimator;  
  76.         if (animator.isRunning()) {  
  77.             return true;  
  78.         }  
  79.         return false;  
  80.     }  
  81.   
  82.     private boolean isStarted() {  
  83.         final Animator animator = valueAnimator;  
  84.         if (animator.isStarted()) {  
  85.             return true;  
  86.         }  
  87.         return false;  
  88.     }  
  89. }  




Animatable を実装しているクラスとして、AnimatedVectorDrawableAnimationDrawable があります。



2015年5月11日月曜日

全画面の Toast を表示する

Gravity の FILL_HORIZONTALFILL_GRAVITY を指定します。
  1. private void showFullscreenToast(Context context, String message) {  
  2.     TextView tv = new TextView(context);  
  3.     tv.setText(message);  
  4.     tv.setTextColor(Color.WHITE);  
  5.     tv.setBackgroundColor(Color.parseColor("#99000000"));  
  6.     tv.setGravity(Gravity.CENTER);  
  7.   
  8.     Toast toast = new Toast(context);  
  9.     toast.setGravity(Gravity.FILL_HORIZONTAL | Gravity.FILL_VERTICAL, 00);  
  10.     toast.setView(tv);  
  11.     toast.show();  
  12. }  


対応するToastクラスのコードは以下になります。

http://tools.oesf.biz/android-5.0.1_r1.0/xref/frameworks/base/core/java/android/widget/Toast.java#400
  1. 400                 if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {  
  2. 401                     mParams.horizontalWeight = 1.0f;  
  3. 402                 }  
  4. 403                 if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {  
  5. 404                     mParams.verticalWeight = 1.0f;  
  6. 405                 }