Как вложить ListView внутрь Column во Flutter без ошибки неограниченной высоты
Почему ListView внутри Column выбрасывает 'Vertical viewport was given unbounded height', и четыре решения (Expanded, Flexible, shrinkWrap, SizedBox) с компромиссами по производительности, которые определяют, какое вам нужно.
Поместите ListView прямо внутрь Column, и Flutter выбросит ошибку ещё до того, как отрисует хоть один пиксель: Vertical viewport was given unbounded height, обычно с последующей стеной диагностик RenderBox. Короткий ответ: Column передаёт своим дочерним элементам неограниченное вертикальное пространство, а прокручиваемый ListView отказывается рендериться в бесконечную высоту, потому что не будет иметь ни малейшего понятия, какой высоты ему быть. Вы исправляете это, задавая списку ограниченную высоту, и правильный инструмент почти всегда Expanded (заполнить оставшееся пространство), когда список — единственный прокручиваемый элемент, или shrinkWrap: true, когда список короткий и конечный. Этот пост объясняет, почему возникает ошибка, показывает минимальное воспроизведение и разбирает все четыре решения во Flutter 3.x (проверено на 3.44), чтобы вы выбрали то, что подходит, а не просто то, что заглушает исключение.
Почему Column даёт своим дочерним элементам неограниченную высоту
Layout во Flutter работает по единственному правилу: ограничения идут вниз, размеры идут вверх. Родитель сообщает каждому дочернему элементу минимальную и максимальную ширину и высоту, которую тот может занять, дочерний элемент выбирает размер в этих пределах, а родитель его позиционирует. Весь фреймворк это то самое рукопожатие, повторяемое вниз по дереву.
Column это виджет Flex, расположенный вдоль вертикальной оси. Вдоль своей главной оси (вертикальной) он не накладывает максимальную высоту на свои нефлексибельные дочерние элементы. Он сообщает каждому дочернему элементу, по сути: “будь такой высоты, какой захочешь, я сложу тебя в стопку и измерю общую сумму потом”. В терминах ограничений дочерний элемент получает maxHeight: double.infinity. Именно это и означает “unbounded” (неограниченный): входящее ограничение высоты не имеет конечного максимума.
Большинство виджетов с этим в порядке. Text, Row, Icon, Container с дочерними элементами — все они задают свой размер по содержимому, поэтому бесконечный потолок никогда не имеет значения. Они сообщают обратно конкретную высоту, а Column её суммирует.
ListView — другое дело. Это прокручиваемый viewport, и вся работа viewport заключается в том, чтобы быть фиксированным окном над содержимым, которое может быть намного больше него самого. Для этого ему нужно знать, какой высоты это окно. Вдоль своей оси прокрутки (вертикальной) ListView пытается растянуться, чтобы заполнить всю предложенную ему высоту. Предложите ему бесконечность — и он попытается стать бесконечно высоким, что сводит на нет сам смысл прокрутки и не может быть размещено. Поэтому вместо того, чтобы молча создать сломанный layout, фреймворк выбрасывает assertion:
The following assertion was thrown during performResize():
Vertical viewport was given unbounded height.
Viewports expand in the scrolling direction to fill their container. In this
case, a vertical viewport was given an unlimited amount of vertical space in
which to expand.
Проще говоря: Column говорит “бери столько высоты, сколько хочешь”, ListView говорит “я работаю, только если ты скажешь мне точно, сколько высоты я получаю”, и эти двое несовместимы, пока вы не вставите виджет, который разрешит высоту. Это то же семейство сбоев, что и RenderBox was not laid out, где отсутствующее ограничение размера останавливает layout до отрисовки.
Минимальное воспроизведение
Вот самый маленький виджет, который это воспроизводит. Ничего экзотического, просто заголовок, сложенный над списком:
// Flutter 3.x (tested 3.44), Dart 3.x
class ProfilePage extends StatelessWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
const Text('Recent activity'),
ListView(
children: const [
ListTile(title: Text('Item 1')),
ListTile(title: Text('Item 2')),
ListTile(title: Text('Item 3')),
],
),
],
);
}
}
Запустите его, и вы получите assertion о неограниченной высоте в тот момент, когда это поддерево выполняет свой layout. Column предложил ListView бесконечную высоту; ListView сдался. Всё, что ниже, это способ изменить, какую высоту получает ListView.
Решение 1: Expanded, когда список должен заполнить оставшееся пространство
Это решение, которое вам нужно чаще всего. Expanded это flex-дочерний элемент, который сообщает Column отдать ему всё вертикальное пространство, оставшееся после измерения нефлексибельных дочерних элементов. Поскольку “всё оставшееся пространство” — конкретное число, как только Column знает свою собственную высоту, ListView теперь получает ограниченный maxHeight и выполняет свой layout нормально, сохраняя полную ленивую прокрутку.
// Flutter 3.x (tested 3.44)
Column(
children: [
const Text('Recent activity'),
Expanded(
child: ListView(
children: const [
ListTile(title: Text('Item 1')),
ListTile(title: Text('Item 2')),
ListTile(title: Text('Item 3')),
],
),
),
],
)
Механика: Expanded задаёт flex-фактор своего дочернего элемента и принудительно устанавливает жёсткую высоту, равную его доле свободного пространства. Column сначала измеряет Text, вычитает его из собственной высоты и передаёт остаток Expanded, который передаёт его вниз как ограниченное ограничение. ListView заполняет ровно это окно и прокручивает свои дочерние элементы внутри него. Это правильный выбор всегда, когда список — основная прокручиваемая область экрана, и вы хотите, чтобы он рос и сжимался с доступной высотой, что является подавляюще распространённым случаем (заголовок над лентой, поле формы над результатами, заголовок над журналом чата).
Одно требование: сам Column должен иметь ограниченную высоту, чтобы Expanded было что делить. Внутри body у Scaffold, в SizedBox с высотой или в любом родителе, который уже ограничивает вертикальное пространство, она есть. Если Column сам находится внутри другого неограниченного контекста, вы протолкнули ту же проблему на уровень выше, и вам нужно сначала ограничить внешний бокс.
Если вы хотите, чтобы список занял оставшееся пространство, но также имел право сжаться ниже своей доли, когда содержимого мало, используйте Flexible вместо Expanded. Expanded это Flexible с fit: FlexFit.tight; обычный Flexible использует FlexFit.loose, что означает “вплоть до этого, но не больше, чем нужно твоему содержимому”. Для ListView, который хочет заполнить свою ось, оба на практике ведут себя одинаково, так что тянитесь к Expanded, если только у вас нет смешанного layout, где важна нежёсткость.
Решение 2: shrinkWrap, когда список короткий и конечный
Если у списка небольшое, более-менее известное число элементов, и вы хотите, чтобы он был точно такой же высоты, как его содержимое (чтобы Column мог складывать больше виджетов под ним), задайте shrinkWrap: true:
// Flutter 3.x (tested 3.44)
Column(
children: [
const Text('Recent activity'),
ListView(
shrinkWrap: true,
children: const [
ListTile(title: Text('Item 1')),
ListTile(title: Text('Item 2')),
ListTile(title: Text('Item 3')),
],
),
const Text('End of list'),
],
)
shrinkWrap: true переворачивает поведение viewport: вместо того чтобы растягиваться и заполнять свою ось, он измеряет все свои дочерние элементы, суммирует их высоты и задаёт свой размер по этой сумме. Теперь он сообщает конечную высоту вверх Column, assertion исчезает, и вы можете размещать виджеты как над ним, так и под ним.
Цена реальна, и её стоит понимать. Обычный ListView ленив: он строит и выполняет layout только для элементов, видимых в данный момент в viewport, плюс небольшой кеш. Именно это удерживает список из 10 000 строк на 60fps. shrinkWrap: true отбрасывает это, потому что чтобы узнать свою общую высоту, viewport должен построить и измерить все свои дочерние элементы заранее. Для горстки элементов это ничто. Для длинного или неограниченного списка это означает построение тысяч виджетов в первом кадре, что взвинчивает время layout и вызывает подтормаживания, которые вы можете наблюдать на timeline (см. как профилировать подтормаживания в приложении Flutter с помощью DevTools). Список с shrinkWrap также не прокручивается независимо в обычном смысле; он растёт, чтобы вместиться, и если весь Column переполняется, вы возвращаетесь к предупреждению RenderFlex overflowed. Практическое правило: shrinkWrap — для коротких, ограниченных списков (несколько строк настроек, фиксированное меню), а не для лент.
Если вы используете shrinkWrap и всё же хотите, чтобы внешний Column прокручивался, когда всё вместе слишком высокое, оберните Column в SingleChildScrollView и задайте внутреннему ListView physics: const NeverScrollableScrollPhysics(), чтобы два прокручиваемых элемента не боролись:
// Flutter 3.x (tested 3.44)
SingleChildScrollView(
child: Column(
children: [
const Text('Recent activity'),
ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: const [
ListTile(title: Text('Item 1')),
ListTile(title: Text('Item 2')),
],
),
],
),
)
Решение 3: SizedBox, когда вы знаете точную высоту
Если список занимает фиксированную часть экрана, оберните его в SizedBox с конкретной высотой. Это самый прямой ответ на ошибку: ListView спросил, какой высоты ему быть, и вы ему сказали.
// Flutter 3.x (tested 3.44)
Column(
children: [
const Text('Recent activity'),
SizedBox(
height: 240,
child: ListView(
children: const [
ListTile(title: Text('Item 1')),
ListTile(title: Text('Item 2')),
ListTile(title: Text('Item 3')),
],
),
),
],
)
SizedBox накладывает maxHeight: 240 на ListView, который заполняет эти 240 логических пикселей и лениво прокручивает своё содержимое внутри них, сохраняя выигрыш в производительности, который shrinkWrap отдаёт. Это правильно для горизонтальных каруселей (ряд карточек фиксированной высоты внутри вертикального Column) и любого дизайна, где высота списка — намеренная константа. Недостаток — магическое число: жёстко заданные высоты не адаптируются к разным размерам экрана или настройкам масштаба текста, так что избегайте этого для списка, который должен заполнять оставшееся пространство. Для этого Expanded — адаптивная версия той же идеи.
Решение 4: заменить Column на CustomScrollView со слайверами
Когда “column” на самом деле — прокручивающаяся страница, которая просто содержит список, самая чистая структура — вовсе не Column с вложенным ListView. Это единственный CustomScrollView, разделами которого являются слайверы. Один viewport, одна физика прокрутки, полная ленивость, никаких конфликтов вложенности:
// Flutter 3.x (tested 3.44)
CustomScrollView(
slivers: [
const SliverToBoxAdapter(
child: Text('Recent activity'),
),
SliverList.builder(
itemCount: 3,
itemBuilder: (context, index) => ListTile(
title: Text('Item ${index + 1}'),
),
),
],
)
Слайвер это прокручиваемая область, которая напрямую договаривается с родительским viewport о том, сколько она отрисовывает, поэтому нет никакого рукопожатия “неограниченной высоты”, которое могло бы провалиться. SliverToBoxAdapter оборачивает обычные box-виджеты (ваш заголовок), а SliverList.builder это ленивый список. Тянитесь к этому, когда экран имеет несколько сложенных прокручиваемых разделов, заголовок, который должен прокручиваться из виду, или список плюс сетку в одной непрерывной прокрутке. Это многословнее, чем Column, но это тот layout, который Flutter действительно хочет для этой формы, и он никогда не заставляет вас выбирать между корректностью и ленивостью.
Какое решение я использую на самом деле
- Список — основное содержимое под заголовком, должен заполнить экран:
Expanded. Сохраняет ленивость, адаптируется к любой высоте. - Несколько фиксированных элементов, вы хотите виджеты сверху и снизу в том же
Column:shrinkWrap: true. Дёшево, потому что список короткий. - Списку нужна конкретная фиксированная высота (карусель, мини-список):
SizedBox(height: ...). Сохраняет ленивость, явно и просто. - Весь экран это прокрутка с несколькими разделами:
CustomScrollViewсо слайверами. Структурно правильный ответ.
Ловушка, которой стоит избегать, — рефлекторно тянуться к shrinkWrap: true, потому что это самый короткий diff. Он заставляет красную ошибку исчезнуть, но на длинном списке он молча меняет громкий assertion layout на тихую регрессию производительности: каждая строка построена в первом кадре, потерянные кадры при загрузке и память, которая масштабируется с количеством элементов, а не с размером viewport. Если список может расти, используйте Expanded или слайверы, чтобы фреймворк мог продолжать переиспользовать строки. Связанные заметки по отладке живут в Fix: RenderBox was not laid out и Fix: A RenderFlex overflowed это две ошибки, на которые вы, скорее всего, наткнётесь следующими, пока это собираете. И если список использует ScrollController, не забудьте освободить его, чтобы не допустить утечки при демонтировании виджета.
Однострочная ментальная модель, которую стоит запомнить
Column даёт неограниченную высоту; ListView требует ограниченную высоту. Каждое решение выше — просто разный способ ответить на вопрос “какой высоты?” — Expanded говорит “то, что осталось”, SizedBox говорит “ровно это”, shrinkWrap говорит “такой же высоты, как мои дочерние элементы”, а слайверы говорят “пусть решает внешний viewport”. Как только вы читаете ошибку как “ты забыл сказать прокручиваемому элементу его высоту”, решение очевидно каждый раз.
Источники
- Документация Flutter: Understanding constraints — модель “ограничения идут вниз, размеры идут вверх” и бокс, который решает ограниченный vs неограниченный.
- Класс ListView, справочник API Flutter —
shrinkWrap, поведение viewport и заметка о производительности по построению всех дочерних элементов. - Класс Expanded, справочник API Flutter — flex-fit и то, как оставшееся пространство делится в
ColumnилиRow. - Класс CustomScrollView, справочник API Flutter — комбинирование слайверов в одном viewport.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.