Edited at

[Unity uGUI] スクロールビューの基本と軽量なスクロールリストビューの作り方


はじめに

この記事では、アプリ開発において必ずと言っていいほど現れるスクロールビューの実装について、基本を整理しながら解説していきます。

スクロールビューは登場の頻度が高い割に実装で躓くポイントが多く、気を付けて実装しないと著しくパフォーマンスが劣化し、UXが非常に悪くなってしまうというなんとも厄介な存在です。

逆に言えば、スクロールビューを制するものはUI開発を制すると言っても過言ではないかもしれません。いや、流石に過言かもしれません。

ともあれ、そんなスクロールビューと少しでも仲良くなりたいという人のためにお役に立てれば幸いです。


uGUIのスクロールビューの基本

まず初めに、uGUIの標準のスクロールビューの仕組みについて整理しておきます。

ヒエラルキーを右クリックし、コンテキストメニューからUI -> Scroll Viewを選択してスクロールビューを配置します。

大体こんな感じのオブジェクトが生成され、既にスクロールビューとして動く状態になっています。

これを見ながら、どのような仕組みで動いているかを解説していきます。


Scroll Rectコンポーネント

親要素であるScroll Viewオブジェクトに付いているScroll Rectコンポーネントがスクロールビューの要です。

Scroll Rectが担当する役割は以下。


  • ユーザーの入力を受け取ってオブジェクトを移動させる

  • コンテンツのサイズに合わせてスクロールバーの大きさを調整する

  • スクロール位置に合わせてスクロールバーを追従させる

MovementTypeの設定によって以下のように挙動が変わります。


  • Unrestricted:コンテンツ領域外へのスクロールを許可する


    • スクロール位置がコンテンツ領域外になっても何も起こらない



  • Elastic:スクロール位置を自動的にコンテンツ領域内に収める(デフォルト)


    • スクロール位置がコンテンツ領域外になった場合、自動的にスクロール位置が調整される

    • Elasticityで補正の強さ(早さ)を調整できる



  • Clamped:コンテンツ領域外へのスクロールを禁止する


    • スクロールビュー内の要素をドラッグしてもコンテンツ領域外までスクロールすることができなくなる




ViewportとContent

Scroll RectはViewportContentという2つのRectTransformを持ちます。

それぞれの役割は以下。


  • Viewport:スクロールビューの描画範囲を表す

  • Content:スクロールビューの中のコンテンツ部分

ViewportとContentは以下のような構造になっています。

ViewportはMaskコンポーネントが付けられており、これによってViewport外の子要素を隠す役割を果たしています。

また、ViewportのサイズはScrollRectのオブジェクトのサイズに自動的に追従します。そのため、描画範囲を拡大・縮小したい場合はScrollRect側のサイズを変更する必要があります。

ちなみにデフォルトではMaskコンポーネントが使われていますが、短形にマスクを掛けるだけなら、より軽量なRectMask2Dを使うほうが良さそうです。

参考:【Unity】パフォーマンスが良いらしいUIのマスク、RectMask2Dについて - テラシュールブログ

ContentはViewportの子要素になっており、ScrollRectコンポーネントによってpositionをコントロールされます。ユーザー入力に合わせてViewportとの相対位置を調整することによってコンテンツが移動し、スクロール動作が実現されています。

また、Contentの位置、サイズによってスクロールバーの位置、サイズが決まります。Contentのサイズを大きくすると、その分スクロールバーのつかみの部分が小さくなります。


Scrollbarコンポーネント

Scroll RectはHorizontal、Verticalの2つのScrollbarを持ちます。

デフォルトではHorizontal、Verticalの両方が設定されていますが、どちらか一方向のみのスクロールビューであれば、使わない側のScrollbarは削除しても構いません。(スクロールバー自体要らないなら両方削除しても構わない)

Scrollbarのvalueの値がつかみの部分(Handle)の位置に対応していて、0〜1のfloatで表されます。

DirectionがLeft To Rightの時は0が左端、1が右端、というような対応関係になります。

Number Of Stepsを2以上にすると、スクロール位置が段階的になり、強制的に指定したステップ数に分割された位置に合わせられます。

BackGroundやHandleのSpriteを変更することで見た目をカスタマイズすることができます。


EventSystem

スクロールビューに限らずですが、uGUIのユーザー入力イベントはEventSystemによって制御されています。

コンテキストメニューからuGUIオブジェクトを追加した場合には自動的に必要なコンポーネントが配置されるようになっていますが、Emptyオブジェクトから自分でAddComponentしていく場合、何らかのコンポーネントの付け忘れなどによってうまく入力が検知できない事があります。

入力が検知できない場合、よくある原因としては以下のようなものが考えられます。


  • シーン内にEventSystemコンポーネントが存在しない

  • CanvasにGraphicRaycasterコンポーネントが付いていない

  • Imageコンポーネントの「Raycast Target」がfalseになっている


高速なスクロールリストビューの作り方

簡単なスクロールリストビューなら、上記のスクロールビューとuGUIの各種コンポーネント(LayoutGroupやContentSizeFitter)をうまく利用することによってほとんどコードを書かずに実現することができるのですが、 愚直に作るとシーン内にリストの要素分GameObjectが生成されてしまい、要素数が数百、数千となってくるとパフォーマンスに顕著な影響が出てしまいます。

これを解決するためのアイデアとして、描画範囲内に必要なオブジェクトだけ生成し、スクロール時には同じオブジェクトを使いまわすという方法があります。

まあまあ使い古された実装で、少し調べれば色んな実装例が見つかります。また、汎用的に使えるらしい有料アセットも存在します。

できるならこういった有り物の実装を使ってしまうのが楽で良いのですが、アプリの設計、要件上どうしても自前で実装しなければならないという場面も往々にして存在します。

ここでは、uGUIのコンポーネントを利用しつつ高速なスクロールリストビューを作るにはどうすればいいのか?ということを整理していきます。


コンテンツ領域のサイズ計算

事前に全てのオブジェクトを生成してしまえば、LayoutGroupによって自動的に等間隔に配置し、ContentSizeFitterで自動的にコンテンツ領域のサイズが調整できますが、オブジェクトを生成しない場合は自前でRectTransformのサイズを調整する必要があります。

ここで、リストビューを構成する各要素について考えてみます。

一般的なリストビューは大体こんな感じで、要素がいくつかあって等間隔に配置されており、さらにその外側にいくらかの余白がある、という風になっているでしょう。

ここで、要素数をitemCountとすると、コンテンツ領域のサイズは以下で求められます。

margin * 2 + itemSize * itemCount + space * (itemCount - 1)

つまり、コンテンツ領域のサイズを計算するには、以下の要素を知る必要があります。

SerializeFieldで入力できるようにしたり、外部から設定できる形になるでしょう。


  • margin:余白のサイズ

  • itemSize:子要素のサイズ

  • itemCount:リスト要素の数

  • space:要素ごとの間隔

さらに、異なるサイズのオブジェクトを並べたい場合には、サイズの情報を持ったリストが必要になるでしょう。

大体こんな感じの雰囲気になると思います。

public interface IListViewItem

{
float ItemSize { get; }
}

List<IListViewItem> items;

public float GetContentSize()
{
margin * 2 + items.Sum(item => item.ItemSize) + space * (itemCount - 1);
}


描画範囲内に含まれるリスト要素を取得する

描画範囲内のみ要素を描画するために、描画範囲内に含まれるリスト要素を推測する必要があります。

ここで、スクロールリストビューの各要素を整理してみます。

この図の場合、リスト要素の1〜4までが描画範囲内に含まれていることになります。

前項までで、以下の要素については既知(または計算可能)です。


  • margin:余白のサイズ

  • itemSize:子要素のサイズ

  • itemCount:リスト要素の数

  • space:要素ごとの間隔

  • Content area width (height):コンテンツ領域のサイズ

あとはViewportのサイズとスクロール量が取得できれば推測できそうです。


Viewportのサイズの取得

Viewportのサイズは、ViewportのRectTransformから取得することができます。

ViewportのサイズはScrollRectによってコントロールされているため、sizeDeltaではなくrectから取得します。

[SerializeField]

private ScrollRect scrollRect;

var viewportWidth = scrollRect.viewport.rect.width;
var viewportHeight = scrollRect.viewport.rect.height;


スクロールされた量の取得

Scrolled width(スクロールされた量)はScrollRect.normalizedPositionとContent area width、Viewport widthから計算が可能です。

結論から言うと、以下の計算式になります。

(contentAreaWidth - viewportWidth) * normalizedPosition.x

(※ 水平方向の場合)

なんでこうなるかと言うと、ScrollRect.normalizedPositionの表す数値は以下のようになっています。

図で見ると分かりますが、scroll positionが0の時はスクロール量は0、1の時は(ContentAreaWidth - ViewPortWidth)になります。scroll positionを変数として1次関数的に変動するので、上記の数式で計算できることが分かります。

ちなみに、ScrollRectのMovementTypeがClamped以外の場合、normalizedPositionは0未満(負の値)や1より大きい値になる可能性もあります


描画範囲内に含まれるリスト要素の取得

ここまで来てようやくリスト要素の取得ができます。

改めて図を見てみます。

範囲内に含まれるリスト要素の最初のIndexと、範囲内に含まれる可能性のあるリスト要素の最大個数を取得すれば、描画する必要のあるリスト要素が把握できそうです。

細かい説明は省きますが、以下で取得できます。

// NOTE: 厳密には0未満や総アイテム数より大きくなったりしないようにする必要がある

var startIndex = (int)Math.Truncate( (scrolledWidth - margin) / (itemSize + space) );
var maxVisibleItemCount = (int)Math.Ceiling(ViewPortSize / (_itemSize + _itemPadding)) + 2;

実際には最初のIndexと最後のIndexさえ取得すればいいのですが、リスト要素を再利用する都合上、描画する要素の個数は常に一定のほうが実装がシンプルになるため、最大個数を取得しています。


リスト要素の位置の取得

範囲内のリスト要素が把握できたら、描画するために要素の位置を取得する必要があります。

リスト要素はコンテンツ領域の子要素になるので、コンテンツ領域の左端(上端)からの相対位置を取得し、localPositionに反映してやればOKです。

以下で計算できます。

var position = margin + (itemSize + space) * itemIndex;

ポイントとしては、リスト要素のprefabのルートオブジェクトのRectTransformのpivotを(x: 0, y: 0)にしておくことです。そうすることでlocalPositionの基準位置がリスト要素の左上端になり、上記のようにシンプルな計算で済むようになります。

pivotが0でない場合を考慮すると以下になります。

var position = margin + (itemSize + space) * itemIndex - (itemSize * itemRectPivot);


リスト要素の生成

リスト要素は最初に範囲内に含まれる可能性のあるリスト要素の最大個数分だけ生成してキャッシュしておきます。

生成処理は外部に委譲します。

[SerializeField]

private RectTransform _contentArea;

public delegate MonoBehaviour InstantiateItemViewDelegate();
private InstantiateItemViewDelegate _instantiateItemViewDelegate;

private int _maxVisibleItemCount;

/// <summary>
/// 生成済みのコンポーネントをキャッシュするリスト
/// </summary>
private LinkedList<MonoBehaviour> _itemViews = new LinkedList<MonoBehaviour>();

/// <summary>
/// maxVisibleItemCountの数だけitemViewを生成
/// </summary>
private void InstantiateItemViews()
{
// maxVisibleItemCountの数だけitemViewを生成
for (var i = _itemViews.Count; i < _maxVisibleItemCount; i++)
{
var itemView = _instantiateItemViewDelegate();
itemView.transform.SetParent(_contentArea, false);
_itemViews.AddLast(itemView);
}
}


リスト要素の再利用

軽量リストビューの要となるリスト要素の再利用について考えます。

リスト要素を再利用するにあたっては以下の情報が必要になります。


  • 描画済みの要素(のIndex)とGameObjectの対応関係

  • 描画範囲外となった要素(のIndex)

  • 新しく描画範囲内に入ってきた要素(のIndex)

  • 要素の更新処理

要素の更新処理は外部に委譲できる形にします。

public delegate void UpdateItemViewDelegate(int itemIndex, MonoBehaviour component);

ここでポイントとしては、スクロールビュー側ではMonoBehaviourをキャッシュして更新時にそれを渡す形になっていますが、更新時に毎回GetComponentするのはコストが高いため、リスト要素の生成時にはコンポーネントをMonoBehaviourにアップキャストしてキャッシュしておき、更新時にはダウンキャストして用います。

/// <summary>

/// Viewを生成する(InstantiateItemViewDelegate)
/// </summary>
private MonoBehaviour InstantiateItem()
{
CardView view = CardView.Load();

return view;
}

/// <summary>
/// ViewをUpdateする(UpdateItemViewDelegate)
/// </summary>
private void UpdateItemView(int itemIndex, MonoBehaviour component)
{
var view = component as CardView;
view.SetCard(_cards[itemIndex]);
}

interface化すればもっと綺麗になりそうですがまだやっていません(やったほうがいいと思います)

要素の再利用の回し方については色んな実装方法が考えられますが、テラシュールブログさんの参考実装でも使われているLinkedListを使った実装を考えてみます。

LinkedListはリストの先頭・末尾への挿入・削除が高速であるという特徴を持つジェネリックコレクションです。

参考:双方向連結リスト - アルゴリズムとデータ構造 | ++C++; // 未確認飛行 C

なぜLinkedListが有用なのか?というと、リスト要素を再利用する時は必ず先頭の要素を末尾に入れ替え(またはその逆)になるため、LinkedListの特性にマッチしていると言えます。

ちょっと説明が長くなりそうなので、とりあえずコードを貼っておきます。なんとなく雰囲気は感じ取って貰えるかなと思います。余裕があればそのうち詳しい解説を追記します。

/// <summary>

/// 生成済みのコンポーネントをキャッシュするリスト
/// </summary>
private LinkedList<MonoBehaviour> _itemViews = new LinkedList<MonoBehaviour>();

/// <summary>
/// スクロール位置に合わせてアイテムの描画を行う
/// </summary>
/// <param name="scrollPosition">現在のスクロール位置</param>
/// <param name="forceRepaint">全ての要素を強制的に再描画する</param>
private void UpdateItemViews(Vector2 scrollPosition, bool forceRepaint)
{
if (!forceRepaint && _totalItemCount == 0) return;
var startIndex = GetStartIndex(scrollPosition);
var itemIndex = 0;
if (forceRepaint)
{
itemIndex = startIndex;
while (itemIndex < startIndex + _maxVisibleItemCount)
{
var itemView = _itemViews.First();
_itemViews.RemoveFirst();
UpdateItemView(itemIndex, itemView);
_itemViews.AddLast(itemView);
itemIndex++;
}
_renderedStartIndex = startIndex;
return;
}

if (startIndex > _renderedStartIndex)
{
itemIndex = Math.Max(startIndex, _renderedStartIndex + _maxVisibleItemCount);
while (itemIndex < startIndex + _maxVisibleItemCount)
{
var itemView = _itemViews.First();
_itemViews.RemoveFirst();
UpdateItemView(itemIndex, itemView);
_itemViews.AddLast(itemView);
itemIndex++;
}
}
else if (itemIndex < _renderedStartIndex)
{
itemIndex = Math.Min(_renderedStartIndex + _maxVisibleItemCount - 1, _renderedStartIndex - 1);
while (itemIndex >= startIndex)
{
var itemView = _itemViews.Last();
_itemViews.RemoveLast();
UpdateItemView(itemIndex, itemView);
_itemViews.AddFirst(itemView);
itemIndex--;
}
}
_renderedStartIndex = startIndex;
}


再描画のトリガー

スクロール位置の変動に合わせて再描画が必要になるので、ScrollRectのonValueChangedに紐つけるのが良いです。onValueChangedイベントでは変動後のScrollRect.normalizedPositionが通知されます。

スクロール位置が変わらなければ再描画の必要が無いため、Updateで毎フレーム行う必要はありません。


おわりに

毎回スクロールビューの実装で色々悩んだりするので、自分のためにもまとめてみました。参考になれば幸いです。

もっと効率の良い・使いやすい実装案があればぜひ教えてください!(描画部分はもっと色々効率化できそうな気がしている)

また、次はMVPパターンによる軽量スクロールリストビューの設計について考えてみる記事を書く予定です。