13 人が閲覧(直近 30 日) シミュレーション

UnityのScrollViewの使い方|UI ToolkitとuGUI Scroll Rectの作り方・縦横スクロール・動かない原因

UnityのScrollViewの使い方|UI ToolkitとuGUI Scroll Rectの作り方・縦横スクロール・動かない原因

UI ToolkitのScrollViewは、表示領域より大きい要素群をスクロールで見せるためのコンテナです。UI Builderで置くだけで動く一方、「スクロールバーが出ない」「追加した直後のScrollToが効かない」「マウスでつかんでもスクロールしない」といった挙動は、内部構造と入力処理を知らないと原因が見えにくい部分です。

この記事では、Unity 6.3 LTS(6000.3)の公開C#ソース(UnityCsReferenceのScrollView.cs、6000.3.27f1時点)と公式マニュアルのScrollViewのページで確かめた挙動をもとに、作り方・設定・動的追加・つまずきの原因・ListViewとの使い分けを説明します。Canvasで作るuGUIの「Scroll View」(Scroll Rectコンポーネント)は別の部品なので、作り方とスクロールしないときの確認項目を独立した章にまとめました。

まとめ:UnityのScrollViewの要点

  • UI Builder・UXMLで画面を作っているならUI ToolkitのScrollView、Canvasで作っているならuGUIのScroll View(Scroll Rect)。名前は同じでも設定方法は別物
  • uGUIは、ContentにVertical Layout GroupとContent Size Fitter(Vertical Fit=Preferred Size)を付け、行の数に合わせてContentを伸ばすのが基本形
  • 子要素はcontentContainer(#unity-content-container)に入り、外側のcontentViewportが切り抜く。スクロールできる量は「中身の高さ − ビューポートの高さ」で決まる
  • スクロール方向はmode(Vertical/Horizontal/VerticalAndHorizontal)、スクロールバーの表示は*-scroller-visibility(Auto/AlwaysVisible/Hidden)で指定する
  • スクロールしないときは、まずScrollView自体の高さが中身に合わせて伸びていないか確認する。高さかflex-grow: 1で枠を固定する
  • マウスドラッグでのスクロールはソース上で明示的に除外されている。必要ならPointerイベントで自作する
  • 追加直後のScrollToは、6.3でレイアウト確定まで自動で再試行されるようになった。6.0〜6.2では子要素のGeometryChangedEventを待ってから呼ぶ
  • view-data-keyによるスクロール位置の保持はEditor UI専用。ゲーム画面ではscrollOffsetを自分で保存する
  • 同じ形の行が件数不定で並ぶならListView、行ごとに構造が違う画面や横並び・グリッドならScrollView

以下、構造の確認から順に、設定・実装・トラブル対応へ進みます。

ScrollViewの内部構造とuGUIのScroll Viewとの違い

new ScrollView()を実行すると、内部では次の階層が組み立てられます。名前はUSSセレクタやUI Toolkit Debuggerでそのまま使えます。

要素名 C#からの参照 役割
unity-content-and-vertical-scroll-container (非公開) ビューポートと縦バーを横に並べる
unity-content-viewport contentViewport 表示枠。はみ出しを切り抜く
unity-content-container contentContainer Add()した子の入れ先
縦・横のScroller verticalScroller / horizontalScroller スクロールバー本体

ソースでは、スクロールできる高さをcontentContainer.boundingBox.height - contentViewport.layout.heightで計算しています。表示設定がAutoの場合、中身がビューポートに収まっていればスクロールバーは表示されません。AlwaysVisibleなら中身の大きさにかかわらず表示されます。ビューポートはpickingMode = Ignoreで、ポインタの直接のヒット対象にはなりません。

uGUIのScroll Rectとの比較表

検索で「unity scrollview」と打つと、上位にはuGUIのScroll Viewの解説が多く並びます。両者は部品の組み方から違います。

項目 UI Toolkit ScrollView uGUI Scroll Rect
作り方 UXML/C#の1要素 GameObject+Viewport+Content
スクロールバー 内蔵(表示だけ切替) Scrollbarを別に参照させる
中身のサイズ Flexboxで自動計算 Content のRectTransformで決める
マウスでのドラッグ 不可(自作が必要) 可能(左ボタンのみ)
端での挙動の名前 touch-scroll-type Movement Type
大量の行 ListViewで行を使い回す 標準の使い回し機能なし

端での挙動は両方ともUnrestricted/Elastic/Clampedの3値で、名前が共通です。UI Toolkit側の既定はClampedです。touch-scroll-typeは名前に反してタッチ専用ではなく、内部のスクロールバーが範囲で値を止めるかどうかも切り替えるため、ホイール操作にも効きます。慣性の減速率の既定値は、uGUIのScrollRectのAPIリファレンスにあるdecelerationRateもUI Toolkitのscroll-deceleration-rateも0.135です。ただし、入力処理や端での挙動が異なるため、この値が同じでも移行後の操作感が一致するとは限りません。

uGUIのScroll View(Scroll Rect)で縦リストを作る手順

Canvasで画面を作っている場合は、uGUI(Unity 6ではUnity UIパッケージcom.unity.ugui 2.0)のScroll Viewを使います。ここでは公式マニュアルのScroll Rectのページの構成に沿って、行が縦に並ぶリストを作ります。

メニューから追加したScroll Viewの階層

Hierarchyで「GameObject > UI (Canvas) > Scroll View」を選ぶと、次の階層が一式作られます。シーンにCanvasが無ければ、CanvasとEventSystemも一緒に作られます。

GameObject 主なコンポーネント 役割
Scroll View Scroll Rect/Image 入力を受けてContentを動かす
Viewport Mask/Image 表示枠。はみ出しを切り抜く
Content Rect Transform 行の親。この大きさがスクロール量
Scrollbar Horizontal/Vertical Scrollbar Scroll Rectから参照されるバー

行はすべてContentの子に入れ、ContentはViewportの子に置きます。Content欄にはスクロール対象のRect Transformを指定します。この構成ではViewport欄にもViewportを指定しますが、未指定の場合はScroll Rect自身のRect Transformが表示領域として使われます。スクロールバーをAuto Hideにしても、隠れるのはPlay Modeだけで、Edit Modeでは常に表示されるとマニュアルに書かれています。

Contentの自動伸長:Vertical Layout GroupとContent Size Fitterの設定

メニューで作った直後のContentは固定の高さなので、行を足してもスクロール量が増えません。行の数に合わせてContentが伸びるように、次の順で設定します。

  1. 縦だけに動かすなら、Scroll RectのHorizontalのチェックを外す。横のバーが不要なら、Scrollbar Horizontalを削除する
  2. ContentのアンカーとピボットをViewportの上端にそろえる(Anchor Presetsで上端の横stretch、Pivot Yを1)
  3. ContentにVertical Layout Groupを追加し、Spacingで行の間隔、Control Child SizeとChild Force Expandで行の幅・高さの扱いを決める
  4. ContentにContent Size Fitterを追加し、Vertical FitをPreferred Sizeにする
  5. 行にするPrefab(ButtonやTextMeshProのテキスト)をContentの子に並べる

2を飛ばすと失敗します。Content Size Fitterのマニュアルは「Rect Transformの伸縮はピボットを中心に行われる」と説明しており、ピボットが中央のままだとContentが上下両方へ伸びて、先頭の行がViewportの上にはみ出します。横に流すならHorizontal Layout GroupとHorizontal Fit、複数行・複数列のグリッドならGrid Layout Groupを使い、Scroll Rectの縦横のチェックを向きに合わせます。

uGUIのC#実装:行の追加と最下部へのスクロール

チャットやログ向けに、ボタンで行を追加して最下部へ送る例です。UguiItemList.csをシーン内のGameObjectに付け、InspectorでScroll Rectと、TMP_Textを含む有効な行Prefabを割り当てます。追加ボタンのOn Clickには、このコンポーネントのAddItemを登録してください。

using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class UguiItemList : MonoBehaviour
{
    [SerializeField] ScrollRect m_ScrollRect;
    [SerializeField] RectTransform m_RowPrefab;
    int m_Count;

    public void AddItem()
    {
        var row = Instantiate(m_RowPrefab, m_ScrollRect.content);
        row.GetComponentInChildren<TMP_Text>().text = $"Item {++m_Count}";

        // Content Size Fitterによる高さの再計算を先に済ませる
        Canvas.ForceUpdateCanvases();
        m_ScrollRect.verticalNormalizedPosition = 0f;
    }
}

verticalNormalizedPositionは0から1の値で、APIリファレンスの定義では0が下端、1が上端です。UI ToolkitのscrollOffset(上端からのピクセル数)とは向きも単位も違うので、移植するときに取り違えやすい箇所です。行を足した直後はまだContent Size FitterがContentの高さを計算し直していないため、Canvas.ForceUpdateCanvases()でレイアウトを確定させてから代入します。

uGUIのScroll Viewがスクロールしないときの確認項目

  1. ContentがViewportより短い:Content Size Fitterが無いか、Vertical FitがUnconstrainedのまま。SceneビューでContentの枠を選び、Viewportより長くなっているかを見る
  2. EventSystemが無い、または入力モジュールが合っていない:Player設定のActive Input HandlingをInput System Packageだけにしたプロジェクトで、EventSystemに旧来のStandalone Input Moduleが残っていると操作が届かない。Input Systemのマニュアルによると、この状態ではInspectorにInput System UI Input Moduleへ置き換えるボタンが表示される
  3. Scroll Rectの参照が外れている:Content欄が空、または意図したContentを参照していない。別のViewportを使う構成では、その参照とContentの親子関係も確認する
  4. 縦横のチェックが向きと合っていない:Verticalのチェックを外したまま縦に動かそうとしている
  5. Movement TypeがUnrestricted:マニュアルは、範囲の制限が無いため中身の制御を取り戻せなくなることがあると注意している。一覧ならClampedかElasticにする

行が数百を超える一覧も注意が必要です。uGUIのScroll Rectには画面外の行を使い回す仕組みが標準で無く、行の数だけGameObjectが残ります。UI Toolkitに移せるなら後述のListView、uGUIのままならオブジェクトプールで行を使い回す実装を用意します。

UI ToolkitのScrollViewの作成手順:UI Builder・UXML・C#

UI BuilderでのScrollView配置と表示枠のサイズ設定

UI BuilderのLibraryにある「Containers」からScrollViewをHierarchyへドラッグし、その下に子要素を入れます。配置した直後に必ずやるのは、ScrollView自体の大きさを決めることです。Inspectorの「Size」でHeightを指定するか、高さが決まっている親の中で「Flex」のGrowを1にします。どちらもしないと、ScrollViewが中身に合わせて伸びてスクロールが発生しません。

Labelの長文を折り返したい場合は、Labelの「Text」でWrapをnormalにしたうえで、ScrollViewの直下に幅の決まったVisualElementを1つ挟み、その中にLabelを入れます。公式マニュアルによると、contentContainerには境界が設定されていない(無限の大きさとして扱われる)ため、直下のLabelは正しく折り返されません。

UXMLで指定できる属性と既定値

<ui:UXML xmlns:ui="UnityEngine.UIElements">
  <ui:ScrollView name="item-list"
                 mode="Vertical"
                 horizontal-scroller-visibility="Hidden"
                 vertical-scroller-visibility="Auto"
                 mouse-wheel-scroll-size="36"
                 style="height: 320px;" />
</ui:UXML>

次の表は、6.3で使うScrollView固有の主要なUXML属性11個です。非推奨属性と継承属性は除いています。既定値はソース上の初期値です。

UXML属性 C#プロパティ 既定値
mode mode Vertical
horizontal-scroller-visibility horizontalScrollerVisibility Auto
vertical-scroller-visibility verticalScrollerVisibility Auto
horizontal-page-size horizontalPageSize -1(未設定)
vertical-page-size verticalPageSize -1(未設定)
mouse-wheel-scroll-size mouseWheelScrollSize 18
touch-scroll-type touchScrollBehavior Clamped
scroll-deceleration-rate scrollDecelerationRate 0.135
elasticity elasticity 0.1
elastic-animation-interval-ms elasticAnimationIntervalMs 16
nested-interaction-kind nestedInteractionKind Default

古い記事やサンプルにあるshow-horizontal-scroller/show-vertical-scrollerは、6.3ではObsolete扱いです。読み込みはされますが、新しく書くなら*-scroller-visibilityを使います。

C#で生成してUIDocumentに追加

using UnityEngine;
using UnityEngine.UIElements;

[RequireComponent(typeof(UIDocument))]
public class ScrollViewBuilder : MonoBehaviour
{
    ScrollView m_View;

    void OnEnable()
    {
        var root = GetComponent<UIDocument>().rootVisualElement;

        var scrollView = new ScrollView(ScrollViewMode.Vertical);
        scrollView.style.height = 320;
        scrollView.verticalScrollerVisibility = ScrollerVisibility.AlwaysVisible;

        for (int i = 0; i < 50; i++)
        {
            scrollView.Add(new Label($"Item {i}"));
        }

        root.Add(scrollView);
        m_View = scrollView;
    }

    void OnDisable()
    {
        m_View?.RemoveFromHierarchy();
        m_View = null;
    }
}

scrollView.Add()はcontentContainerへ子を入れます。スクロールバーなど内部の部品まで含めて触りたいときだけscrollView.hierarchyを使います。

縦・横スクロールの切り替え(mode)と横並び・折り返し

modeの3値は、ソースのコメントで子要素に求める条件が書き分けられています。

mode スクロール方向 子要素に必要な指定
Vertical(既定) 縦のみ height
Horizontal 横のみ width
VerticalAndHorizontal 縦と横 height(中身は折り返さない)

横スクロールでのホイール縦成分の振り替え条件

Horizontalモードでは、横にしかスクロールできない状態のとき、ホイールの縦方向の回転量を横スクロールに振り替えるようソースに書かれています(OnScrollWheel内の判定)。カード列やタブ列を横に流すUIで、横ホイールの無いマウスでも操作できるのはこのためです。VerticalAndHorizontalでも、横方向だけにスクロール余地がある場合は縦の回転量が横移動に使われます。縦にもスクロール余地がある場合は、縦成分を縦移動、横成分を横移動に使います。

この振り替え条件は、パッチで2回絞られています。

  • 6000.3.27f1:縦の回転量が横より大きいときだけ振り替える。トラックパッドの横スワイプにも小さな縦成分が混ざり、符号が揺れて逆走していた問題(UUM-152573)への対処で、6000.3.26f1までのソースには無い
  • 6000.6系(6000.6.7f1時点)と6.7ベータ:上の条件に加えて、nested-interaction-kindがStopScrollingでなく、祖先に縦スクロールできるScrollViewがある場合は振り替えない

2つ目は、縦に流れる画面の中に横スクロールのカード列を置く構成で効きます。6.3ではカード列の上でホイールを回すとカード列が横に動きますが、6.6以降では、カード列が横に動かないためイベントが止められず、縦の回転は親の縦スクロールに使われます。カード列をホイールで送る前提の画面を6.3から6.6以降へ上げるときは、カード列にStopScrollingを指定すれば従来どおり横に振り替わります。

contentContainerのflex-wrapによるグリッド配置

アイコン一覧のように「横に並べて、端まで来たら次の行へ」と配置するには、ScrollViewではなく中のcontentContainerにFlexboxの指定を入れます。

.unity-scroll-view__content-container {
    flex-direction: row;
    flex-wrap: wrap;
}

.inventory-slot {
    width: 96px;
    height: 96px;
    margin: 4px;
}

C#ならscrollView.contentContainer.style.flexDirection = FlexDirection.Row;とscrollView.contentContainer.style.flexWrap = Wrap.Wrap;です。このときmodeはVerticalにします。VerticalAndHorizontalは「中身を折り返さない」と定義されているので、wrapを書いても1行に並んだまま横に伸びます。子要素の幅を固定しないと1行に何個入るかが画面幅で揺れるため、スロットのような等幅の部品ではwidthを明示します。

スクロールバーの表示切り替えとスクロール速度の調整

スクロールバーのAuto・AlwaysVisible・Hidden

ScrollerVisibilityの3値は、Autoが「中身が収まらないときだけ表示」、AlwaysVisibleが「常に表示」、Hiddenが「常に非表示」です。Hiddenにしてもスクロール自体はでき、ホイールやタッチ、scrollOffsetの代入で動きます。バーを消してスマートフォン風に見せたいときはHiddenで足ります。

Autoで中身の量が境目付近にあると、バーの出し入れで幅が変わり、レイアウトが揺れることがあります。ソースはこれを避けるため、最初のレイアウトから5パスを超えると、一度表示したバーを消さないようにしています。チャット欄のように中身が増え続ける画面では、最初からAlwaysVisibleにしておくと幅が変わりません。

ホイール・キーボード・タッチで別々に効く速度の設定

速度の設定は入力手段ごとに分かれています。どれを変えるべきかを取り違えると「数値を変えても速さが変わらない」状態になります。

  • マウスホイール:mouse-wheel-scroll-size。既定18はUSS変数--unity-metrics-single_line-heightの既定行高18pxに対応し、36にすると同じホイール入力に対する移動量が既定値18の2倍になる。属性を指定するとUSS変数より優先される
  • キーボードとスクロールバーのボタン・ハンドル:horizontal-page-size/vertical-page-size。公式マニュアルは「ページサイズの倍率」と説明しているが、6.3のソースでは指定値をそのままスクロールバーのpageSizeに入れ、矢印ボタン1回でその値だけ位置を動かす。既定の-1(未設定)のときは、つまみの長さの90%が1回分になる
  • タッチの慣性:scroll-deceleration-rate。1秒あたりの減速率で、0.5なら毎秒半分に減速し、0で指を離した瞬間に止まる
  • タッチで端を越えたときの戻り:touch-scroll-typeをElasticにしたときだけelasticityが効く

PCとスマートフォンの両方に出すゲームでは、ホイール量とタッチの慣性を別々に調整できる点が利点です。一方でtouch-scroll-typeをElasticにすると、6.3のソースではホイールで端を越えた場合もスクロールバーの範囲制限が外れ、elasticityに従って戻る動きになります。PC向けのツールで端の跳ね返りを出したくないなら、既定のClampedのままにします。

ScrollViewに要素を動的に追加・削除する実装

ボタンで行を追加し、追加した行まで自動でスクロールする例です。UXMLにitem-list(ScrollView)、add-button、clear-buttonがある前提です。

using UnityEngine;
using UnityEngine.UIElements;

[RequireComponent(typeof(UIDocument))]
public class ItemListController : MonoBehaviour
{
    ScrollView m_List;
    int m_Count;

    void OnEnable()
    {
        var root = GetComponent<UIDocument>().rootVisualElement;
        m_List = root.Q<ScrollView>("item-list");
        root.Q<Button>("add-button").clicked += AddItem;
        root.Q<Button>("clear-button").clicked += m_List.Clear;
    }

    void OnDisable()
    {
        var root = GetComponent<UIDocument>().rootVisualElement;
        if (root == null)
            return;
        var addButton = root.Q<Button>("add-button");
        var clearButton = root.Q<Button>("clear-button");
        if (addButton != null)
            addButton.clicked -= AddItem;
        if (clearButton != null && m_List != null)
            clearButton.clicked -= m_List.Clear;
    }

    void AddItem()
    {
        var label = new Label($"Item {++m_Count}");
        m_List.Add(label);
        m_List.ScrollTo(label);
    }
}

1件だけ消すならm_List.Remove(label)かlabel.RemoveFromHierarchy()、全件ならm_List.Clear()です。Clear()はcontentContainerの子だけを消すので、スクロールバーは残ります。

Unity 6.0〜6.2で追加直後のScrollToが効かない理由と回避策

上のコードは、6.0〜6.2では最後の行まで移動しないことがあります。6.0のソースのScrollToは、呼ばれた瞬間のworldBound(画面上の位置)で移動量を計算します。Add()した直後の要素はまだレイアウト計算を通っておらず位置が確定していないため、移動量が0か計算不能になり、そのまま何もせずに終わります。

6.3のソースでは、パネルのレイアウトが未確定のときにScrollToを予約し、毎フレーム最大60回まで再試行する処理が加わりました(6000.0〜6000.2の各ブランチには無い)。ただしこの処理には、UIを毎フレーム更新している画面でScrollToの後に手動でスクロールすると対象の行へ引き戻される不具合(UUM-142486)があり、6.3系では6000.3.18f1で修正されています。6.3を使うなら6000.3.18f1以降に上げます(6.3系のパッチの追い方はUnity 6.3 LTS(6000.3)の新機能と変更点にまとめています)。6.2以前を使い続けるなら、子要素のGeometryChangedEventを1回待ってから呼びます。次の補助メソッドをItemListController内に追加し、AddItem内のm_List.ScrollTo(label);をScrollToAfterLayout(m_List, label);に置き換えてください。

static void ScrollToAfterLayout(ScrollView view, VisualElement child)
{
    EventCallback<GeometryChangedEvent> onLayout = null;
    onLayout = _ =>
    {
        child.UnregisterCallback(onLayout);
        view.ScrollTo(child);
    };
    child.RegisterCallback(onLayout);
}

一番下まで送りたいだけなら、レイアウト確定後にscrollView.scrollOffset = new Vector2(0, scrollView.verticalScroller.highValue);と書く方法が公式マニュアルに載っています。これもレイアウト前に実行するとhighValueが古い値のままなので、同じくGeometryChangedEventの後で実行します。

ScrollViewがスクロールしない・スクロールバーが出ないときの原因

旧来の解説には「中身を変えたらスクロールバーを強制的に再描画する」という対処が出てきますが、ソースを見るとその必要はありません。contentContainerとcontentViewportの両方にGeometryChangedEventが登録されており、サイズが変わるたびにバーの表示と範囲が更新されます。MarkDirtyRepaint()は再描画を要求するだけでレイアウトを計算し直さないので、呼んでも結果は変わりません。原因は次の順に確かめます。

  1. ScrollViewが中身に合わせて伸びている:UI Toolkit Debuggerでunity-content-viewportとunity-content-containerの高さを比べる。同じ値ならスクロール量は0。ScrollViewに高さを与えるか、親に高さを与えたうえでflex-grow: 1にする
  2. 親の高さが決まっていない:flex-grow: 1は親の残り領域を分け合う指定なので、親に高さが無いと効かない。VisualTreeAsset.Instantiate()で作ったTemplateContainerを挟んでいる場合は、そのTemplateContainerにも高さかflex-growが要る
  3. modeと中身の向きが合っていない:Verticalで横に長い中身を入れても横には動かない
  4. マウスでつかんで動かそうとしている:マウスのドラッグは仕様で無効(次章)
  5. レイアウト前に位置を読んでいる:Add()直後のverticalScroller.highValueやscrollOffsetは古い値。GeometryChangedEventの後で読む

まず1と2で表示枠と中身の高さを確認します。高さに問題がなければ、3以降でスクロール方向、入力方法、レイアウト更新のタイミングを切り分けます。

マウスドラッグでScrollViewの中身をつかんでスクロールさせる実装

UI ToolkitのScrollViewは、マウスで中身をつかんでも動きません。6.3のソースではOnPointerDownとOnPointerMoveの先頭でevt.pointerType == PointerType.mouseなら処理を抜けており、ドラッグスクロールはタッチ・ペンと、6.3で加わったXRコントローラー(PointerType.tracked)だけが対象です。エディタ上でスマートフォン向けUIを確認するときに困るので、マウス用のマニピュレーターを自作します。

using UnityEngine;
using UnityEngine.UIElements;

public class MouseDragScrollManipulator : PointerManipulator
{
    readonly ScrollView m_ScrollView;
    Vector2 m_StartPointer;
    Vector2 m_StartOffset;
    bool m_Dragging;

    public MouseDragScrollManipulator(ScrollView scrollView)
    {
        m_ScrollView = scrollView;
        activators.Add(new ManipulatorActivationFilter { button = MouseButton.LeftMouse });
    }

    protected override void RegisterCallbacksOnTarget()
    {
        target.RegisterCallback<PointerDownEvent>(OnPointerDown);
        target.RegisterCallback<PointerMoveEvent>(OnPointerMove);
        target.RegisterCallback<PointerUpEvent>(OnPointerUp);
    }

    protected override void UnregisterCallbacksFromTarget()
    {
        target.UnregisterCallback<PointerDownEvent>(OnPointerDown);
        target.UnregisterCallback<PointerMoveEvent>(OnPointerMove);
        target.UnregisterCallback<PointerUpEvent>(OnPointerUp);
    }

    void OnPointerDown(PointerDownEvent evt)
    {
        if (evt.pointerType != PointerType.mouse || !CanStartManipulation(evt))
            return;
        m_StartPointer = evt.position;
        m_StartOffset = m_ScrollView.scrollOffset;
        m_Dragging = true;
        target.CapturePointer(evt.pointerId);
    }

    void OnPointerMove(PointerMoveEvent evt)
    {
        if (!m_Dragging || !target.HasPointerCapture(evt.pointerId))
            return;
        Vector2 delta = (Vector2)evt.position - m_StartPointer;
        if (m_ScrollView.mode == ScrollViewMode.Vertical)
            delta.x = 0;
        else if (m_ScrollView.mode == ScrollViewMode.Horizontal)
            delta.y = 0;
        m_ScrollView.scrollOffset = m_StartOffset - delta;
    }

    void OnPointerUp(PointerUpEvent evt)
    {
        if (!m_Dragging || !CanStopManipulation(evt))
            return;
        m_Dragging = false;
        target.ReleasePointer(evt.pointerId);
    }
}

使うときはscrollView.contentContainer.AddManipulator(new MouseDragScrollManipulator(scrollView));と登録します。ビューポートのPickingMode.Ignoreは直接のヒット対象から外す設定で、子から伝播するイベントの受信まで禁止するものではありません。scrollOffsetへの代入が範囲内に丸められるのは、touchScrollBehaviorがClampedの場合です。この例で端からのはみ出しを防ぐには、既定のClampedを維持してください。ボタンの上から始めたドラッグでは、ボタンのClickableがポインタを捕捉してイベントの伝播を止める(6.3のソースでStopImmediatePropagation)ため、この処理は動きません。つかむ場所は行の背景や余白になります。

慣性やバウンスまで再現したくなったら、自作をやめてuGUIのScroll Rectを使うか、タッチ入力でテストする方が早く済みます。上のコードは「エディタでつかんで動かせればよい」用途に絞った最小実装です。

6.7ベータ(6000.7.0b6)のソースには、ドラッグに対する中身の動く向きを決めるtouchScrollDirection(既定のNaturalと、逆向きのInverted)が加わっています。ソースの説明は、対象をタッチ・ペン・XRコントローラーのドラッグに限り、マウスのドラッグ・スクロールバー・ホイールには効かないと明記しています。新しい版でもマウスでつかんで動かすには、上のような自作が必要です。

入れ子のScrollViewとスクロール位置の保持

NestedInteractionKindによる親子間のスクロール引き継ぎ

縦スクロールの画面の中に横スクロールのカード列を入れるような入れ子では、子が端に達したあと親へスクロールを渡すかどうかをnested-interaction-kindで決めます。

値 子が端に達したとき
Default タッチはStopScrolling、ホイールはForwardScrolling相当
StopScrolling ドラッグを始めた子が操作を持ち続ける
ForwardScrolling 動けない方向の操作を親へ渡す

Defaultのままで不満が出やすいのは、スマートフォンで子の横リストを端まで送ったあと、そのまま指を動かしても親が動かないケースです。親へ渡したいなら子にForwardScrollingを指定します。

スクロール位置の保存:Editor UIのview-data-keyとランタイムのscrollOffset

ScrollViewにview-data-keyを付けると、ドメインリロードやエディタ再起動をまたいでスクロール位置が戻ります。ただし公式マニュアルは「View data persistence only works in the Editor UI」と明記しており、ランタイムのUIDocumentでは効きません。ゲーム内のメニューを閉じて開き直したときに位置を戻したいなら、閉じる前のscrollOffsetを保持し、開き直したら前章のGeometryChangedEventの後で代入し直します。エディタ拡張の位置保持にはview-data-key、ゲーム画面には自前の保存、と使い分けます。

ScrollViewとListViewの使い分け基準

ListViewは内部にScrollViewを持ち、画面を埋めるのに必要な数だけ行の要素を作って使い回す(仮想化する)コントロールです。ScrollViewはAdd()した要素をすべて保持し、すべてがレイアウト計算の対象になります。判断は件数の閾値ではなく、次の条件で決めます。

  • ListViewにする:同じ形の行が並び、件数がデータ次第で増える(インベントリ、ログ、ランキング、チャット履歴)
  • ScrollViewにする:行ごとに構造が違う設定画面、件数が固定で少ない一覧、横並びやグリッド。公式マニュアルはListViewの横レイアウトを非対応とし、横並びにはScrollViewとflex-direction: rowを勧めている

避けたいのは、件数が読めない一覧をScrollViewで作り、数百〜数千の行をAdd()し続ける構成です。要素の数だけレイアウトとイベントの対象が増えます。ListViewの最小構成は次のとおりです。

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;

[RequireComponent(typeof(UIDocument))]
public class LogListController : MonoBehaviour
{
    readonly List<string> m_Items = new List<string>();
    ListView m_View;

    void OnEnable()
    {
        m_Items.Clear();
        for (int i = 0; i < 1000; i++)
            m_Items.Add($"Log {i}");

        var listView = new ListView
        {
            itemsSource = m_Items,
            fixedItemHeight = 24,
            makeItem = () => new Label(),
            bindItem = (element, index) => ((Label)element).text = m_Items[index],
        };
        listView.style.flexGrow = 1;
        GetComponent<UIDocument>().rootVisualElement.Add(listView);
        m_View = listView;
    }

    void OnDisable()
    {
        m_View?.RemoveFromHierarchy();
        m_View = null;
    }
}

ListViewのvirtualizationMethodは既定がFixedHeightで、fixedItemHeight(既定22)で全行の高さをそろえる前提です。行の高さが中身で変わるならDynamicHeightにしますが、実際の高さが計算されるのを待つぶん重くなります。データを書き換えたらRefreshItems()、makeItemや型を変えたときだけRebuild()を呼びます。指定行へ送るのはScrollToItem(index)です。ListView内部のScrollViewは6.3では非公開プロパティなので、ホイール速度を変えたいときはlistView.Q<ScrollView>()で取り出してmouseWheelScrollSizeを設定します。

よくある質問

ScrollViewとは何ですか?

表示枠より大きい中身を、スクロールバーやホイール、タッチで動かして見せるUI部品です。UnityにはUI ToolkitのScrollView要素と、uGUIの「Scroll View」(Scroll Rectコンポーネントを付けたGameObject)の2種類があり、同じ名前でも設定方法はまったく違います。UI Builder・UXMLで画面を作っているならUI Toolkit版、Canvasで作っているならuGUI版です。

Unity 6でScrollViewを作るならUI ToolkitとuGUIのどちらを使うべきですか?

新しく画面を作り、マウスでのドラッグスクロールが必須でないならUI Toolkitを選べます。スクロールバー内蔵で、中身のサイズもFlexboxが自動で計算するので、uGUIのようにViewportやContent、Content Size Fitterを組む手間がありません。一方、PCでも中身をつかんで動かす操作が必須の場合や、画面全体を既存のuGUIで作っている場合は、uGUIのScroll Rectの方が手早く済みます。どちらで組むかを含めてゲーム画面の設計から相談したい場合は、一創のゲームアプリ開発でも対応しています。

UnityのScrollViewがスクロールしないのはなぜですか?

確認する原因の一つは、ScrollViewの高さが制約されず、中身に合わせて伸びているケースです。この状態ではビューポートと中身が同じ高さになり、スクロール量が0になります。ScrollViewにheightを指定するか、高さの決まった親の中でflex-growを1にしてください。マウスで中身をつかんで動かそうとしている場合は仕様上動かないので、ホイールかスクロールバーで確認します。uGUIのScroll Viewなら、ContentにContent Size Fitterが無くViewportより短いままになっているケースと、EventSystemの入力モジュールがInput Systemの設定と合っていないケースを先に確かめます。

ScrollViewのスクロールバーを非表示にしてもスクロールできますか?

できます。vertical-scroller-visibilityまたはhorizontal-scroller-visibilityをHiddenにすると、バーは消えますが、ホイール・タッチ・キーボード操作とscrollOffsetの代入によるスクロールは引き続き動きます。中身の量によってバーが出たり消えたりして幅が揺れるのが気になる場合は、逆にAlwaysVisibleで固定する方法もあります。

GeometryChangedEventはScrollViewでどう使いますか?

要素のレイアウト(位置や大きさ)が計算し直されたときに届くイベントです。ScrollViewでは、要素をAdd()した直後はまだ位置が決まっていないため、ScrollToやverticalScroller.highValueを使う処理をこのイベントを契機に実行します。ただし、その後もサイズが変わる画面では、レイアウトが安定してから実行してください。Unity 6.3ではScrollToだけはレイアウト確定まで自動で再試行されるようになりましたが、highValueを読む処理は引き続き待つ必要があります。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

RELATED POSTS 関連記事

目次