ScrollRectを使わないUnityリサイクルスクロールの設計と実装

目次

はじめに

以前の記事で、サムザップの Unity 向けアプリケーション基盤「Spica」の全体像を紹介しました。今回はその中のひとつ、Spica.ReusableScroller にフォーカスし、設計判断と内部実装の詳細を掘り下げます。

ReusableScroller は、Unity の標準コンポーネントである ScrollRect や LayoutGroup を一切使わずに、セルのリサイクル機構を備えた高性能スクロールリストを実現するライブラリです。数千件のデータでも、画面に表示される分+αのセルインスタンスだけで快適にスクロールできます。

この記事では、なぜ標準コンポーネントを使わない選択をしたのか、その代わりにどのような仕組みを構築したのかを、実際のコードを交えて解説します。

ScrollRect を使わない理由

Unity で大量のデータをスクロール表示しようとしたとき、まず思い浮かぶのは ScrollRect と LayoutGroup の組み合わせです。しかし、この方法にはいくつかの課題があります。

標準コンポーネントの課題

ScrollRect + LayoutGroup で1,000件のリストを表示する場合、1,000個の GameObject が生成されます。画面に表示されるのが10個程度だとしても、残りの990個は見えない場所でメモリを消費し、ヒエラルキー上に存在し続けます。

さらに、LayoutGroup はフレームごとに子要素のレイアウトを再計算します。要素数が増えるほどこのコストは蓄積し、スクロール操作のフレームレートに直結します。

また、ScrollRect は縦横2軸のスクロールに対応した汎用コンポーネントです。1軸のリスト表示にはオーバースペックで、使わない軸の計算コストが無駄になります。

ReusableScroller のアプローチ

ReusableScroller では、以下の方針でこれらの課題を解消しました。

  • ScrollRect を使わず、1つのコンポーネントにスクロール機能を統合する。入力処理・物理挙動・セル管理をすべて自前で実装し、不要な処理を排除する。
  • LayoutGroup を使わず、セルの位置をデータロード時に事前計算する。フレームごとのレイアウト再計算が不要になる。
  • スクロール方向を1軸に固定する。縦または横の一方に限定し、計算をシンプルに保つ。
  • 画面外のセルをプールに回収し、再利用する。生成するインスタンス数を最小限に抑える。

全体のアーキテクチャ

ReusableScroller は3つの主要クラスで構成されています。

ReusableScroller アーキテクチャ概要

ScrollView ― 制御の中心

ScrollView は ReusableScroller の中核コンポーネントです。入力の受け付け、スクロール位置の管理、セルのリサイクル、スナップやループといった機能をすべて担います。

ScrollCellData ― セルのデータ

ScrollCellData はセルのデータを表す抽象クラスです。利用者はこのクラスを継承して、表示に必要なデータを定義します。

public abstract class ScrollCellData
{
    public virtual int PrefabIndex { get; set; }

    public virtual float Size
    {
        get => SizeInternal ?? 0f;
        set => SizeInternal = value;
    }
}

PrefabIndex は使用するプレハブの種類を、Size はスクロール方向のセルサイズを指定します。Size が未設定の場合はプレハブの RectTransform から自動取得されるため、固定サイズのリストであればサイズ指定は不要です。

利用側のコードは、たとえば以下のようになります。

public class ItemCellData : ScrollCellData
{
    public string Name { get; set; }
    public Sprite Icon { get; set; }
}

ScrollCell ― セルのビュー

ScrollCell<T> はセルの表示を担う抽象クラスです。テンプレートメソッドパターンにより、ライフサイクルの各タイミングで利用者のコードが呼び出されます。

public abstract class ScrollCell<T> : ScrollCell where T : ScrollCellData
{
    public T CellData => _cellData;

    public virtual void OnActivate() { }
    public virtual void OnDeactivate() { }
}
メソッド 呼ばれるタイミング
OnActivate セルが画面に表示されるとき(新規生成時・再利用時の両方)
OnDeactivate セルが画面外に出てプールに回収されるとき
OnRefresh RefreshActiveCells() による明示的な更新時

利用側は OnActivate でデータを表示し、OnDeactivate でクリーンアップします。

public class ItemCell : ScrollCell<ItemCellData>
{
    [SerializeField] private Text _nameText;
    [SerializeField] private Image _iconImage;

    public override void OnActivate()
    {
        _nameText.text = CellData.Name;
        _iconImage.sprite = CellData.Icon;
    }
}

データとビューがこのように分離されているため、ScrollView はビューの詳細を知らずにセルの生成・再利用・配置を管理できます。

初期化処理

利用側では、セルデータの配列を作成して ScrollView.Initialize を呼ぶだけでスクロールリストが構築されます。

[SerializeField]
private ScrollView _scrollView;

[SerializeField]
private SampleCellData[] _cellData;

private void Start()
{
    _scrollView.Initialize(_cellData);
}

Initialize に渡されたデータをもとに、セルサイズの事前計算・初期表示範囲のセル生成が自動的に行われます。

セルリサイクルの仕組み

ReusableScroller の性能を支えるのがセルリサイクルの仕組みです。画面に表示されるセルだけを生成し、画面外に出たセルはプールに回収して再利用します。

セルリサイクルのフロー

2つのリストによる管理

ScrollView は内部に _activeCells(画面に表示中のセル)と _recycledCells(プールに回収されたセル)の2つのリストを持っています。

複数プレハブのスクロール例

スクロールによって新しいセルが必要になると、まずプールから同じ PrefabIndex を持つセルを探します。見つかればそれを再利用し、見つからなければ Instantiate で新規生成します。どちらの場合も最終的に利用者の OnActivate が呼ばれるため、利用側は新規生成か再利用かを意識する必要がありません。

画面外に出たセルは SetActive(false) で非表示にしてプールに戻します。SetParent による親変更ではなく SetActive で管理しているのは、Transform の再計算コストを避けるためです。

スライディングウィンドウ方式の差分更新

スクロール位置が変わると、表示すべきセルの範囲も変化します。この更新はスライディングウィンドウ方式で効率的に処理されます。

  1. 現在表示中のセルのうち、新しい範囲から外れたものをリサイクル
  2. 残ったセルのインデックスを記録
  3. 残ったセルの前方・後方に不足分を追加

通常のスクロールでは、範囲の前後1〜2個が入れ替わるだけなので、ほとんどのセルはそのまま残ります。完全に別の位置にジャンプした場合は全セルを入れ替えますが、その場合もプールからの再利用が優先されるため、Instantiate の回数は最小限に抑えられます。

事前計算レイアウトと二分探索

LayoutGroup を使わない代わりに、ReusableScroller ではセルのサイズと位置をデータロード時に一度だけ計算します。

事前計算

データロード時に、各セルのサイズ(ScrollCellData.Size または プレハブの RectTransform から取得)とスペーシングを _cellSizes 配列に格納し、それを先頭から累積加算して _cellPositions 配列を構築します。

_cellPositions[i] を参照するだけでセル i の先頭位置がわかるため、フレームごとのレイアウト再計算は不要です。

二分探索によるインデックス検索

「スクロール位置 X にあるセルは何番目か?」という問いを高速に解くため、二分探索を使用しています。_cellPositions がソート済みであることを利用し、中間セルの終端位置と検索位置を比較して探索範囲を半分ずつ絞り込みます。

セルの位置は累積的に増加するため、この配列は常にソート済みです。計算量は O(log n) で、10,000件のリストでも約13回の比較でインデックスが確定します。

この二分探索は、可視範囲の判定(CalculateCurrentActiveCellRange)とスナップ先の決定(GetCellIndexAtClosestPosition)の両方で使われています。

スクロール物理の自前実装

ScrollRect を使わないということは、ドラッグ入力の処理やスクロールの慣性・弾性もすべて自前で実装する必要があります。

入力インタフェースの実装

ScrollView は uGUI の IBeginDragHandlerIDragHandlerIEndDragHandlerIScrollHandler などのイベントインタフェースを直接実装しています。OnBeginDrag でドラッグ開始位置を記録し、OnDrag でポインタの移動量からコンテナの位置を計算、OnEndDrag でスナップ処理を開始します。

LateUpdate による物理挙動

スクロールの慣性や弾性は LateUpdate で毎フレーム処理されます。主な挙動は以下のとおりです。

  • 慣性スクロール: ドラッグ終了後、_velocityMathf.Pow(_decelerationRate, deltaTime) を毎フレーム掛けて減速させる。deltaTime を指数に使うことでフレームレート非依存の減速を実現している。
  • 弾性(Elastic): コンテンツが端を超えた場合、Mathf.SmoothDamp でバウンドバック位置へ滑らかに戻す。_elasticity がバネの硬さを制御する。
  • ドラッグ中の速度追跡: 前フレームとの位置差分から速度を計算し、Lerp で平滑化する。指を離した瞬間の速度が慣性スクロールの初速となる。
  • ラバーバンド効果: 端を超えたドラッグ量を非線形に抑制する。引っ張るほど抵抗が増す、スマートフォンでおなじみの挙動である。

座標系の吸収

内部ではスクロール方向を正の値で統一的に扱い、ScrollToAnchored で RectTransform の座標系(横スクロールは負方向)に変換しています。座標系の差異を1箇所で吸収することで、他のロジックはスクロール方向を意識せずに書けます。

ループスクロールの実装

先頭と末尾がつながった無限スクロールは、カルーセルUIなどで頻繁に使われる機能です。ReusableScroller ではデータの複製とスクロール位置のワープを組み合わせてこれを実現しています。

ループスクロールの仕組み

ループスクロールの例

データの複製戦略

ループモードを有効にすると、セルサイズの配列が複数回複製されます。複製回数は偶数に調整され、全体で奇数グループ(例:オリジナル+前後に各2複製 = 5グループ)になります。これにより中央のグループが一意に定まり、ジャンプ先の基準として使えます。

セルが少ない場合は、スクロールビューの3画面分以上のサイズが確保されるよう追加で複製します。これはドラッグ中にコンテンツの端が見えてしまうことを防ぐためです。

ジャンプトリガーによるワープ

スクロール位置が中央グループから1画面分以上離れると、位置をワープして中央グループに戻します。同じデータが複数回複製されているため、ジャンプ先にも同じデータが配置されており、見た目には何も起きていないように見えます。

ワープ時にはスクロール位置だけでなく、ドラッグ開始時のコンテナ位置や前フレームの位置もまとめて補正します。これにより、慣性計算やドラッグ量の計算がワープの前後で正しく継続され、ユーザーはジャンプが起きたことを一切感じません。

スナップ

スクロール終了時に最も近いセルへぴたりと止まるスナップ機能は、カードリストやタブUIで重要な UX です。ReusableScroller は2種類のスナップを提供しています。

ドラッグスナップ

ドラッグスナップの例

ドラッグ終了時にスナップする方式です。OnEndDrag でドラッグ開始位置と終了位置を比較し、移動方向と移動距離からスナップ先を決定します。移動距離が _snapThresholdDistance 未満であれば元の位置に戻り、それ以上であれば移動方向の次のセルにスナップします。

速度ベーススナップ

速度ベーススナップの例

フリック操作の慣性がスナップのきっかけになる方式です。LateUpdate 内で速度が _velocitySnapThreshold 以下になった瞬間、二分探索で最も近いセルを特定し、そのセルの位置へ向けてTweenアニメーションを開始します。

イージングアニメーション

スナップや Focus で使用されるスクロールアニメーションは Tween.Interpolate で制御されます。Linear、Quad、Cubic、Bounce、Elastic など33種類のイージング関数が用意されており、_snapEaseType でスナップ時の動きを設定できます。

Tween の実行は UniTask が利用可能な環境では async/await、そうでない場合はコルーチンで行われます。Time.unscaledDeltaTime を使用しているため、Time.timeScale の影響を受けず、ポーズ中でもスクロールアニメーションが正しく動作します。

おわりに

この記事では、Spica.ReusableScroller の設計と実装の詳細を紹介しました。

  • ScrollRect / LayoutGroup を使わず、入力・物理・レイアウト・セル管理を1つのコンポーネントに統合
  • セルリサイクル により、数千件のデータでも生成インスタンスは画面表示分+αだけ
  • 事前計算レイアウト + 二分探索 で、フレームごとのレイアウト再計算を排除し O(log n) でインデックスを特定
  • ループスクロール はデータ複製とシームレスなワープで実現
  • スナップ はドラッグベースと速度ベースの2方式を提供

Unity 標準の汎用コンポーネントに頼らず、スクロールリストに必要な処理だけを自前で実装することで、パフォーマンスと柔軟性の両立を実現しています。大量データのスクロール表示で課題を感じている方の参考になれば幸いです。


s07452
中北 龍秀

株式会社サムザップ クライアントエンジニア 2019年入社
『呪術廻戦 ファントムパレード』や『真 戦国炎舞 -KIZNA-』の開発を経て
現在は開発推進室のアプリケーション基盤と新規プロジェクトの開発を担当