2016年の11月に droid girls というAndroidの技術に特化した女性コミュニティを立ち上げました。
第2回 Meetup では私が講師を担当してRecyclerViewを取り上げたのですが、その時に SnapHelper というものを発見してしまいました。
今日はこの SnapHelper についての話です。
https://t.co/5sXrCQb7yX いつのまにか増えてた
— Yuki Anzai (@yanzm) 2016年12月21日
ちなみに第3回 Meetupでは vector drawable を取り上げます。開催は2017年1月下旬を予定しています。
本題
以下の検証は v25.1.0 で行っています。RecyclerView.OnFlingListener および関連する SnapHelper などは v24.2.0 で追加されました。
- RecyclerView.setOnFlingListener(android.support.v7.widget.RecyclerView.OnFlingListener)
- RecyclerView.OnFlingListener
- abstract class
- fling 時の挙動を定義
- つまり、このクラスのサブクラスを用意することで fling 時の挙動をカスタマイズ可能
- SnapHelper
- RecyclerView.OnFlingListener を継承した abstract class
- fling したときに子ビューに snap させる
- LayoutManager が ScrollVectorProvider を実装している前提
- つまり、独自の LayoutManager で利用する場合は ScrollVectorProvider を実装する必要がある
- LinearSnapHelper
- SnapHelperを継承したクラス
- snap 対象の View の中心が RecyclerView の中心に来るように snap する
- PagerSnapHelper
- SnapHelperを継承したクラス
- ViewPagerのような挙動を実現する
SnapHelper の abstract メソッド
- public abstract int findTargetSnapPosition(LayoutManager layoutManager, int velocityX, int velocityY)
- snap 対象の Adapter での位置を返す
- public abstract View findSnapView(LayoutManager layoutManager)
- snap 対象の View を返す
- scroll 後に scroll 状態が idle になったときと、SnapHelper.attachToRecyclerView(android.support.v7.widget.RecyclerView) を呼んだときに呼ばれる
- 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;
}
...
}