文章目录
-
- [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,拖拽时显示,默认隐藏

两个关键点:
Slot_Prefab要做成 prefab,然后从Content下移除场景实例,交给脚本运行时生成。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 |
GridLayoutGroup 的 Constraint 建议固定列数,比如 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. 使用方法
- 按第 2 节搭好
Panel_Inventory、ScrollView_Bag、Content、Panel_Detail和DragLayer。 - 把
Slot_Prefab做成 prefab,保留Image、CanvasGroup、InventorySlotUI,并拖好 Icon / Count / Selected / EmptyMask 引用。 - 删除场景里
Content下的Slot_Prefab实例,只保留 Project 面板中的 prefab。 - 给
Panel_Inventory挂InventoryGridController。 - 在
InventoryGridController上拖引用:Slot Prefab→Slot_PrefabContent→ ScrollView 下的ContentDrag Layer→DragLayerDrag Icon→Image_DragIconDetail Icon / Detail Name / Detail Desc→ 右侧详情面板对应组件
- 在
_items列表里添加测试数据,填id、itemName、description、icon、count。 - 运行后点击格子会刷新详情,拖动一个有道具的格子到另一个格子上,会把源道具插入到目标格子前面。
如果你的背包数据来自业务系统,不要直接在 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 上要有 GridLayoutGroup 和 ContentSizeFitter,并且 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 里的 id 和 count。本文用 string.IsNullOrEmpty(id) || count <= 0 判断空格子,id 没填会被当成空格。
9. 性能 / 适配建议
- 不要每帧 Refresh:背包只有在获得道具、消耗道具、排序、切页时才需要刷新。每帧重绑数据会频繁触发布局重建。
- 批量改数据后统一刷新一次 :连续添加 10 个道具时,先改
_items,最后只调用一次Refresh()。 - 子节点关闭 Raycast Target:只保留 Slot 背景接收点击,图标和文字不参与射线检测,可以减少 GraphicRaycaster 的遍历成本。
- 背包 Canvas 可以独立出来:如果背包里格子很多,打开/刷新时会触发 Canvas Rebuild。把背包面板和常驻 HUD 分开,避免影响主界面。
- 超大列表考虑虚拟滚动:几百个格子还可以直接 GridLayoutGroup;几千个道具就该做复用池和虚拟滚动。后续"高性能无限滚动列表"会专门讲这一块。