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
にあります。