mcpField
U
unity-ui-patterns

unity-ui-patterns

github.com/nice-wolf-studio设计与 UI4065 次安装6 周前更新
代码仓库

关于 unity-ui-patterns

unity-ui-patterns 是面向 Unity 项目的运行时 UI 设计技能,采用 UI Toolkit。它提供屏幕导航、响应式视图、HUD、反馈、列表和转场模式,分离 UI 行为与设计。

主要功能

  1. 01基于栈的屏幕导航
  2. 02分离 View 与 ViewModel
  3. 03事件驱动的 HUD 组件
  4. 04基于 ScriptableObject 的 UI 反馈
  5. 05复用 ListView 元素
  6. 06可复用的屏幕转场契约

适用场景

  1. 01组织游戏屏幕间的导航
  2. 02根据游戏数据变化更新 UI
  3. 03构建响应事件的 HUD 元素
  4. 04显示大型库存或商店列表
  5. 05为设计师配置屏幕动画

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/nice-wolf-studio/unity-claude-skills --skill unity-ui-patterns

安装后在 Claude Code 中输入/unity-ui-patterns确认已加载。

附带文件references/ui-pattern-scaffolds.md

详细介绍

UI/UX 模式——设计转换模式

前置技能: unity-ui(UI Toolkit API、USS/UXML、数据绑定)、unity-async-patterns(异步过渡、取消)、unity-game-architecture(事件、服务定位器)

Claude 会将数据获取、显示和动画混在一个单体脚本中来构建 UI。一个屏幕时尚可应付,但当设计师添加过渡效果、动态列表或多个屏幕时,就难以维护了。这些模式将结构与行为分离,让屏幕可以组合,并让设计师能够配置游戏果汁感。


模式:屏幕流架构

设计意图:游戏包含多个屏幕(主菜单、设置、物品栏、HUD、暂停菜单),并支持导航流程和返回按钮。

错误做法:

// One MonoBehaviour per screen with direct references -- breaks on every new screen
public class MainMenu : MonoBehaviour
{
    [SerializeField] private GameObject settingsPanel;
    [SerializeField] private GameObject inventoryPanel;

    public void OnSettingsClicked()
    {
        gameObject.SetActive(false);
        settingsPanel.SetActive(true); // Direct coupling
    }

    public void OnBackFromSettings()
    {
        settingsPanel.SetActive(false);
        gameObject.SetActive(true); // Every screen knows every other screen
    }
}

正确做法:使用基于堆栈导航(推入/弹出)的 ScreenManager。每个屏幕都实现带有 ShowAsync/HideAsync 的 IScreen。通过 UIDocument 从 UXML 模板加载屏幕。导航与屏幕逻辑解耦——屏幕之间不会相互引用。

脚手架:

using System;
using System.Threading;
using UnityEngine;
using UnityEngine.UIElements;

/// <summary>
/// Contract for all navigable screens.
/// </summary>
public interface IScreen
{
    /// <summary>Display name used for debugging and stack inspection.</summary>
    string ScreenName { get; }

    /// <summary>Transition the screen into view.</summary>
    Awaitable ShowAsync(CancellationToken ct);

    /// <summary>Transition the screen out of view.</summary>
    Awaitable HideAsync(CancellationToken ct);
}

/// <summary>
/// Stack-based screen navigator. Push screens to go forward, pop to go back.
/// </summary>
public class ScreenManager : MonoBehaviour
{
    [SerializeField] private UIDocument uiDocument;

    private readonly System.Collections.Generic.Stack<IScreen> _screenStack = new();
    private bool _isTransitioning;

    public static ScreenManager Instance { get; private set; }

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void ResetStatic() => Instance = null;

    void Awake()
    {
        if (Instance != null) { Destroy(gameObject); return; }
        Instance = this;
    }

    /// <summary>Push a new screen onto the stack, hiding the current one.</summary>
    public async Awaitable PushAsync(IScreen screen)
    {
        if (_isTransitioning) return;
        _isTransitioning = true;

        var ct = destroyCancellationToken;

        if (_screenStack.TryPeek(out var current))
        {
            await current.HideAsync(ct);
        }

        _screenStack.Push(screen);
        await screen.ShowAsync(ct);

        _isTransitioning = false;
    }

    /// <summary>Pop the current screen and reveal the one beneath it.</summary>
    public async Awaitable PopAsync()
    {
        if (_isTransitioning || _screenStack.Count <= 1) return;
        _isTransitioning = true;

        var ct = destroyCancellationToken;
        var leaving = _screenStack.Pop();
        await leaving.HideAsync(ct);

        if (_screenStack.TryPeek(out var revealed))
        {
            await revealed.ShowAsync(ct);
        }

        _isTransitioning = false;
    }
}

设计扩展点:新增屏幕时,实现 IScreen 并创建 UXML 模板;通过 ScreenManager.PushAsync(screen) / PopAsync() 导航。屏幕之间互不知晓。

注意事项:屏幕堆栈必须处理边缘情况:重复推入同一屏幕、弹出最后一个屏幕、过渡期间推入屏幕。此脚手架通过 _isTransitioning 防止过渡重叠。为防止重复推入,请在推入前检查 _screenStack.Peek() != screen。


模式:视图/视图模型分离

设计意图:UI 显示来自多个系统(生命值、物品栏、任务)的数据,并能响应式更新。

错误做法:

// UI script queries game systems directly every frame
public class InventoryUI : MonoBehaviour
{
    [SerializeField] private UIDocument doc;

    void Update()
    {
        // Polling every frame, tightly coupled to game internals
        var root = doc.rootVisualElement;
        var items = GameManager.Instance.Player.Inventory.Items;
        for (int i = 0; i < items.Count; i++)
        {
            root.Q<Label>($"item-{i}").text = items[i].Name;
            root.Q<Label>($"count-{i}").text = items[i].Count.ToString();
        }
    }
}

正确做法:视图模型(纯 C# 类)将游戏数据转换为显示数据,并在数据变化时触发 C# 事件。视图将 VisualElements 绑定到视图模型属性。视图模型不依赖 VisualElement。游戏系统更新视图模型;视图随之响应。

脚手架:

using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;

/// <summary>
/// ViewModel adapting inventory data for display.
/// No dependency on UnityEngine.UIElements.
/// </summary>
public class InventoryViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private string _selectedItemName = "";
    private int _selectedItemCount;
    private int _totalItems;

    /// <summary>Name of the currently selected item.</summary>
    public string SelectedItemName
    {
        get => _selectedItemName;
        set { _selectedItemName = value; OnPropertyChanged(); }
    }

    /// <summary>Stack count of the selected item.</summary>
    public int SelectedItemCount
    {
        get => _selectedItemCount;
        set { _selectedItemCount = value; OnPropertyChanged(); }
    }

    /// <summary>Total number of distinct items in inventory.</summary>
    public int TotalItems
    {
        get => _totalItems;
        set { _totalItems = value; OnPropertyChanged(); }
    }

    private void OnPropertyChanged([CallerMemberName] string name = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}
using UnityEngine;
using UnityEngine.UIElements;

/// <summary>
/// View that binds VisualElements to InventoryViewModel properties.
/// </summary>
public class InventoryView : MonoBehaviour
{
    [SerializeField] private UIDocument uiDocument;

    private InventoryViewModel _viewModel;
    private Label _itemNameLabel;
    private Label _itemCountLabel;
    private Label _totalLabel;

    /// <summary>Bind the view to a ViewModel instance.</summary>
    public void Bind(InventoryViewModel viewModel)
    {
        if (_viewModel != null)
            _viewModel.PropertyChanged -= OnPropertyChanged;

        _viewModel = viewModel;
        _viewModel.PropertyChanged += OnPropertyChanged;

        var root = uiDocument.rootVisualElement;
        _itemNameLabel = root.Q<Label>("item-name");
        _itemCountLabel = root.Q<Label>("item-count");
        _totalLabel = root.Q<Label>("total-items");

        RefreshAll();
    }

    private void OnPropertyChanged(object sender,
        System.ComponentModel.PropertyChangedEventArgs e)
    {
        switch (e.PropertyName)
        {
            case nameof(InventoryViewModel.SelectedItemName):
                _itemNameLabel.text = _viewModel.SelectedItemName;
                break;
            case nameof(InventoryViewModel.SelectedItemCount):
                _itemCountLabel.text = _viewModel.SelectedItemCount.ToString();
                break;
            case nameof(InventoryViewModel.TotalItems):
                _totalLabel.text = _viewModel.TotalItems.ToString();
                break;
        }
    }

    private void RefreshAll()
    {
        _itemNameLabel.text = _viewModel.SelectedItemName;
        _itemCountLabel.text = _viewModel.SelectedItemCount.ToString();
        _totalLabel.text = _viewModel.TotalItems.ToString();
    }

    void OnDestroy()
    {
        if (_viewModel != null)
            _viewModel.PropertyChanged -= OnPropertyChanged;
    }
}

设计扩展点:新增数据源时,创建适配游戏系统的新视图模型;视图绑定数据时无需了解游戏内部实现。视图模型无需依赖 Unity 即可测试。

注意事项:不要在游戏运行时 UI 中使用 Unity 内置数据绑定——它是为编辑器工具设计的,其开销和局限性不适合运行时游戏 UI。使用显式 C# 事件订阅,以便控制和调试。


模式:HUD 架构

设计意图:HUD 元素(生命值条、弹药计数器、小地图标记、准星)在游戏过程中始终可见,并以不同频率更新。

错误做法:

// All HUD logic in one script, polling every frame
public class HUD : MonoBehaviour
{
    [SerializeField] private UIDocument doc;

    void Update()
    {
        var root = doc.rootVisualElement;
        root.Q<ProgressBar>("health-bar").value = Player.Instance.Health;
        root.Q<Label>("ammo-count").text = Player.Instance.Ammo.ToString();
        root.Q<Label>("score").text = ScoreManager.Instance.Score.ToString();
        // Everything updates every frame whether it changed or not
    }
}

正确做法:每个 HUD 部件都实现带有 Initialize/Dispose 生命周期的 IHudWidget。部件订阅游戏事件(来自 unity-game-architecture 的 SO 通道),只在数据变化时自行更新。HudController 管理部件的注册和生命周期。

脚手架:

using System;
using UnityEngine.UIElements;

/// <summary>
/// Contract for HUD widgets. Each widget owns a section of the HUD.
/// </summary>
public interface IHudWidget : IDisposable
{
    /// <summary>Bind to VisualElements and subscribe to game events.</summary>
    void Initialize(VisualElement hudRoot);
}
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;

/// <summary>
/// Manages HUD widget lifecycle. Attach to the HUD UIDocument GameObject.
/// </summary>
public class HudController : MonoBehaviour
{
    [SerializeField] private UIDocument hudDocument;

    private readonly List<IHudWidget> _widgets = new();

    /// <summary>Register and initialize a widget.</summary>
    public void RegisterWidget(IHudWidget widget)
    {
        widget.Initialize(hudDocument.rootVisualElement);
        _widgets.Add(widget);
    }

    void OnDestroy()
    {
        foreach (var widget in _widgets)
            widget.Dispose();
        _widgets.Clear();
    }
}
using UnityEngine;
using UnityEngine.UIElements;

/// <summary>
/// Health bar widget. Updates only when health changes via event subscription.
/// </summary>
public class HealthBarWidget : IHudWidget
{
    // ScriptableObject event channel (see unity-game-architecture)
    private readonly FloatEventChannel _healthChanged;
    private ProgressBar _healthBar;

    public HealthBarWidget(FloatEventChannel healthChanged)
    {
        _healthChanged = healthChanged;
    }

    public void Initialize(VisualElement hudRoot)
    {
        _healthBar = hudRoot.Q<ProgressBar>("health-bar");
        _healthChanged.OnEventRaised += UpdateHealth;
    }

    private void UpdateHealth(float normalizedHealth)
    {
        _healthBar.value = normalizedHealth * 100f;
    }

    public void Dispose()
    {
        _healthChanged.OnEventRaised -= UpdateHealth;
    }
}

设计扩展点:新增部件时,实现 IHudWidget;在 HUD UXML 中添加元素;向 HudController 注册。部件彼此独立、可测试,并且只在其数据变化时更新。

注意事项:HUD 根节点必须使用与游戏屏幕分离的 UIDocument——UIDocument 组件上的 sortingOrder 控制图层顺序。HUD 的 sortingOrder 应高于屏幕 UI,使其显示在上层。一个场景中可以共存多个 UIDocument 组件。

模式:反馈与爽感系统

设计意图:UI 对游戏事件做出视觉反馈(伤害数字、屏幕震动、颜色闪烁、缩放冲击)。

错误:

// Hardcoded feedback inline in combat logic
public class CombatManager : MonoBehaviour
{
    [SerializeField] private UIDocument hudDoc;

    public void ApplyDamage(float amount)
    {
        // Combat script directly drives UI -- tight coupling
        var healthBar = hudDoc.rootVisualElement.Q("health-bar");
        healthBar.style.backgroundColor = Color.red;
        // No way to revert, no timing, no designer control
    }
}

正确:使用 UIFeedbackPlayer 组件和 UIFeedback SO 资源。游戏代码触发事件,反馈系统订阅事件并应用视觉效果。反馈序列:添加 USS 类,通过 USS transition-* 属性播放过渡效果,并在持续时间结束后移除 USS 类。

脚手架:

using UnityEngine;

/// <summary>
/// Designer-configurable UI feedback effect.
/// </summary>
[CreateAssetMenu(menuName = "UI/Feedback Effect")]
public class UIFeedback : ScriptableObject
{
    [Tooltip("USS class to add that triggers the visual effect")]
    public string ussClassName = "feedback--damage";

    [Tooltip("Duration in seconds before removing the USS class")]
    public float duration = 0.3f;

    [Tooltip("Optional: target element name. Null = apply to root.")]
    public string targetElementName;
}
using System.Threading;
using UnityEngine;
using UnityEngine.UIElements;

/// <summary>
/// Plays UIFeedback effects on a UIDocument's visual tree.
/// </summary>
public class UIFeedbackPlayer : MonoBehaviour
{
    [SerializeField] private UIDocument uiDocument;

    /// <summary>Play a feedback effect, adding then removing a USS class.</summary>
    public async Awaitable PlayAsync(UIFeedback feedback)
    {
        var ct = destroyCancellationToken;
        var root = uiDocument.rootVisualElement;

        var target = string.IsNullOrEmpty(feedback.targetElementName)
            ? root
            : root.Q(feedback.targetElementName);

        if (target == null) return;

        // Add USS class -- USS transition-* properties handle the animation
        target.AddToClassList(feedback.ussClassName);

        await Awaitable.WaitForSecondsAsync(feedback.duration, ct);

        // Remove USS class -- transition plays in reverse
        target.RemoveFromClassList(feedback.ussClassName);
    }
}

伤害闪烁的 USS 示例:

/* Base state */
.health-bar {
    transition-property: background-color, scale;
    transition-duration: 0.15s;
    transition-timing-function: ease-out;
    background-color: rgb(50, 180, 50);
    scale: 1 1;
}

/* Feedback state -- applied by UIFeedbackPlayer */
.health-bar.feedback--damage {
    background-color: rgb(220, 40, 40);
    scale: 1.1 1.1;
}

设计扩展点:设计师创建 UIFeedback SO,并为视觉效果分配 USS 类。新增爽感效果 = 新 SO + USS 规则。无需更改代码。

注意事项:USS 过渡使用 transition-duration 和 transition-timing-function——必须在基础状态(而非触发类)上设置过渡属性,否则过渡不会播放。触发类只更改目标属性值。


模式:动态列表/网格视图

设计意图:背包、商店、排行榜——高效渲染包含可变长度数据的可滚动列表。

错误:

// Instantiate one VisualElement per data item
public void PopulateInventory(List<ItemData> items)
{
    var container = doc.rootVisualElement.Q("item-container");
    container.Clear();
    foreach (var item in items)
    {
        // 1000 items = 1000 elements in the visual tree -- laggy scroll
        var element = new VisualElement();
        element.Add(new Label(item.Name));
        element.Add(new Label(item.Count.ToString()));
        container.Add(element);
    }
}

正确:使用 UI Toolkit ListView,通过 makeItem/bindItem 回调复用元素。数据源为 List<T> 或 IList。条目模板来自 UXML。视觉树中只存在可见条目。

脚手架:

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

/// <summary>
/// Sets up a ListView with element recycling for efficient scrolling.
/// </summary>
public class InventoryListView : MonoBehaviour
{
    [SerializeField] private UIDocument uiDocument;
    [SerializeField] private VisualTreeAsset itemTemplate;

    private ListView _listView;
    private List<ItemData> _items = new();

    void OnEnable()
    {
        _listView = uiDocument.rootVisualElement.Q<ListView>("inventory-list");

        _listView.makeItem = () => itemTemplate.Instantiate();

        _listView.bindItem = (element, index) =>
        {
            var item = _items[index];
            element.Q<Label>("item-name").text = item.Name;
            element.Q<Label>("item-count").text = item.Count.ToString();
        };

        _listView.unbindItem = (element, index) =>
        {
            // Clean up any subscriptions or dynamic content
            element.Q<Label>("item-name").text = "";
            element.Q<Label>("item-count").text = "";
        };

        _listView.itemsSource = _items;
        _listView.fixedItemHeight = 60f;
        _listView.selectionType = SelectionType.Single;

        _listView.selectionChanged += OnSelectionChanged;
    }

    /// <summary>Update the data source and refresh the list.</summary>
    public void SetItems(List<ItemData> items)
    {
        _items = items;
        _listView.itemsSource = _items;
        _listView.RefreshItems();
    }

    private void OnSelectionChanged(IEnumerable<object> selection)
    {
        foreach (var obj in selection)
        {
            if (obj is ItemData item)
                Debug.Log($"Selected: {item.Name}");
        }
    }

    void OnDisable()
    {
        _listView.selectionChanged -= OnSelectionChanged;
    }
}

设计扩展点:条目视觉样式 = UXML 模板;数据绑定在 bindItem 回调中完成;新列表类型使用不同的数据/模板复用此模式。设计师通过 UXML + USS 控制条目布局。

注意事项:数据变更后调用 ListView.RefreshItems()(而非 Rebuild())——Rebuild 会从头重新创建所有元素,而 RefreshItems 会重新绑定现有的已复用元素。仅当条目模板本身发生变化时才使用 Rebuild()。


模式:过渡与动画契约

设计意图:屏幕过渡(淡入淡出、滑动、缩放)保持一致,并可由设计师配置。

错误:

// Manual alpha lerps per screen, no standard contract
public async Awaitable ShowMainMenu(VisualElement root)
{
    root.style.opacity = 0;
    root.style.display = DisplayStyle.Flex;
    float t = 0;
    while (t < 0.3f)
    {
        t += Time.deltaTime;
        root.style.opacity = t / 0.3f;
        await Awaitable.NextFrameAsync();
    }
    // Every screen duplicates this. Duration/easing hardcoded. No designer control.
}

正确:使用 IScreenTransition 接口,并提供 PlayAsync(VisualElement target, CancellationToken ct) 方法。实现使用 USS 过渡(opacity、translate、scale)。过渡是带有持续时间/缓动配置的 SO 资源。ScreenManager 在每次导航调用时接收过渡效果。

脚手架:

using System.Threading;
using UnityEngine.UIElements;

/// <summary>
/// Contract for screen transitions.
/// </summary>
public interface IScreenTransition
{
    /// <summary>Play the transition on the target element.</summary>
    Awaitable PlayAsync(VisualElement target, CancellationToken ct);
}
using System.Threading;
using UnityEngine;
using UnityEngine.UIElements;

/// <summary>
/// Fade transition using USS opacity. Designer-configurable duration.
/// </summary>
[CreateAssetMenu(menuName = "UI/Transitions/Fade")]
public class FadeTransition : ScriptableObject, IScreenTransition
{
    [Tooltip("Fade duration in seconds")]
    [SerializeField] private float duration = 0.3f;

    [Tooltip("True = fade in (0 to 1), False = fade out (1 to 0)")]
    [SerializeField] private bool fadeIn = true;

    public async Awaitable PlayAsync(VisualElement target, CancellationToken ct)
    {
        float startOpacity = fadeIn ? 0f : 1f;
        float endOpacity = fadeIn ? 1f : 0f;

        // Set initial state without transition
        target.style.transitionDuration = new List<TimeValue> { new(0, TimeUnit.Second) };
        target.style.opacity = startOpacity;

        // Wait one frame for the initial state to apply
        await Awaitable.NextFrameAsync(ct);

        // Set transition duration then animate
        target.style.transitionProperty = new List<StylePropertyName> { new("opacity") };
        target.style.transitionDuration = new List<TimeValue> { new(duration, TimeUnit.Second) };
        target.style.transitionTimingFunction =
            new List<EasingFunction> { new(EasingMode.EaseInOut) };
        target.style.opacity = endOpacity;

        await Awaitable.WaitForSecondsAsync(duration, ct);
    }
}
using System.Threading;
using UnityEngine;
using UnityEngine.UIElements;

/// <summary>
/// Slide transition using USS translate. Configurable direction and duration.
/// </summary>
[CreateAssetMenu(menuName = "UI/Transitions/Slide")]
public class SlideTransition : ScriptableObject, IScreenTransition
{
    [SerializeField] private float duration = 0.4f;
    [SerializeField] private bool slideIn = true;
    [SerializeField] private Vector2 offset = new(100f, 0f); // percentage

    public async Awaitable PlayAsync(VisualElement target, CancellationToken ct)
    {
        var start = slideIn
            ? new Translate(Length.Percent(offset.x), Length.Percent(offset.y))
            : new Translate(Length.Percent(0), Length.Percent(0));
        var end = slideIn
            ? new Translate(Length.Percent(0), Length.Percent(0))
            : new Translate(Length.Percent(offset.x), Length.Percent(offset.y));

        // Set initial state
        target.style.transitionDuration = new List<TimeValue> { new(0, TimeUnit.Second) };
        target.style.translate = new StyleTranslate(start);

        await Awaitable.NextFrameAsync(ct);

        // Animate
        target.style.transitionProperty = new List<StylePropertyName> { new("translate") };
        target.style.transitionDuration = new List<TimeValue> { new(duration, TimeUnit.Second) };
        target.style.transitionTimingFunction =
            new List<EasingFunction> { new(EasingMode.EaseOut) };
        target.style.translate = new StyleTranslate(end);

        await Awaitable.WaitForSecondsAsync(duration, ct);
    }
}

设计扩展点:新增过渡效果 = 将 IScreenTransition 实现为 ScriptableObject;设计师可为每次屏幕切换选择过渡 SO。ScreenManager.PushAsync(screen, transition) 会应用该过渡。

注意事项:USS transition-property 必须包含所有动画属性——如果要为 opacity 和 translate 添加动画,两者都必须列出。可以使用 transition-property: all 作为备用方案,但请注意,它会为每个属性变更添加动画(包括你不打算添加动画的布局变更)。


反模式摘要

反模式问题模式修正方案
单体屏幕脚本每个屏幕都了解其他所有屏幕;新增一个屏幕就要修改五个文件屏幕流架构——基于栈的导航
在 Update 中直接轮询系统浪费运行周期,UI 与游戏逻辑紧密耦合View/ViewModel——事件驱动更新
单个 HUD MonoBehaviour一处修改就会破坏所有 HUD 元素;所有内容都在每帧轮询HUD 架构——独立组件订阅事件
在游戏逻辑中硬编码反馈战斗脚本直接驱动 UI;设计师无法控制反馈与爽感——由 SO 驱动的 USS 类效果
手动实例化元素1000 个条目 = 1000 个 VisualElements;滚动卡顿动态 ListView——使用 makeItem/bindItem 复用元素
每个屏幕各自编写动画代码每个屏幕都重复实现过渡逻辑;缺乏一致性过渡契约——基于 SO 的 IScreenTransition

相关技能

  • unity-ui -- UI Toolkit 基础知识、USS/UXML 编写、VisualElement 层级结构
  • unity-async-patterns -- Awaitable、CancellationToken、异步生命周期管理
  • unity-game-architecture -- 事件通道、Service Locator、ScriptableObject 模式
  • unity-scene-assets -- 适用于每个屏幕对应一个场景的架构的场景加载

其他资源

  • skills/unity-ui-patterns/references/ui-pattern-scaffolds.md -- 所有模式的完整实现
  • Unity Manual:UI Toolkit 运行时用法
  • Unity Manual:ListView 和虚拟化
  • Unity Manual:USS 过渡和动画

评论

支持 Markdown