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

先看最终效果:

一、需求回顾

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

要求:

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

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


二、ScrollView 的结构和滚动原理

Unity 的 ScrollViewScrollRect)典型结构:

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

滚动的原理:

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

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


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

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. 拿到关键组件:

    • contentRectrightPanelContentRectTransform
    • 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 尺寸"的套路。

相关推荐
m0_6265352042 分钟前
代码分析 关于看图像是否包括损坏
java·前端·javascript
李贺梖梖44 分钟前
day06 二维数组、Arrays、System、HuTool、方法
java
pingzhuyan44 分钟前
linux常规(shell脚本)-启动java程序-实现快捷git拉取,maven打包,nohup发布(无dockerfile版)
java·linux·git·maven·shell
小股虫1 小时前
idea编译内存溢出 java: java.lang.OutOfMemoryError: WrappedJavaFileObject[ 解决方案
java·ide·intellij-idea·idea
U***74691 小时前
三大框架-Spring
java·spring·rpc
南部余额1 小时前
深度解析 Spring @Conditional:实现智能条件化配置的利器
java·后端·spring·conditional
计算机毕设指导61 小时前
基于Springboot+微信小程序流浪动物救助管理系统【源码文末联系】
java·spring boot·后端·spring·微信小程序·tomcat·maven
刘晓倩1 小时前
Python的re
java·python·mysql