【Unity基础】实现Scroll View跟随动态内容滚动

先看最终效果:

一、需求回顾

在场景中,我们先添加一个Scroll View和两个Button。

要求:

  • 点 GenerateButton:在 Scroll View 里生成 20 个按钮,文字是 1~20,可以上下滚动看到所有按钮。
  • 点 ClearButton:清空 Scroll View 内容,必要时滚动条也要跟着内容变化(没有超出就不需要滚动条)。

这个过程的核心,其实就是:让 ScrollRect 的 Content 大小跟着内容数量动态变化,从而实现"跟随内容滚动"。


二、ScrollView 的结构和滚动原理

Unity 的 ScrollView(ScrollRect)典型结构:

  • ScrollView(带 ScrollRect 组件)
    • Viewport(带 Mask + Image)
      • Content(RectTransform,这里再挂 GridLayoutGroup)

滚动的原理:

  • ScrollRect 不直接管"有多少子物体",它只看:
    • Viewport.rect(可视区域大小)
    • Content.rect(内容总高度/宽度)
  • 当 Content 比 Viewport 更大时,才有可滚动区域;否则滚动条不会出现 / 不需要滚动。

也就是说:只往 Content 下面加子物体,不保证能滚动;必须同时让 Content 的 RectTransform 尺寸变大。


三、第一步:生成和清空按钮

在 GridLayoutDemo.cs 中,先实现最基本的生成 / 清空逻辑:

1. 序列化字段(右侧)

csharp 复制代码
[SerializeField] private Button generateButton;
[SerializeField] private Button clearButton;
[SerializeField] private Transform rightPanelContent; // Content
[SerializeField] private Button buttonPrefab;
[SerializeField] private ScrollRect rightPanelScrollRect;

2. 生成 20 个按钮

csharp 复制代码
private void OnGenerateClicked()
{
    if (rightPanelContent == null || buttonPrefab == null)
        return;

    for (int i = 1; i <= 20; i++)
    {
        Button newButton = Instantiate(buttonPrefab, rightPanelContent);
        newButton.name = $"Button_{i}";

        string label = i.ToString();
        TMP_Text tmpText = newButton.GetComponentInChildren<TMP_Text>();
        if (tmpText != null)
            tmpText.text = label;
        else
        {
            Text uiText = newButton.GetComponentInChildren<Text>();
            if (uiText != null)
                uiText.text = label;
        }
    }

    // 后面再补:UpdateContentSize();
}

3. 清空按钮

csharp 复制代码
private void OnClearClicked()
{
    if (rightPanelContent == null)
        return;

    for (int i = rightPanelContent.childCount - 1; i >= 0; i--)
    {
        Destroy(rightPanelContent.GetChild(i).gameObject);
    }

    // 后面再补:UpdateContentSize();
}

此时按钮可以生成和删除,但有两个问题:

  1. 按钮多了之后,不能正常滚动到全部内容。
  2. 清空后,Content 大小没变,滚动条还在。

根源都是:Content 的高度还在用一个固定值(场景里原来的 300),没有随内容数量变化。


四、关键:动态计算 Content 高度

为了解决滚动问题,我们在脚本中实现了 UpdateContentSize 方法:

根据 GridLayoutGroup 的配置和子物体数量,计算出应该的 Content 高度。

方法签名(最终版):

csharp 复制代码
// overrideChildCount >= 0 时使用传入值(如清空后传 0),否则使用实际 childCount
private void UpdateContentSize(int overrideChildCount = -1)

实现思路:

  1. 拿到关键组件:

    • contentRect:rightPanelContent 的 RectTransform
    • grid:挂在 Content 上的 GridLayoutGroup
    • viewportRect:Content 的父物体(Viewport)的 RectTransform
  2. 决定"有多少个元素":

    • 大部分情况用 contentRect.childCount
    • 清空时会主动传 0(后面详讲 Destroy 延迟问题)
  3. 计算列数和行数:

    • 列数由 Viewport 宽度决定(避免写死):

      csharp 复制代码
      float width = viewportRect.rect.width;
      float cellWidth = grid.cellSize.x;
      float spacingX = grid.spacing.x;
      int columns = Mathf.Max(
          1,
          Mathf.FloorToInt(
              (width - grid.padding.left - grid.padding.right + spacingX) /
              (cellWidth + spacingX)
          )
      );
    • 行数:

      csharp 复制代码
      int rows = childCount == 0 ? 0 : Mathf.CeilToInt(childCount / (float)columns);
  4. 计算高度:

    csharp 复制代码
    float cellHeight = grid.cellSize.y;
    float spacingY = grid.spacing.y;
    float height = grid.padding.top + grid.padding.bottom;
    if (rows > 0)
    {
        height += rows * cellHeight + (rows - 1) * spacingY;
    }
  5. Content 最终高度 = "计算出的高度" 和 "Viewport 高度" 的较大值:

    • 没有内容时也至少等于 Viewport 高度,避免缩成一条线。
    csharp 复制代码
    float minHeight = viewportRect.rect.height;
    contentRect.SetSizeWithCurrentAnchors(
        RectTransform.Axis.Vertical,
        Mathf.Max(height, minHeight)
    );
  6. 立即刷新布局:

    csharp 复制代码
    LayoutRebuilder.ForceRebuildLayoutImmediate(contentRect);

完整方法(核心部分):

csharp 复制代码
private void UpdateContentSize(int overrideChildCount = -1)
{
    if (rightPanelContent == null) return;

    RectTransform contentRect = rightPanelContent as RectTransform;
    if (contentRect == null) return;

    GridLayoutGroup grid = rightPanelContent.GetComponent<GridLayoutGroup>();
    if (grid == null) return;

    RectTransform viewportRect = contentRect.parent as RectTransform;
    if (viewportRect == null) return;

    int childCount = overrideChildCount >= 0 ? overrideChildCount : contentRect.childCount;

    float width = viewportRect.rect.width;
    float cellWidth = grid.cellSize.x;
    float spacingX = grid.spacing.x;
    int columns = Mathf.Max(
        1,
        Mathf.FloorToInt(
            (width - grid.padding.left - grid.padding.right + spacingX) /
            (cellWidth + spacingX)
        )
    );

    int rows = childCount == 0 ? 0 : Mathf.CeilToInt(childCount / (float)columns);

    float cellHeight = grid.cellSize.y;
    float spacingY = grid.spacing.y;
    float height = grid.padding.top + grid.padding.bottom;
    if (rows > 0)
    {
        height += rows * cellHeight + (rows - 1) * spacingY;
    }

    float minHeight = viewportRect.rect.height;
    contentRect.SetSizeWithCurrentAnchors(
        RectTransform.Axis.Vertical,
        Mathf.Max(height, minHeight)
    );

    LayoutRebuilder.ForceRebuildLayoutImmediate(contentRect);
}

五、让滚动条"跟随内容",并回到顶部

当我们在生成 / 清空按钮后调用:

csharp 复制代码
UpdateContentSize();
if (rightPanelScrollRect != null)
{
    rightPanelScrollRect.verticalNormalizedPosition = 1f; // 回到顶部
}

效果是:

  • UpdateContentSize 让 Content 的高度与内容数量匹配;
  • ScrollRect 现在知道"内容比 Viewport 高",于是可以滚动;
  • 通过 verticalNormalizedPosition = 1f 把滚动位置设置到顶部(0f 是底部)。

这一步就实现了"随着内容多少自动出现/扩展滚动区域"。


六、"必须点两次 Clear 才生效"的坑:Destroy 是延迟的

实际运行时,我们遇到一个细节问题:

  • 第一次点 ClearButton:
    • 视觉上按钮全没了;
    • 但 Content 高度并没有立刻变小,滚动条仍在。
  • 第二次再点 ClearButton:
    • 这时 Content 高度才按"0 个元素"计算,滚动条消失。

原因:Unity 的 Destroy 并不会立刻把子物体从层级中移除,它会在当前帧结束 后统一执行。

所以在第一次点击中:

  1. Destroy 排队,子物体还被视作"存在";
  2. 紧接着调用 UpdateContentSize(),里面通过 contentRect.childCount 看到的还是旧值(例如 20);
  3. Content 高度还是按有 20 个元素的情况算,滚动条自然也还在。

解决思路:在清空逻辑中,不依赖 childCount,而是主动告诉 UpdateContentSize:"现在应该按 0 个元素算"。

于是我们做了两个调整:

  1. UpdateContentSize 增加 overrideChildCount 参数(前面已展示);
  2. 在 OnClearClicked 中这样调用:
csharp 复制代码
private void OnClearClicked()
{
    if (rightPanelContent == null)
        return;

    int childCount = rightPanelContent.childCount;
    for (int i = childCount - 1; i >= 0; i--)
    {
        Destroy(rightPanelContent.GetChild(i).gameObject);
    }

    // 清空后更新 Content 尺寸并回到顶部
    // 这里传入 0,因为 Destroy 是延迟执行的
    UpdateContentSize(0);
    if (rightPanelScrollRect != null)
    {
        rightPanelScrollRect.verticalNormalizedPosition = 1f;
    }
}

这样:

  • 虽然 GameObject 真正被移除要等到这一帧结束,
  • 但布局计算我们不等,直接当作 0 个元素来算;
  • Content 高度立刻变成与 Viewport 一样高,滚动条瞬间消失,第一次点击就生效。

七、小结:实现"跟随内容滚动"的关键点

  1. ScrollRect 是否能滚动,取决于 Content 的 RectTransform 尺寸,而不是子物体数量本身。
  2. 使用 GridLayoutGroup 时,内容高度通常需要手动计算(行数 × cellSize + spacing + padding)。
  3. 每次内容数量变化(生成/清空)后,调用类似 UpdateContentSize 的方法,动态调整 Content 高度。
  4. 使用 LayoutRebuilder.ForceRebuildLayoutImmediate 可以让布局立刻刷新,避免下一帧才生效的视觉延迟。
  5. 清空内容时注意:Destroy 延迟执行,所以需要用"预期的子物体数"(这里是 0)来计算布局,而不是依赖 childCount 的即时值。
  6. 通过 ScrollRect.verticalNormalizedPosition 可以在更新后把滚动位置重置到顶部或底部,改善用户体验。

如果之后要扩展到:

  • 横向滚动(只算宽度);
  • 无限加载 / 分页加载(每次加载更多后更新 Content 尺寸);

也可以沿用同一套"根据布局 + 元素数量动态调整 Content 尺寸"的套路。

相关推荐
weixin_460443569 小时前
2026年新修订《网络安全法》实施:Java私有化考试系统如何做好SBOM、漏洞治理与安全升级?
java·开发语言·在线考试系统·企业培训考试系统·宏远培训考试系统·国企培训考试系统·国产化培训系统
海鸥-w12 小时前
什么是AOP
java
Cc.Y12 小时前
Java零基础入门:可变字符串与包装类:StringBuilder、StringBuffer 与 通讯录管理系统实战
java·开发语言·python
paopaokaka_luck12 小时前
基于springboot+vue的智慧家政管理系统(AI问答、 WebSocket实时沟通、ECharts运营分析、订单规则与服务追踪)
java·javascript·spring boot·数据分析·echarts·mybatis
sm_9267870512 小时前
RFID 标签打印的技术实现要点与二次开发实践
java·大数据·前端·c++·编辑器
阿森_dev12 小时前
2027 届毕设选题怎么选:30 个 Java 方向选题的实现难度排序
java·开发语言·spring boot·毕业设计·课程设计
Sarvartha12 小时前
Lambda 表达式和函数式接口
java·开发语言
计算机毕业设计杰瑞13 小时前
【计算机毕设精品】基于SpringBoot的一体化教务与学业质量管理系统的设计与实现,附源码_增删改查_java项目_可完美运行部署_网站项目
java·spring boot·课程设计
colinu13 小时前
C++ 异步任务与线程生命周期:`std::async`、`future`、`thread`、`jthread` 与协作式退出
java·jvm·c++
蜗牛互联网13 小时前
Python + JSON Schema 实现工单结构化输出与本地复核
java·人工智能·后端