2015年5月19日火曜日

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

前回 の続きです。


1. enum 版で SharedPreferences

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

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

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



2. class 版で SharedPreferences

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


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


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

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

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



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

まずは int 型で、ってなりますよね。 private int size; @Override public void onSizeSelected(int size) { this.size = size; } @OnClick(R.id.send_button) void onSendButtonClicked() { send(size); } @POST("/tshirt-size") boolean send(@Query("size") int size); これだと、ユーザーが選択していない状態で送信ボタンを押すと 0 が送られてしまうので、メッセージを表示して送信をブロックしましょう。
そのためには未選択状態の値を定義しないといけません。よくあるのは -1 で、こんな感じになるでしょう。 private int size = -1; @Override public void onSizeSelected(int size) { this.size = size; } @OnClick(R.id.send_button) void onSendButtonClicked() { if (size == -1) { Toast.makeText(context, "サイズを選択してください", Toast.LENGTH_SHORT).show(); } else { send(size); } } 悪くないんですが、-1 を弾くよりサーバーに送るデータ を 1, 2, 3 に制限したほうがよさそうです。
そこで @IntDef を使って次のようにしてみます。 public static final int SIZE_L = 1; public static final int SIZE_M = 2; public static final int SIZE_S = 3; @Retention(RetentionPolicy.SOURCE) @IntDef({SIZE_L, SIZE_M, SIZE_S}) public @interface ValidSize { } private int size = -1; @Override public void onSizeSelected(@ValidSize int size) { this.size = size; } @OnClick(R.id.send_button) void onSendButtonClicked() { if (size == -1) { Toast.makeText(this, "サイズを選択してください", Toast.LENGTH_SHORT).show(); } else { send(size); // ここでエラーになる } } @POST("/tshirt-size") boolean send(@ValidSize int size) { return true; } これだと send(size); のところでエラーがでます。size が @ValidSize ではないからですね。
次のように値をチェックすればエラーは出なくなりますが、int から int 変換ですしどうもいまいちです。 @OnClick(R.id.send_button) void onSendButtonClicked() { if (size == -1) { Toast.makeText(this, "サイズを選択してください", Toast.LENGTH_SHORT).show(); } else { switch (size) { case SIZE_L: send(SIZE_L); break; case SIZE_M: send(SIZE_M); break; case SIZE_S: send(SIZE_S); break; } } } そこで @ValidSize の int 値を保持するクラスを用意してみます。
未設定かどうかを保持する boolean 値も持たせます。 private static class Size { public static final int SIZE_L = 1; public static final int SIZE_M = 2; public static final int SIZE_S = 3; @Retention(RetentionPolicy.SOURCE) @IntDef({SIZE_L, SIZE_M, SIZE_S}) public @interface ValidSize { } @ValidSize private int size; private boolean isValid = false; public void setSize(@ValidSize int size) { this.size = size; this.isValid = true; } @ValidSize public int getSize() { return size; } public boolean isValid() { return isValid; } } private Size size = new Size(); @Override public void onSizeSelected(@Size.ValidSize int size) { this.size.setSize(size); } @OnClick(R.id.send_button) void onSendButtonClicked() { // ここのチェックを強制できない if (!size.isValid()) { Toast.makeText(this, "サイズを選択してください", Toast.LENGTH_SHORT).show(); } else { send(size.getSize()); } } @POST("/tshirt-size") boolean send(@Size.ValidSize int size) { return true; } これで不細工なswitch文にさよならできましたが、問題があります。
size.isValid() でチェックすることを利用側に強制できません。

そこで、null を未設定状態として扱うようにしてみます。 private static class Size { public static final int SIZE_L = 1; public static final int SIZE_M = 2; public static final int SIZE_S = 3; @Retention(RetentionPolicy.SOURCE) @IntDef({SIZE_L, SIZE_M, SIZE_S}) public @interface ValidSize { } public static Size valueOf(@ValidSize int size) { return new Size(size); } @ValidSize private final int size; private Size(@ValidSize int size) { this.size = size; } @ValidSize public int getValue() { return size; } } @Nullable private Size size = null; @Override public void onSizeSelected(@Size.ValidSize int size) { this.size = Size.valueOf(size); } @Override public void onSizeCleared() { this.size = null; } @OnClick(R.id.send_button) void onSendButtonClicked() { if (size == null) { Toast.makeText(this, "サイズを選択してください", Toast.LENGTH_SHORT).show(); } else { send(size.getValue()); } } @POST("/tshirt-size") boolean send(@Size.ValidSize int size) { return true; } これで 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回 で候補がでます。 public enum Size { L(SIZE_L), M(SIZE_M), S(SIZE_S); @Retention(RetentionPolicy.SOURCE) @IntDef({SIZE_L, SIZE_M, SIZE_S}) public @interface ValidSize { int SIZE_L = 1; int SIZE_M = 2; int SIZE_S = 3; } @ValidSize private final int size; Size(@ValidSize int size) { this.size = size; } @ValidSize public int getValue() { return size; } } @Nullable private Size size = null; @Override public void onSizeSelected(Size size) { this.size = size; } @Override public void onSizeCleared() { this.size = null; } @OnClick(R.id.send_button) void onSendButtonClicked() { if (size == null) { Toast.makeText(this, "サイズを選択してください", Toast.LENGTH_SHORT).show(); } else { send(size.getValue()); } } @POST("/tshirt-size") boolean send(@Size.ValidSize int size) { return true; }



2015年5月18日月曜日

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

ProgressBar の indeterminate にカスタムDrawableを指定すると、draw() と onLevelChange() が呼ばれ続けます。 final CustomDrawable customDrawable = new CustomDrawable(); ProgressBar progressBar = (ProgressBar) findViewById(R.id.progressBar); progressBar.setIndeterminate(true); progressBar.setIndeterminateDrawable(customDrawable); ProgressBar に RotateDrawable をセットすると回転するのは、このonLevelChange() を利用して角度を変えているからです。 public class CustomDrawable extends Drawable { @Override public void draw(Canvas canvas) { // 繰り返し呼ばれる } @Override public void setAlpha(int alpha) { } @Override public void setColorFilter(ColorFilter cf) { } @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } @Override protected boolean onLevelChange(int level) { // 繰り返し呼ばれる // level は 0 〜 10000 return super.onLevelChange(level); } } この振る舞いは、Animatable を実装すると変わります。 onLevelChange() は呼ばれなくなり、そのままだと stop(), start() の後に draw() が1回だけ呼ばれます。 public class CustomDrawable extends Drawable implements Animatable { @Override public void draw(Canvas canvas) { } ... @Override public void start() { } @Override public void stop() { } @Override public boolean isRunning() { return false; } } このままでは draw() が連続して呼ばれないのでアニメーションになりません。そのため、まず start() で内部のアニメーションを開始します。アニメーションを開始したら invalidateSelf() や scheduleSelf() を呼びます。
また、draw() 内でもアニメーション中なら invalidateSelf() や scheduleSelf() を呼ぶようにします。これにより、アニメーション中は draw() が連続して呼ばれるようになります。 public class CustomDrawable extends Drawable implements Animatable { ... private static final Interpolator cubicBezierInterpolator = PathInterpolatorCompat.create(0.66f, 0.22f, 0.21f, 1f); private final int width; private final int height; private final RectF rectF; private final ValueAnimator valueAnimator; private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Path path = new Path(); @Override public int getIntrinsicWidth() { return width; } @Override public int getIntrinsicHeight() { return height; } public CustomDrawable(Context context) { float density = context.getResources().getDisplayMetrics().density; width = (int) (density * 100); height = (int) (density * 100); rectF = new RectF(density * 6, density * 6, density * 94, density * 94); valueAnimator = ValueAnimator.ofFloat(0f, 1f); valueAnimator.setDuration(2000); valueAnimator.setRepeatCount(ValueAnimator.INFINITE); valueAnimator.setInterpolator(cubicBezierInterpolator); paint.setColor(Color.parseColor("#0D47A1")); paint.setStyle(Paint.Style.FILL); } @Override public void draw(Canvas canvas) { float factor = (float) valueAnimator.getAnimatedValue(); paint.setAlpha((int) (255 * (1f - factor))); path.reset(); path.moveTo(width * 0.5f, height * 0.5f); path.arcTo(rectF, -90, 360 * factor); path.moveTo(width * 0.5f, height * 0.5f); path.close(); canvas.drawPath(path, paint); if (isStarted()) { invalidateSelf(); } } @Override public void start() { if (isStarted()) { return; } final Animator animator = valueAnimator; animator.start(); invalidateSelf(); } @Override public void stop() { final Animator animator = valueAnimator; animator.end(); } @Override public boolean isRunning() { final Animator animator = valueAnimator; if (animator.isRunning()) { return true; } return false; } private boolean isStarted() { final Animator animator = valueAnimator; if (animator.isStarted()) { return true; } return false; } }



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