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; } ... }