【Unity UGUI】背包 / 道具列表(Inventory Grid)

文章目录

    • [0. 效果预览](#0. 效果预览)
    • [1. 需求分析](#1. 需求分析)
    • [2. Hierarchy 搭建](#2. Hierarchy 搭建)
    • [3. 核心组件配置](#3. 核心组件配置)
    • [4. 完整代码](#4. 完整代码)
    • [5. 使用方法](#5. 使用方法)
    • [6. 参数说明](#6. 参数说明)
    • [7. 变体与扩展](#7. 变体与扩展)
      • [变体 1:改成交换排序](#变体 1:改成交换排序)
      • [变体 2:双击使用道具](#变体 2:双击使用道具)
      • [变体 3:格子数量固定,道具不足时显示空格](#变体 3:格子数量固定,道具不足时显示空格)
    • [8. 常见问题](#8. 常见问题)
    • [9. 性能 / 适配建议](#9. 性能 / 适配建议)

0. 效果预览

背包列表的核心不是"把图标摆成九宫格",而是把 数据、格子 UI、滚动区域、拖拽排序 四件事拆开。这样后面要做装备栏、商城列表、仓库、技能列表,本质上都能复用同一套结构。


1. 需求分析

核心思路:用 ScrollRect + GridLayoutGroup 负责排版,用一份 List<InventoryItemData> 作为唯一数据源,格子只负责显示和回传点击/拖拽事件。

典型使用场景:

  • RPG 背包 / 仓库 / 装备栏
  • 道具选择弹窗
  • 技能列表、卡牌列表、成就列表
  • 商城商品格子、材料合成列表

本文实现的功能点:

  • GridLayoutGroup 自动排列背包格子
  • ScrollRect 支持多行滚动
  • 用数据列表驱动 UI 刷新,避免手动一个个改格子
  • 点击格子后显示道具详情
  • 拖拽一个格子到另一个格子上,把源道具插入到目标格子前面
  • 空格子、选中态、数量角标都统一处理

前置知识建议先看本系列第 3 篇 InputField 输入框全解 和第 5 篇 ScrollRect 与 Scrollbar 深度用法。本文直接用到 ScrollRect、GridLayoutGroup、UGUI 事件接口和 Raycast Target 的配置。


2. Hierarchy 搭建

整体分成三块:左侧滚动网格、右侧详情面板、拖拽图标层。

复制代码
Canvas
└── Panel_Inventory                         ← 挂 InventoryGridController
    ├── ScrollView_Bag                      ← ScrollRect
    │   ├── Viewport                        ← Image + Mask
    │   │   └── Content                     ← RectTransform + GridLayoutGroup + ContentSizeFitter
    │   │       └── Slot_Prefab             ← InventorySlotUI + Image + CanvasGroup(做成 prefab)
    │   │           ├── Image_Icon          ← Image,道具图标
    │   │           ├── Text_Count          ← Text,数量角标
    │   │           ├── Image_Selected      ← Image,选中边框
    │   │           └── Image_EmptyMask     ← Image,空格子遮罩(可选)
    │   └── Scrollbar_Vertical              ← Scrollbar(可选)
    ├── Panel_Detail
    │   ├── Image_DetailIcon                ← 详情图标
    │   ├── Text_DetailName                 ← 道具名
    │   └── Text_DetailDesc                 ← 道具描述
    └── DragLayer                           ← RectTransform,铺满 Canvas,拖拽图标层
        └── Image_DragIcon                  ← Image,拖拽时显示,默认隐藏

两个关键点:

  1. Slot_Prefab 要做成 prefab,然后从 Content 下移除场景实例,交给脚本运行时生成。
  2. DragLayer 要放在列表和详情面板后面,也就是 Hierarchy 更靠下,这样拖拽图标能盖在所有 UI 上方。

3. 核心组件配置

ScrollView_Bag

对象 组件 关键参数
ScrollView_Bag ScrollRect Content = Content,Viewport = Viewport,Horizontal 关,Vertical 开
Viewport Image Color 可设半透明深色,Raycast Target 开
Viewport Mask Show Mask Graphic 关或开都可以
Content RectTransform Anchor Min (0,1),Anchor Max (1,1),Pivot (0.5,1)
Content GridLayoutGroup Cell Size (86,86),Spacing (8,8),Constraint = Fixed Column Count
Content ContentSizeFitter Vertical Fit = Preferred Size

GridLayoutGroupConstraint 建议固定列数,比如 5 列。背包 UI 通常希望横向格子数稳定,纵向靠滚动扩展。

Slot_Prefab

对象 组件 关键参数
Slot_Prefab Image 作为格子背景,Raycast Target 开
Slot_Prefab CanvasGroup 用于拖拽时临时关闭 blocksRaycasts
Slot_Prefab InventorySlotUI 拖引用:Icon、Count、Selected、EmptyMask
Image_Icon Image Preserve Aspect 开,Raycast Target 关
Text_Count Text 右下角对齐,Raycast Target 关
Image_Selected Image 默认隐藏,Raycast Target 关
Image_EmptyMask Image 默认隐藏,Raycast Target 关

注意:Slot 背景的 Raycast Target 要开,子节点的 Raycast Target 尽量关。

这样点击和拖拽事件都落在 Slot 上,不会被图标、文字、选中框抢掉。


4. 完整代码

InventoryItemData.cs

csharp 复制代码
using System;
using UnityEngine;

/// <summary>
/// 背包道具数据。UI 只读取这份数据,不直接保存业务状态。
/// </summary>
[Serializable]
public class InventoryItemData
{
    public string id;                         // 道具唯一标识,空字符串表示空格子
    public string itemName;                   // 显示名称
    [TextArea] public string description;     // 详情描述
    public Sprite icon;                       // 道具图标
    public int count = 1;                     // 堆叠数量

    public bool IsEmpty => string.IsNullOrEmpty(id) || count <= 0;
}

InventoryGridController.cs

csharp 复制代码
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

/// <summary>
/// 背包网格控制器:负责生成格子、刷新数据、处理选中和拖拽插入排序。
/// </summary>
public class InventoryGridController : MonoBehaviour
{
    // ===== 核心引用 =====
    [Header("核心引用")]
    [SerializeField] private InventorySlotUI _slotPrefab;      // 背包格子 prefab
    [SerializeField] private RectTransform _content;           // ScrollRect 的 Content
    [SerializeField] private RectTransform _dragLayer;         // 拖拽图标所在层,通常铺满 Canvas
    [SerializeField] private Image _dragIcon;                  // 拖拽时跟随鼠标/手指的图标
    [SerializeField] private Sprite _emptyIcon;                // 空格子的默认图标,可留空

    // ===== 详情面板 =====
    [Header("详情面板")]
    [SerializeField] private Image _detailIcon;                // 右侧详情图标
    [SerializeField] private Text _detailNameText;             // 右侧道具名
    [SerializeField] private Text _detailDescText;             // 右侧描述

    // ===== 数据 =====
    [Header("数据")]
    [SerializeField] private List<InventoryItemData> _items = new List<InventoryItemData>();

    private readonly List<InventorySlotUI> _slots = new List<InventorySlotUI>();
    private InventorySlotUI _dragSource;                       // 当前正在拖拽的源格子
    private int _selectedIndex = -1;                            // 当前选中的格子索引

    private void Awake()
    {
        if (_dragIcon != null)
            _dragIcon.gameObject.SetActive(false);
    }

    private void Start()
    {
        Refresh();
    }

    /// <summary>
    /// 外部背包系统可以直接传入新数据并刷新 UI。
    /// </summary>
    public void SetItems(List<InventoryItemData> items)
    {
        _items = items ?? new List<InventoryItemData>();
        _selectedIndex = _items.Count > 0 ? 0 : -1;
        Refresh();
    }

    /// <summary>
    /// 重新生成 / 刷新所有格子。
    /// </summary>
    public void Refresh()
    {
        EnsureSlotCount();

        for (int i = 0; i < _slots.Count; i++)
        {
            bool selected = i == _selectedIndex;
            _slots[i].Bind(this, i, _items[i], selected, _emptyIcon);
        }

        RefreshDetailPanel();

        // 只在批量刷新后重建一次布局,不要每帧调用。
        if (_content != null)
            LayoutRebuilder.ForceRebuildLayoutImmediate(_content);
    }

    /// <summary>
    /// 点击格子时选中并刷新详情。
    /// </summary>
    public void Select(int index)
    {
        if (!IsValidIndex(index)) return;

        _selectedIndex = index;
        Refresh();
    }

    /// <summary>
    /// 开始拖拽。由 InventorySlotUI 调用。
    /// </summary>
    public void BeginDrag(InventorySlotUI source, PointerEventData eventData)
    {
        if (source == null || source.ItemData == null || source.ItemData.IsEmpty)
            return;

        _dragSource = source;

        if (_dragIcon != null)
        {
            _dragIcon.sprite = source.ItemData.icon;
            _dragIcon.color = source.ItemData.icon == null ? new Color(1f, 1f, 1f, 0f) : Color.white;
            _dragIcon.raycastTarget = false;        // 拖拽图标不参与射线,避免挡住目标格子
            _dragIcon.gameObject.SetActive(true);
            UpdateDrag(eventData);
        }
    }

    /// <summary>
    /// 拖拽过程中让图标跟随鼠标 / 手指。
    /// </summary>
    public void UpdateDrag(PointerEventData eventData)
    {
        if (_dragIcon == null || _dragLayer == null || eventData == null)
            return;

        Vector2 localPoint;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            _dragLayer,
            eventData.position,
            eventData.pressEventCamera,
            out localPoint);

        _dragIcon.rectTransform.localPosition = localPoint;
    }

    /// <summary>
    /// 结束拖拽。无论是否成功 Drop,最后都要清掉拖拽图标。
    /// </summary>
    public void EndDrag(InventorySlotUI source, PointerEventData eventData)
    {
        if (_dragIcon != null)
            _dragIcon.gameObject.SetActive(false);

        _dragSource = null;
    }

    /// <summary>
    /// 拖到目标格子上时,把源道具插入到目标格子前面。
    /// </summary>
    public void DropOn(InventorySlotUI target, PointerEventData eventData)
    {
        InventorySlotUI source = _dragSource;
        if (source == null && eventData != null && eventData.pointerDrag != null)
            source = eventData.pointerDrag.GetComponent<InventorySlotUI>();

        if (source == null || target == null || target == source)
            return;

        int from = source.Index;
        int targetIndex = target.Index;
        if (!IsValidIndex(from) || !IsValidIndex(targetIndex))
            return;

        int insertIndex = targetIndex;
        if (from < insertIndex)
            insertIndex--;

        if (insertIndex == from)
            return;

        InventoryItemData movingItem = _items[from];
        _items.RemoveAt(from);
        _items.Insert(insertIndex, movingItem);

        _selectedIndex = ResolveSelectedIndexAfterInsert(_selectedIndex, from, insertIndex);

        Refresh();
    }

    private void EnsureSlotCount()
    {
        if (_slotPrefab == null || _content == null)
            return;

        while (_slots.Count < _items.Count)
        {
            InventorySlotUI slot = Instantiate(_slotPrefab, _content);
            slot.gameObject.SetActive(true);
            _slots.Add(slot);
        }

        for (int i = 0; i < _slots.Count; i++)
            _slots[i].gameObject.SetActive(i < _items.Count);
    }

    private void RefreshDetailPanel()
    {
        InventoryItemData item = IsValidIndex(_selectedIndex) ? _items[_selectedIndex] : null;
        bool hasItem = item != null && !item.IsEmpty;

        if (_detailIcon != null)
        {
            _detailIcon.sprite = hasItem ? item.icon : _emptyIcon;
            _detailIcon.color = hasItem && item.icon != null ? Color.white : new Color(1f, 1f, 1f, 0.25f);
        }

        if (_detailNameText != null)
            _detailNameText.text = hasItem ? item.itemName : "未选择道具";

        if (_detailDescText != null)
            _detailDescText.text = hasItem ? item.description : "点击左侧格子查看道具详情。";
    }

    private bool IsValidIndex(int index)
    {
        return index >= 0 && index < _items.Count;
    }

    private int ResolveSelectedIndexAfterInsert(int selectedIndex, int from, int insertIndex)
    {
        if (selectedIndex < 0)
            return selectedIndex;

        if (selectedIndex == from)
            return insertIndex;

        if (from < insertIndex && selectedIndex > from && selectedIndex <= insertIndex)
            return selectedIndex - 1;

        if (from > insertIndex && selectedIndex >= insertIndex && selectedIndex < from)
            return selectedIndex + 1;

        return selectedIndex;
    }
}

InventorySlotUI.cs

csharp 复制代码
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

/// <summary>
/// 单个背包格子:只负责显示和把点击/拖拽事件回传给 InventoryGridController。
/// </summary>
public class InventorySlotUI : MonoBehaviour,
    IPointerClickHandler,
    IBeginDragHandler,
    IDragHandler,
    IEndDragHandler,
    IDropHandler
{
    // ===== Inspector 引用 =====
    [Header("引用")]
    [SerializeField] private Image _iconImage;         // 道具图标
    [SerializeField] private Text _countText;          // 数量角标
    [SerializeField] private Image _selectedFrame;     // 选中边框
    [SerializeField] private GameObject _emptyMask;    // 空格子遮罩,可选

    private InventoryGridController _owner;
    private CanvasGroup _canvasGroup;

    public int Index { get; private set; }
    public InventoryItemData ItemData { get; private set; }

    private void Awake()
    {
        _canvasGroup = GetComponent<CanvasGroup>();
        if (_canvasGroup == null)
            _canvasGroup = gameObject.AddComponent<CanvasGroup>();
    }

    /// <summary>
    /// 绑定数据并刷新显示。
    /// </summary>
    public void Bind(
        InventoryGridController owner,
        int index,
        InventoryItemData itemData,
        bool selected,
        Sprite emptyIcon)
    {
        _owner = owner;
        Index = index;
        ItemData = itemData;

        bool hasItem = itemData != null && !itemData.IsEmpty;

        if (_iconImage != null)
        {
            _iconImage.sprite = hasItem ? itemData.icon : emptyIcon;
            _iconImage.color = hasItem && itemData.icon != null ? Color.white : new Color(1f, 1f, 1f, 0.25f);
        }

        if (_countText != null)
        {
            bool showCount = hasItem && itemData.count > 1;
            _countText.gameObject.SetActive(showCount);
            _countText.text = showCount ? itemData.count.ToString() : string.Empty;
        }

        if (_selectedFrame != null)
            _selectedFrame.gameObject.SetActive(selected);

        if (_emptyMask != null)
            _emptyMask.SetActive(!hasItem);

        if (_canvasGroup != null)
        {
            _canvasGroup.alpha = 1f;
            _canvasGroup.blocksRaycasts = true;
        }
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        _owner?.Select(Index);
    }

    public void OnBeginDrag(PointerEventData eventData)
    {
        if (ItemData == null || ItemData.IsEmpty)
            return;

        if (_canvasGroup != null)
        {
            _canvasGroup.alpha = 0.55f;          // 源格子半透明,表示正在拖动
            _canvasGroup.blocksRaycasts = false; // 让鼠标/手指射线穿透到目标格子
        }

        _owner?.BeginDrag(this, eventData);
    }

    public void OnDrag(PointerEventData eventData)
    {
        _owner?.UpdateDrag(eventData);
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        if (_canvasGroup != null)
        {
            _canvasGroup.alpha = 1f;
            _canvasGroup.blocksRaycasts = true;
        }

        _owner?.EndDrag(this, eventData);
    }

    public void OnDrop(PointerEventData eventData)
    {
        _owner?.DropOn(this, eventData);
    }
}

5. 使用方法

  1. 按第 2 节搭好 Panel_InventoryScrollView_BagContentPanel_DetailDragLayer
  2. Slot_Prefab 做成 prefab,保留 ImageCanvasGroupInventorySlotUI,并拖好 Icon / Count / Selected / EmptyMask 引用。
  3. 删除场景里 Content 下的 Slot_Prefab 实例,只保留 Project 面板中的 prefab。
  4. Panel_InventoryInventoryGridController
  5. InventoryGridController 上拖引用:
    • Slot PrefabSlot_Prefab
    • Content → ScrollView 下的 Content
    • Drag LayerDragLayer
    • Drag IconImage_DragIcon
    • Detail Icon / Detail Name / Detail Desc → 右侧详情面板对应组件
  6. _items 列表里添加测试数据,填 iditemNamedescriptioniconcount
  7. 运行后点击格子会刷新详情,拖动一个有道具的格子到另一个格子上,会把源道具插入到目标格子前面。

如果你的背包数据来自业务系统,不要直接在 Inspector 里填 _items,而是在背包数据加载完成后调用:

csharp 复制代码
inventoryGridController.SetItems(runtimeItems);

6. 参数说明

参数 类型 默认值 说明
_slotPrefab InventorySlotUI 背包格子 prefab
_content RectTransform ScrollRect 的 Content,用来承载所有格子
_dragLayer RectTransform 拖拽图标层,建议铺满 Canvas
_dragIcon Image 拖拽时跟随鼠标 / 手指的临时图标
_emptyIcon Sprite null 空格子默认图标
_detailIcon Image 详情面板图标
_detailNameText Text 详情面板名称文本
_detailDescText Text 详情面板描述文本
_items List 空列表 背包数据源

7. 变体与扩展

变体 1:改成交换排序

现在的逻辑是"拖到目标格子前面插入"。如果你的背包是固定格子,更想做两个位置互换,可以把 DropOn 里的移动逻辑改成:

csharp 复制代码
InventoryItemData temp = _items[from];
_items[from] = _items[to];
_items[to] = temp;

插入排序更适合技能栏、队伍编排;交换排序更适合格子位置有强约束的背包。

变体 2:双击使用道具

可以在 InventorySlotUI 里记录上次点击时间,连续点击同一格触发 UseItem

csharp 复制代码
private float _lastClickTime;

public void OnPointerClick(PointerEventData eventData)
{
    if (Time.unscaledTime - _lastClickTime < 0.3f)
    {
        // 通知背包系统使用道具
    }

    _lastClickTime = Time.unscaledTime;
    _owner?.Select(Index);
}

注意使用道具属于业务逻辑,不建议直接写在 Slot UI 里,最好继续回传给背包系统处理。

变体 3:格子数量固定,道具不足时显示空格

如果背包有 40 个固定格子,但实际只有 12 个道具,可以让 _items 始终补齐 40 条数据,空位用 new InventoryItemData() 表示。这样 UI 逻辑不需要关心"格子数"和"道具数"的差异。


8. 常见问题

Q:拖拽时 OnDrop 不触发?

A:先查三个点:Slot 背景 Image 的 Raycast Target 是否开启;源 Slot 拖拽时 CanvasGroup.blocksRaycasts 是否被设为 false;拖拽图标 _dragIcon.raycastTarget 是否关闭。任何一层挡住射线,目标格子都收不到 Drop。

Q:Content 不随格子数量变高,滚动不了?

A:Content 上要有 GridLayoutGroupContentSizeFitter,并且 ContentSizeFitter.Vertical Fit = Preferred Size。如果 Content 高度没变化,ScrollRect 就认为没有可滚动内容。

Q:格子顺序看起来乱了?

A:GridLayoutGroup 是按 Content 子节点顺序排版的。本文的做法是固定 Slot 实例顺序,只移动 _items 数据,再重新 Bind,所以视觉顺序由数据列表决定,不要在运行时手动拖动 Hierarchy 顺序。

Q:点击图标没反应,但点背景有反应?

A:通常是子节点 Image / Text 的 Raycast Target 开着,事件被子节点截走了。Icon、Count、Selected、EmptyMask 的 Raycast Target 都建议关闭,只让 Slot 背景接收事件。

Q:运行后格子全部空白?

A:检查 _items 里的 idcount。本文用 string.IsNullOrEmpty(id) || count <= 0 判断空格子,id 没填会被当成空格。


9. 性能 / 适配建议

  • 不要每帧 Refresh:背包只有在获得道具、消耗道具、排序、切页时才需要刷新。每帧重绑数据会频繁触发布局重建。
  • 批量改数据后统一刷新一次 :连续添加 10 个道具时,先改 _items,最后只调用一次 Refresh()
  • 子节点关闭 Raycast Target:只保留 Slot 背景接收点击,图标和文字不参与射线检测,可以减少 GraphicRaycaster 的遍历成本。
  • 背包 Canvas 可以独立出来:如果背包里格子很多,打开/刷新时会触发 Canvas Rebuild。把背包面板和常驻 HUD 分开,避免影响主界面。
  • 超大列表考虑虚拟滚动:几百个格子还可以直接 GridLayoutGroup;几千个道具就该做复用池和虚拟滚动。后续"高性能无限滚动列表"会专门讲这一块。