RecyclerViewはアイテムをスクロールさせて隠れたアイテムを表示できます。
スクロールを止める位置は任意です。
任意であるがゆえに、止めた位置によってはアイテムの一部が欠けてしまうこともあります。
携帯端末の画面は狭いので、効率よくコンテンツの表示を行いたいとアプリ開発者は考えます。
アイテムの一部が欠けてしまうことは、効率が良いとは言えません。
このような問題を解決するために、アイテムのスナップをRecyclerViewへ追加できます。
アイテムのスナップを追加する方法を紹介します。
スナップとは
「アイテムのスナップ」とは簡単に言えば「アイテムへ配置条件を設ける」ことです。
例えば、下図は「RecyclerViewの中央にアイテムの上辺を配置する」という条件を持たせた場合です。
条件から外れた配置は出来ません。条件を満たす配置へ吸い付くように移動します。
先にも書きましたが、狭い携帯端末の画面を効率よく使うための、1つの手段です。
アイテムが画像であったり、文字であったり、アイテムの内容によって「効率がよい」の意味が違ってくるので、一概には言えませんが…
RecyclerViewの中央へスナップ(既成クラス)
recyclerview APIにLinearSnapHelerクラスが用意されています。
LinearSnapHelperはスナップをRecyclerViewへ追加します。名前の通り、LinearLayoutManager向けです。
LinearSnapHelperの実装
LinearSnapHelperのアイテムの配置条件は「RecyclerViewの中央に、アイテムの中央を配置する」です。
... lateinit var rcySample: RecyclerView lateinit var snap: SnapHelper ... val _adapter = ListAdapter(_items) val _manager = LinearLayoutManager(this@MainActivity) rcySample = findViewById(R.id.rcySample) rcySample.adapter = _adapter rcySample.layoutManager = _manager ... snap = LinearSnapHelper() snap.attachToRecyclerView(rcySample) ...
... lateinit var rcySample: RecyclerView lateinit var snap: SnapHelper ... val _adapter = ListAdapter(_items) val _manager = LinearLayoutManager(this@MainActivity, LinearLayoutManager.HORIZONTAL, false) rcySample = findViewById(R.id.rcySample) rcySample.adapter = _adapter rcySample.layoutManager = _manager ... snap = LinearSnapHelper() snap.attachToRecyclerView(rcySample) ...
実装は簡単でSnapHelper#attachToRecyclerViewメソッドでRecyclerViewへ関連付けるだけです。※LinearSnapHelperはSnapHelperを継承
VERTICALとHORIZONTALの判定はLinearSnapHelperが行ってくれます。
LinearSnapHelperの仕組み
期待したスナップへカスタマイズするために仕組みの理解が必要です。
※なかなかコード読みが難しく、この章は間違いが多いかもしれません。
スナップ動作の手順
初めに、全体的な動作を説明します。スナップは次の3つの手順で成り立っています。
- (1)最終的なポジションの算出
- (2)フリング(Fling)によるスクロール
- (3)配置条件合わせのスクロール
(1)最終的なポジションの算出
フリング直後に、現在のポジション(currentPosition)とスクロール量の見積もり(deltaJump)を足し合わせて、最終的なポジション(targetPos)を算出します。
(2)フリングによるスクロール
フリングによりスクロールが起動されます。
スクロール量はフリングの速度(velocityX/Y)で決まります。速度が早ければスクロール量は多くなり、遅ければ少なくなります。
スクロール後の停止位置は(1)で算出した最終的なポジションよりも少し手前です。
※なぜ、手前になるのか?理由がプログラムコードから読み取れませんでした。よって、事象の確認のみです。
スクロール後の停止位置と最終的なポジションの差が(3)配置条件合わせのスクロール距離になります。
(3)配置条件合わせのスクロール
最終的なポジションまでスクロールを行います。スクロールを行う中でスナップの配置条件を合わせます。
スクロールというとアイテムが上下に流れて行くという描写になります。しかし、プログラム的にはRecyclerViewの枠がアイテムのリスト上を移動していくイメージの方が正しいです。
SnapHelperを継承
LinearSnapHelperはSnapHelper抽象クラスを継承して作られています。
実装が必要な抽象メソッドは3つです。
これらのメソッドは手順(1)~(3)の動作を決めます。期待したスナップが欲しい場合にカスタマイズが必要なメソッドです。
findSnapView
RecycylerViewの中からスナップ対象のアイテム(View)を見つけて、返します。
LinearSnapHelperの場合は、RecyclerViewの中央へアイテムの中央を配置する条件になっているので、両者の中央が最も近いアイテムを返します。
@Override public View findSnapView(RecyclerView.LayoutManager layoutManager) { if (layoutManager.canScrollVertically()) { return findCenterView(layoutManager, getVerticalHelper(layoutManager)); } else if (layoutManager.canScrollHorizontally()) { return findCenterView(layoutManager, getHorizontalHelper(layoutManager)); } return null; } ... @Nullable private View findCenterView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) { int childCount = layoutManager.getChildCount(); if (childCount == 0) { return null; } View closestChild = null; final int center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2; int absClosest = Integer.MAX_VALUE; for (int i = 0; i < childCount; i++) { final View child = layoutManager.getChildAt(i); int childCenter = helper.getDecoratedStart(child) + (helper.getDecoratedMeasurement(child) / 2); int absDistance = Math.abs(childCenter - center); /** if child center is closer than previous closest, set it as closest **/ if (absDistance < absClosest) { absClosest = absDistance; closestChild = child; } } return closestChild; }
findTargetSnapPosition
手順(1)の最終的なポジションを返します。
このメソッドはフリングのイベントの直後に呼び出されます。
フリングの速度(velocityX/Y)からスクロール量(deltaJump:ポジションの移動数)を見積もって、現在のポジション(currentPosition)へ加算した値を最終ポジション(targetPos)として返します。
現在のポジションはfindSnapViewメソッド(前述)を使って求めています。
@Override public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) { ... final View currentView = findSnapView(layoutManager); if (currentView == null) { return RecyclerView.NO_POSITION; } final int currentPosition = layoutManager.getPosition(currentView); if (currentPosition == RecyclerView.NO_POSITION) { return RecyclerView.NO_POSITION; } ... (HORIZONTALの処理は省略) if (layoutManager.canScrollVertically()) { vDeltaJump = estimateNextPositionDiffForFling(layoutManager, getVerticalHelper(layoutManager), 0, velocityY); if (vectorForEnd.y < 0) { vDeltaJump = -vDeltaJump; } } else { vDeltaJump = 0; } int deltaJump = layoutManager.canScrollVertically() ? vDeltaJump : hDeltaJump; if (deltaJump == 0) { return RecyclerView.NO_POSITION; } int targetPos = currentPosition + deltaJump; if (targetPos < 0) { targetPos = 0; } if (targetPos >= itemCount) { targetPos = itemCount - 1; } return targetPos; }
calculateDistanceToFinalSnap
手順(3)のスクロール距離を返します。
LinearSnapHelperの場合は、RecyclerViewの中央とスナップ対象アイテムの中央との差を返すことになります。
@Override public int[] calculateDistanceToFinalSnap( @NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) { int[] out = new int[2]; ... (HORIZONTALの処理は省略) if (layoutManager.canScrollVertically()) { out[1] = distanceToCenter(layoutManager, targetView, getVerticalHelper(layoutManager)); } else { out[1] = 0; } return out; } ... private int distanceToCenter(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView, OrientationHelper helper) { final int childCenter = helper.getDecoratedStart(targetView) + (helper.getDecoratedMeasurement(targetView) / 2); final int containerCenter = helper.getStartAfterPadding() + helper.getTotalSpace() / 2; return childCenter - containerCenter; }
RecyclerViewへ関連付ける
アイテムのスナップはRecyclerViewへLinearSnapHelperを関連付ける(SnapHelper#attachToRecyclerViewメソッド)ことで動作します。
関連付けで行われていることは次の通りです。
public void attachToRecyclerView(@Nullable RecyclerView recyclerView) throws IllegalStateException { if (mRecyclerView == recyclerView) { return; // nothing to do } if (mRecyclerView != null) { destroyCallbacks(); } mRecyclerView = recyclerView; if (mRecyclerView != null) { setupCallbacks(); mGravityScroller = new Scroller(mRecyclerView.getContext(), new DecelerateInterpolator()); snapToTargetExistingView(); } } ... private void setupCallbacks() throws IllegalStateException { if (mRecyclerView.getOnFlingListener() != null) { throw new IllegalStateException("An instance of OnFlingListener already set."); } mRecyclerView.addOnScrollListener(mScrollListener); mRecyclerView.setOnFlingListener(this); }
RecyclerViewへOnScrollListenerの追加
手順(1)~(3)の実行タイミングを作っています。
RecyclerViewへOnFlingListenerの設定
RecyclerViewのフリング処理をSnapHelperのフリング処理へ置き換えています。
RecyclerViewはフリング処理を持っています。
フリングが発行されると、デフォルトのアニメーション(Interpolator )でスクロールを実行するという処理です。
デフォルトのアニメーションは後半に向かってスクロールの速度が落ちて行きます。
このアニメーションだと、手順(2)と(3)のスクロールの繋ぎが滑らかになりません。
これを回避するためにフリング処理を置き換えていると思われます。
ちなみに、上記のデフォルトのアニメーションはRecyclerViewの中で変更が禁止(final属性を持つ)されています。
RecyclerViewの中央へスナップ(自作クラス)
LinearSnapHelperはSnapHelper抽象クラスを継承して作成されています。
このSnapHelperは抽象度が大きいので、もう少し抽象度を小さくしたクラスを作成しました。
ベースの抽象クラス(LinearSnap)
抽象度を小さくしたので使用条件が限定されます。
LinearSnapはLinearLayoutManager限定で、スナップ先はRecyclerViewの中央のみです。
また、VERTICALとHORIZONTALの判定を組み込みました。実装時に記述が減らせます。
抽象メソッドはdistanceToCenterとfindCenterViewの2つです。
abstract class LinearSnap : SnapHelper() { // 最終的なスナップに必要なスクロール範囲と向きを返す override fun calculateDistanceToFinalSnap( manager: RecyclerView.LayoutManager, targetView: View ): IntArray { val _hDistance = if(manager.canScrollHorizontally()) { val _helper = OrientationHelper.createHorizontalHelper(manager) distanceToCenter(targetView, _helper) } else 0 val _vDistance = if(manager.canScrollVertically()) { val _helper = OrientationHelper.createVerticalHelper(manager) distanceToCenter(targetView, _helper) } else 0 return intArrayOf(_hDistance, _vDistance) } // 最終的なポジションを返す override fun findTargetSnapPosition( manager: RecyclerView.LayoutManager, velocityX: Int, velocityY: Int ): Int { if(manager !is RecyclerView.SmoothScroller.ScrollVectorProvider) return RecyclerView.NO_POSITION if(manager.itemCount == 0) return RecyclerView.NO_POSITION // 現在の表示におけるSnap対象のView(アイテム) val _currentView = findSnapView(manager) if(_currentView == null) return RecyclerView.NO_POSITION val _cureentPosition = manager.getPosition(_currentView) if(_cureentPosition == RecyclerView.NO_POSITION) return RecyclerView.NO_POSITION // 末尾へ向かう時のスクロールの向き(昇順であれば+) val _vectorForEnd = manager.computeScrollVectorForPosition(manager.itemCount) if(_vectorForEnd == null) return RecyclerView.NO_POSITION // フリングでScrollされるポジション数(実際よりも多くなる、理由不明) val _hDeltaJump = if(manager.canScrollHorizontally()) { val _deltajump = estimateNextPositionDiffForFling( manager, OrientationHelper.createHorizontalHelper(manager), velocityX, 0 ) if(_vectorForEnd.x < 0.0f) (- _deltajump) else _deltajump } else 0 val _vDeltaJump = if(manager.canScrollVertically()) { val _deltajump = estimateNextPositionDiffForFling( manager, OrientationHelper.createVerticalHelper(manager), 0, velocityY ) if(_vectorForEnd.y < 0.0f) (- _deltajump) else _deltajump } else 0 val _deltaJump = if(manager.canScrollVertically()) _vDeltaJump else _hDeltaJump if(_deltaJump == 0) return RecyclerView.NO_POSITION // フリング後にSnap対象になるポジション var _targetPos = _cureentPosition + _deltaJump _targetPos = if(_targetPos < 0) 0 else _targetPos _targetPos = if(_targetPos >= manager.itemCount) manager.itemCount - 1 else _targetPos return _targetPos } // スナップ対象のアイテム(のView)を選択し、返す override fun findSnapView(manager: RecyclerView.LayoutManager): View? { if(manager.canScrollHorizontally()) { val _view = findCenterView( manager, OrientationHelper.createHorizontalHelper(manager)) return _view } if(manager.canScrollVertically()) { val _view = findCenterView( manager, OrientationHelper.createVerticalHelper(manager)) return _view } return null } // ----- LinearSnapHelperのアルゴリズム見直し --------------------- // フリング(Fling)によりスクロールされるアイテム数を仮見積もりする // // 前提条件: // ・全てのアイテムViewは高さが等しい private fun estimateNextPositionDiffForFling( manager: RecyclerView.LayoutManager, helper: OrientationHelper, velocityX: Int, velocityY: Int ): Int { val _distances = calculateScrollDistance(velocityX, velocityY) // ↑ SnapHelperのメソッド ↑ val _view = manager.getChildAt(0) // 代表としてindex:0を使う val _distancePerChild = helper.getDecoratedMeasurement(_view) val _distance = if(abs(_distances[0]) > abs(_distances[1])) _distances[0] else _distances[1] return round(_distance.toDouble() / _distancePerChild.toDouble()) .toInt() } // ----- スナップ条件に併せて作成 --------------------------------- // 中央(スナップ先)までの距離と向きを返す. abstract fun distanceToCenter(targetView: View, helper: OrientationHelper) : Int // 中央(スナップ先)にあるアイテム(View)を返す. abstract fun findCenterView( manager: RecyclerView.LayoutManager, helper: OrientationHelper) : View? }
関連付け方法の改善
ベースの抽象クラスの作成に合わせて関連付け方法も改善します。
RecyclerViewに関連付けを行うメソッドを持たせるようにしました。Kotlinの拡張関数を用いています。
こちらの方が主役(RecyclerView)と脇役(SnapHelper)の関係が合っていると思います。
abstract class LinearSnap : SnapHelper() { ... } // ================================================================== // RecyclerView拡張関数 // ================================================================== fun RecyclerView.attachSnap(snap: SnapHelper) { snap.attachToRecyclerView(this) } fun RecyclerView.detachSnap(snap: SnapHelper) { snap.attachToRecyclerView(null) }
アイテムの中央を表示の中央へ(LinearSnap_CenterToCenter)
アイテムの中央をRecyclerViewの中央へスナップします。
LinearSnap抽象クラスを継承して作ります。
class LinearSnap_CenterToCenter : LinearSnap() { // 中央(スナップ先)までの距離と向きを返す.. override fun distanceToCenter( targetView: View, helper: OrientationHelper ): Int { val childCenter = helper.getDecoratedStart(targetView) + helper.getDecoratedMeasurement(targetView) / 2 val containerCenter = helper.startAfterPadding + helper.totalSpace / 2 return childCenter - containerCenter } // 中央(スナップ先)にあるアイテム(View)を返す. override fun findCenterView( manager: RecyclerView.LayoutManager, helper: OrientationHelper ): View? { val _parentCenter = helper.startAfterPadding + helper.totalSpace / 2 var _closestChild: View? = null var _minDistance = Int.MAX_VALUE for(i in 0 until manager.childCount) { val _view = manager.getChildAt(i) val _childCenter = helper.getDecoratedStart(_view) + helper.getDecoratedMeasurement(_view) / 2 val _distance = abs(_parentCenter - _childCenter) if(_minDistance > _distance) { _minDistance = _distance _closestChild = _view } } return _closestChild } }
... lateinit var rcySample: RecyclerView lateinit var snap: SnapHelper ... snap = LinearSnap_CenterToCenter() rcySample.attachSnap(snap) ...
アイテムの上辺を表示の中央へ(LinearSnap_StartToCenter)
アイテムの上辺をRecyclerViewの中央へスナップします。
LinearSnap抽象クラスを継承して作ります。
class LinearSnap_StartToCenter : LinearSnap() { // 中央(スナップ先)までの距離と向きを返す.. override fun distanceToCenter( targetView: View, helper: OrientationHelper ): Int { val childStart = helper.getDecoratedStart(targetView) val containerCenter = helper.startAfterPadding + helper.totalSpace / 2 return childStart - containerCenter } // 中央(スナップ先)にあるアイテム(View)を返す. override fun findCenterView( manager: RecyclerView.LayoutManager, helper: OrientationHelper ): View? { val _parentCenter = helper.getStartAfterPadding() + helper.getTotalSpace() / 2 var _closestChild: View? = null var _minDistance = Int.MAX_VALUE for(i in 0 until manager.childCount) { val _view = manager.getChildAt(i) val _childStart = helper.getDecoratedStart(_view) val _distance = abs(_parentCenter - _childStart) if(_minDistance > _distance) { _minDistance = _distance _closestChild = _view } } return _closestChild } }
... lateinit var rcySample: RecyclerView lateinit var snap: SnapHelper ... snap = LinearSnap_StartToCenter() rcySample.attachSnap(snap) ...
付録
OrientationHelper(サイズを取得するメソッド集)
SnapHelperやLinearSnapHelperのコード中にOrientationHelperクラスが頻繁に登場します。
OrientationHelperは各部のサイズを取得するメソッド集です。
下図のような値を取得するメソッドが用意されています。参考にして下さい。
関連記事: