2016年1月25日月曜日

RecyclerView の notifyItemChanged() 時のちらつきを止める

RecyclerView には RecyclerView.ItemAnimator として DefaultItemAnimator が最初からセットされています。

RecyclerView.AdapternotifyItemChanged()notifyItemRangeChanged() が呼ばれると、RecyclerView.AdapterDataObserver を通して DefaultItemAnimator の animateChange() が呼ばれます。 ここのコードを見ると @Override public boolean animateChange(ViewHolder oldHolder, ViewHolder newHolder, int fromX, int fromY, int toX, int toY) { if (oldHolder == newHolder) { // Don't know how to run change animations when the same view holder is re-used. // run a move animation to handle position changes. return animateMove(oldHolder, fromX, fromY, toX, toY); } final float prevTranslationX = ViewCompat.getTranslationX(oldHolder.itemView); final float prevTranslationY = ViewCompat.getTranslationY(oldHolder.itemView); final float prevAlpha = ViewCompat.getAlpha(oldHolder.itemView); resetAnimation(oldHolder); int deltaX = (int) (toX - fromX - prevTranslationX); int deltaY = (int) (toY - fromY - prevTranslationY); // recover prev translation state after ending animation ViewCompat.setTranslationX(oldHolder.itemView, prevTranslationX); ViewCompat.setTranslationY(oldHolder.itemView, prevTranslationY); ViewCompat.setAlpha(oldHolder.itemView, prevAlpha); if (newHolder != null) { // carry over translation values resetAnimation(newHolder); ViewCompat.setTranslationX(newHolder.itemView, -deltaX); ViewCompat.setTranslationY(newHolder.itemView, -deltaY); ViewCompat.setAlpha(newHolder.itemView, 0); } mPendingChanges.add(new ChangeInfo(oldHolder, newHolder, fromX, fromY, toX, toY)); return true; } oldHolder と newHolder のインスタンスが同じときは移動のアニメーションだけを行い、異なる場合は移動 + アルファのアニメーションを行っています。
そのためレイアウト上の一部分だけを変更するとき(例えば写真のグリッド上にあるお気に入りマークの状態を更新するなど)に notifyItemChanged() を呼ぶと、アルファの処理が入るのでちらつきます。

これを防ぐにはアルファの処理が入らないパス、つまり newHolder として oldHolder と同じインスタンスが渡されるようになればいいわけです。そもそもレイアウトに変更がないのであればインスタンスを再利用しないのは無駄です。

これを切り替えるのが ItemAnimator の canReuseUpdatedViewHolder() です。 デフォルトでは true つまり再利用するようになっています。 ではどこで false が返るように変わったかというと DefaultItemAnimator の親クラスの SimpleItemAnimator です。 abstract public class SimpleItemAnimator extends RecyclerView.ItemAnimator { ... boolean mSupportsChangeAnimations = true; @SuppressWarnings("unused") public boolean getSupportsChangeAnimations() { return mSupportsChangeAnimations; } public void setSupportsChangeAnimations(boolean supportsChangeAnimations) { mSupportsChangeAnimations = supportsChangeAnimations; } @Override public boolean canReuseUpdatedViewHolder(RecyclerView.ViewHolder viewHolder) { return !mSupportsChangeAnimations || viewHolder.isInvalid(); } ... } デフォルトでは mSupportsChangeAnimations が true なので viewHolder.isInvalid() が true のときだけ canReuseUpdatedViewHolder() が true を返す(=再利用する)ようになっています。 mSupportsChangeAnimations の値は setSupportsChangeAnimations() で変更できるようになっているので、 ((DefaultItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); のようにすれば notifyItemChanged() 時のアニメーションが移動だけになります。


2016年1月19日火曜日

TextInputLayout の中の EditText の baseline で揃える方法

「android:baselineAlignedChildIndex の振る舞い」で紹介した方法を使えば、TextInputLayout の中の EditText で位置を揃えることができます。 <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal" android:padding="16dp"> <TextView android:layout_width="wrap_content" android:layout_height="48dp" android:background="#ccccff" android:gravity="center_vertical" android:text="Cupcake" /> <android.support.design.widget.TextInputLayout android:id="@+id/input_layout" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginLeft="16dp" android:background="#ffcccc" android:baselineAlignedChildIndex="0" android:orientation="vertical" app:errorEnabled="true"> <EditText android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="Donuts" /> </android.support.design.widget.TextInputLayout> </LinearLayout>

android:baselineAlignedChildIndex の振る舞い

LinearLayout が baselineAligned されている他のレイアウトの子であるとき、android:baselineAlignedChildIndex で baseline に対応させる子ビューを指定することができます。

まず、以下のようなレイアウトがあった場合 <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="#ccccff" android:paddingTop="60dp" android:text="Cupcake" /> <LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginLeft="10dp" android:layout_marginRight="10dp" android:background="#ffcccc" android:orientation="vertical"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Donuts" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Eclare" /> </LinearLayout> </LinearLayout> 実行結果はこのようになります。



LinearLayout はデフォルトで android:baselineAligned="true" がセットされているので、子ビューの LinearLayout に android:baselineAlignedChildIndex="0" を指定すると次ようなレイアウトに変わります。 <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" ...> <TextView ... /> <LinearLayout ... android:baselineAlignedChildIndex="0"> .... </LinearLayout> </LinearLayout>

android:baselineAlignedChildIndex="1" を指定すると次のようになります。




2016年1月16日土曜日

Android 6.0(M)で oneShot の AnimationDrawable の状態がリセットされる

後方互換性がなくなるのはつらいでござるよ...

oneShotAnimationDrawable をアニメーションさせると最後のフレームのままになります。6.0 未満では画面遷移(別のActivityを開くとかホームボタンを押すとか)して戻ってきても最後のフレームのままでしたが、6.0 から最初のフレームに戻ってしまうようになっています。

M preview のときに Issue↓ が立っていて、Status は Fixedなのですが直っていません(つらい)。

Issue3122 AnimationDrawable (oneShot) end state broken

6.0 で AnimationDrawable のコードが少し変わっていますが、直接的な原因はそこではなく setVisible() メソッドの呼ばれ方が変わったことが影響しています。

5系で実行すると以下のタイミングで setVisible() が呼ばれます。

1. 表示されたとき setVisible(true, false)

一方、6系で実行すると以下のタイミングで setVisible() が呼ばれます。

1. 表示されたとき setVisible(true, false)
2. 他の画面に遷移したとき setVisible(false, false)
3. 戻ってきて表示されたとき setVisible(true, false)

5系だと最初に表示されたときだけ setVisible() が呼ばれるのですが、6系だと別の画面に遷移したときと、戻ってきたときも setVisible() が呼ばれるようになっています。
setVisible() のコードをみると次のようになっています。 @Override public boolean setVisible(boolean visible, boolean restart) { final boolean changed = super.setVisible(visible, restart); if (visible) { if (restart || changed) { boolean startFromZero = restart || !mRunning || mCurFrame >= mAnimationState.getChildCount(); setFrame(startFromZero ? 0 : mCurFrame, true, mAnimating); } } else { unscheduleSelf(this); } return changed; } 別の画面に遷移したとき visible = false で setVisible() が呼ばれるので unscheduleSelf() されてしまい、ここで mRunning が falseになるので、戻ってきたとき(changed = true)に setFrame(0) されてしまうのが原因です。

そこで、oneShotのときは visible = false のときに unscheduleSelf() を呼ばないようにしたサブクラス(AnimationDrawableForM)を用意し、6.0以降の場合はこれを使うようにしてみました。 public class AnimationDrawableCompat { public static AnimationDrawable getAnimationDrawable(Context context, int resId) { AnimationDrawable d = (AnimationDrawable) ContextCompat.getDrawable(context, resId); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return d; } AnimationDrawableForM dm = new AnimationDrawableForM(); dm.setOneShot(d.isOneShot()); final int count = d.getNumberOfFrames(); for (int i = 0; i < count; i++) { dm.addFrame(d.getFrame(i), d.getDuration(i)); } return dm; } public static class AnimationDrawableForM extends AnimationDrawable { private boolean visible = true; @Override public boolean setVisible(boolean visible, boolean restart) { if (this.visible) { this.visible = visible; return super.setVisible(visible, restart); } else { this.visible = visible; return true; } } @Override public void unscheduleSelf(Runnable what) { if (visible || !isOneShot()) { super.unscheduleSelf(what); } } } } しかし、何回も画面遷移するとまだ状態がリセットされてしまうので、フレーム位置を保持するような方法に変えました。 public class AnimationDrawableCompat { public static AnimationDrawable getAnimationDrawable(Context context, int resId) { AnimationDrawable d = (AnimationDrawable) ContextCompat.getDrawable(context, resId); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return d; } AnimationDrawableForM dm = new AnimationDrawableForM(); dm.setOneShot(d.isOneShot()); final int count = d.getNumberOfFrames(); for (int i = 0; i < count; i++) { dm.addFrame(d.getFrame(i), d.getDuration(i)); } return dm; } public static class AnimationDrawableForM extends AnimationDrawable { private int lastFrame = 0; @Override public boolean setVisible(boolean visible, boolean restart) { final int idx = lastFrame; boolean changed = super.setVisible(visible, restart); if (visible && changed && idx > 0) { selectDrawable(getNumberOfFrames() - 1); } return changed; } @Override public boolean selectDrawable(int idx) { lastFrame = idx; return super.selectDrawable(idx); } } } もしくはリフレクションで問題の mRunning と mCurFrame を書き換えるという方法でも対処できます。 public class AnimationDrawableCompat { public static AnimationDrawable getAnimationDrawable(Context context, int resId) { AnimationDrawable d = (AnimationDrawable) ContextCompat.getDrawable(context, resId); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return d; } AnimationDrawableForM dm = new AnimationDrawableForM(); dm.setOneShot(d.isOneShot()); final int count = d.getNumberOfFrames(); for (int i = 0; i < count; i++) { dm.addFrame(d.getFrame(i), d.getDuration(i)); } return dm; } public static class AnimationDrawableForM extends AnimationDrawable { private int lastFrame = 0; @Override public boolean setVisible(boolean visible, boolean restart) { final int idx = lastFrame; if (visible && idx > 0 && isOneShot() && !isRunning()) { try { // set mRunning to true Field mRunning = getClass().getSuperclass().getDeclaredField("mRunning"); mRunning.setAccessible(true); mRunning.set(this, true); // set mCurFrame to lastFrame Field mCurFrame = getClass().getSuperclass().getDeclaredField("mCurFrame"); mCurFrame.setAccessible(true); mCurFrame.set(this, idx); } catch (NoSuchFieldException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } } return super.setVisible(visible, restart); } @Override public boolean selectDrawable(int idx) { lastFrame = idx; return super.selectDrawable(idx); } } } 根本的に直してほしい...



2016年1月14日木曜日

一部の文字サイズを変えるだけの場合は RelativeSizeSpan が便利

相対文字サイズを変えるだけなら RelativeSizeSpan が便利です。 SpannableStringBuilder sb = new SpannableStringBuilder(); sb.append("Hello "); int start = sb.length(); sb.append("Android"); sb.setSpan(new RelativeSizeSpan(1.5f), start, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); TextView textView = (TextView) findViewById(R.id.text); textView.setText(sb);