Чтобы SwiftUI scroll view подстраивался под короткое содержимое, измерьте его внутреннее содержимое и задайте высоту области просмотра как меньшее из этого значения и выбранного предела. Более длинное содержимое можно будет прокручивать в пределах этого ограничения. В этом руководстве показано, как реализовать такое поведение для небольшой панели с метками в собственном приложении для Mac.
Почему короткая прокручиваемая область оставляет пустое место?
Для начала разделим два параметра: высоту содержимого и высоту области просмотра, в которой оно отображается. Apple описывает ScrollView как представление части своего содержимого с изменением видимой части при прокрутке. Короткая группа меток сама по себе не задаёт желаемую высоту области просмотра. Это различие поясняется в документации Apple по ScrollView.
Для компактной панели на компьютере задайте явное правило размера: viewportHeight = min(contentHeight, maximumHeight). Измеряйте содержимое при фактически доступной ему ширине, включая отступы, которые должны прокручиваться вместе с ним. Примените результат к внешнему scroll view. Функция Swift min(_:_:) возвращает меньшее значение, а frame(width:height:alignment:) в SwiftUI задаёт фиксированную высоту, если передан аргумент высоты. Swift min(_:_:), фиксированный размер фрейма в SwiftUI.
Для примера предположим, что максимальная высота составляет 240 пунктов. Если содержимое имеет высоту 86 пунктов, область просмотра будет высотой 86 пунктов; если высота содержимого составляет 410 пунктов, область просмотра будет высотой 240 пунктов, а оставшееся содержимое будет доступно при прокрутке. Это иллюстративные значения, а не результаты измерений из приведённого ниже примера.
| Изменение | Чем управляет | Задаёт ли нужную компактную высоту? |
|---|---|---|
| Измеренная высота содержимого, ограниченная сверху и применённая к scroll view | Высотой области просмотра | Да, в показанной ниже ограниченной компоновке |
.scrollBounceBehavior(.basedOnSize) |
Отскоком относительно размера содержимого | Нет |
.fixedSize(horizontal: false, vertical: true) для меток |
Идеальным вертикальным размером содержимого | Помогает при измерении; для внешней области просмотра всё ещё нужно правило высоты |
Apple описывает .basedOnSize как параметр поведения отскока, а fixedSize(horizontal:vertical:) — как фиксацию представления в его идеальном размере по выбранным осям. Ни одно из этих описаний не задаёт высоту области просмотра на основе содержимого. Поведение отскока при прокрутке, фиксация размера по осям.
Создайте компактную панель с ограничением максимальной высоты прокрутки
Используйте актуальную версию Xcode с SDK macOS, в котором доступна форма onGeometryChange(for:of:action:) с одним значением. Сейчас Apple указывает, что эта форма доступна начиная с macOS 13.0. Полный пример ниже рассчитан на macOS 13.3 или новее, поскольку в нём также используется scrollBounceBehavior(_:axes:), доступный начиная с macOS 13.3. Обратный вызов при изменении геометрии, доступность модификатора отскока.
Создайте проект приложения для macOS на SwiftUI. Замените сгенерированное объявление приложения и представление содержимого этой программой в одном файле, оставив в цели только одно объявление @main. Все метки — синтетические строки, заданные локально. Переключатель Show more labels меняет короткое содержимое на длинное.
import SwiftUI
@main
struct CompactLabelsApp: App {
var body: some Scene {
WindowGroup {
CompactLabelsView()
}
}
}
struct CompactLabelsView: View {
@State private var showMore = false
@State private var viewportHeight: CGFloat = 240
private var labels: [String] {
if showMore {
return (1...18).map { number in
"Sample label \(number): a longer description that can wrap when the panel becomes narrower."
}
}
return ["Sample label A", "Sample label B"]
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Toggle("Show more labels", isOn: $showMore)
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 8) {
if labels.isEmpty {
Text("No labels to display")
} else {
ForEach(labels.indices, id: \.self) { index in
Text(labels[index])
}
}
}
.font(.body)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.onGeometryChange(for: CGFloat.self) { proxy in
min(proxy.size.height.rounded(.up), 240)
} action: { newHeight in
viewportHeight = newHeight
}
}
.frame(height: viewportHeight)
.scrollBounceBehavior(.basedOnSize, axes: .vertical)
.border(Color.secondary.opacity(0.4))
Text("The label panel grows up to 240 points.")
.font(.caption)
}
.padding(20)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.frame(minWidth: 300, minHeight: 360)
}
}
Программа была проверена визуально, но здесь её не компилировали и не запускали, поскольку в этой среде нет компилятора Swift и среды выполнения macOS. Соберите её в Xcode и выполните приведённые ниже проверки, прежде чем встраивать пример в существующий интерфейс.
Почему измерение выполняется внутри scroll view
Обратный вызов геометрии отслеживает внутренний стек с отступами. Измерение внешнего scroll view вернуло бы уже заданную вами высоту области просмотра, создав циклическое правило размера вместо определения необходимой высоты содержимого.
Трансформация возвращает только округлённую высоту с учётом предельного значения. Apple рекомендует преобразовывать геометрию в нужное значение и избегать широкомасштабных обновлений при частых изменениях геометрии. В этом случае изменения позиции прокрутки не влияют на вычисление, а высота содержимого больше установленного предела по-прежнему даёт результат 240 пунктов. Рекомендации Apple по изменению геометрии.
Округление вверх позволяет не задавать области просмотра высоту, которая хоть немного меньше измеренной высоты содержимого. Начальное значение 240 пунктов обеспечивает рабочую область просмотра до получения измерения; это стартовое значение, а не измеренная высота содержимого. Затем обратный вызов заменит его.
Почему важны ширина и порядок модификаторов
Сохраняйте гибкую горизонтальную компоновку. В примере используется .fixedSize(horizontal: false, vertical: true), чтобы метки могли занять доступную ширину и сохранить свой идеальный вертикальный размер. Apple предупреждает, что фиксация идеального размера по одной оси может привести к выходу представления за границы родителя; поэтому фиксация размера по обеим осям — неподходящий вариант по умолчанию для меток с переносом строк в узкой панели. Документация Apple по fixedSize(horizontal:vertical:).
Отступы в 12 пунктов добавляются до модификатора измерения, поэтому они учитываются в измеренной высоте содержимого. Рамка добавляется после задания высоты внешнего представления и обозначает границы области просмотра. Такая компоновка помогает увидеть, где находится свободное место: внутри прокручиваемой панели или в окружающем её окне.
Проверьте короткое содержимое, перенос строк и уменьшение высоты
Выполните эти шаги, чтобы проверить поведение примера, а затем повторите их, поместив панель в фактический контейнер приложения:
- Запустите приложение с выключенным переключателем
Show more labels. Рамка должна охватывать две метки и их отступы, а подпись располагаться сразу под ними. - Включите
Show more labels. Панель должна вырасти до предела в 240 пунктов. Прокрутите её и убедитесь, что до последней метки можно добраться. - Уменьшите и увеличьте ширину окна. Высота меток с переносом строк должна меняться, а их последние строки не должны пропадать.
- Снова выключите переключатель. Панель должна уменьшиться до высоты короткого содержимого.
- Временно замените короткий массив на
[]. Высоту содержимого должно определять явное сообщениеNo labels to display. - Повторите проверку со стилями текста и окружающими элементами управления, используемыми в настоящей панели.
Пустое состояние здесь выбрано намеренно: при пустом массиве пример сохраняет сообщение и отступы. Если панель должна полностью исчезать, условно не отображайте весь scroll view, когда меток нет. Это отдельное решение по дизайну продукта, не связанное с измерением панели, содержащей элементы.
Высота окна примера сохраняет заданный минимум. Код задаёт размер области прокрутки с метками, но не просит уменьшать окно Mac каждый раз, когда метки удаляются. Всё пространство под подписью относится к внешней компоновке, за пределами обведённой рамкой прокручиваемой области.
Найдите причину оставшихся пробелов или обрезанных меток
Если панель остаётся высокой, сначала проверьте, где установлен модификатор измерения. Он должен отслеживать стек содержимого после добавления внутренних отступов. Затем проверьте родительские представления на наличие явной высоты или минимальной высоты, резервирующей пространство вокруг панели. Apple описывает заданную высоту фрейма как фиксированный размер, поэтому отдельный внешний фрейм может сохранять пространство, даже если внутренняя область просмотра уменьшается. Задание размеров фрейма в SwiftUI.
Если само содержимое остаётся неожиданно высоким, уберите вертикальное расширение из измеряемого поддерева. Для этого простого шаблона с метками избегайте вертикального разделителя с гибким пространством и фрейма содержимого, минимальная высота которого зависит от области просмотра. Из-за таких решений высота содержимого будет зависеть от области просмотра, что помешает независимому измерению, необходимому для правила размера.
Если нижний отступ обрезан, убедитесь, что он добавляется до измерения. Если после встраивания текст переносится иначе, проверьте ширину, фактически предоставляемую новым родителем. Не следует считать высоту, измеренную при одной ширине, неизменной при другой ширине.
Подберите предел в 240 пунктов с учётом окружающего интерфейса. В примере задана минимальная высота окна, достаточная для элементов управления, отступов, подписи и панели с максимальной высотой. Для более низкого контейнера понадобится меньший предел или другая окружающая компоновка; этот фиксированный предел не вычисляет автоматически пространство, оставшееся под другими элементами управления.
Используйте этот подход для небольшого набора меток, которые сразу размещаются в компоновке. Apple описывает LazyVStack как представление, создающее элементы только тогда, когда они нужны для отображения на экране. Поэтому не следует считать, что замена внутреннего стека на ленивый сохраняет ту же стратегию измерения всего содержимого. Для большой коллекции обычно лучше подходит область просмотра со стабильным размером и ленивым содержимым. Документация Apple по LazyVStack.
Когда уместны измерения AppKit и TextKit?
Для этой панели SwiftUI с метками измеряйте отображаемую иерархию представлений SwiftUI. Измерение текста в AppKit добавило бы ещё одну конфигурацию компоновки, которую пришлось бы согласовать с отображаемыми метками.
Если приложение уже использует NSScrollView, сохраняйте то же различие между документом и областью просмотра. Apple определяет documentView как прокручиваемое представление, а contentSize — как размер представления содержимого области прокрутки. Несмотря на название, contentSize не измеряет документ целиком. AppKit documentView, AppKit contentSize.
Для существующей компоновки TextKit на основе NSLayoutManager метод usedRect(for:) возвращает ограничивающий прямоугольник глифов, уже размещённых в текстовом контейнере; Apple прямо указывает, что его вызов не создаёт глифы и не выполняет компоновку. Если требуется полная компоновка контейнера, документация рекомендует сначала принудительно выполнить её с помощью ensureLayout(for:). TextKit usedRect(for:), TextKit ensureLayout(for:).
Даже в этом случае прямоугольник глифов не совпадает с полным фреймом текстового представления. Apple описывает дополнительное пространство вокруг текстового контейнера, задаваемое через textContainerInset, а также внутренние отступы фрагментов строк. Учитывайте фактические настройки текстового представления, а не копируйте высоту глифов напрямую в область прокрутки. Эти методы относятся к работе с NSLayoutManager; они не являются универсальным способом задания размеров для любых текстовых компонентов. AppKit textContainerInset.
Для небольшой панели SwiftUI с метками практическое решение — сохранить гибкую ширину, измерить внутреннее содержимое с отступами и ограничить высоту внешней области просмотра. Проверьте результат на коротком и длинном содержимом, а затем протестируйте панель в настоящей родительской компоновке, чтобы убедиться, что окружающие ограничения поддерживают такое же компактное поведение.
Источники
- ScrollView — Apple
- min(::) — Apple
- frame(width:height:alignment:) — Apple
- scrollBounceBehavior(_:axes:) — Apple
- fixedSize(horizontal:vertical:) — Apple
- onGeometryChange(for:of:action:) — Apple
- LazyVStack — Apple
- documentView — Apple
- contentSize — Apple
- usedRect(for:) — Apple
- ensureLayout(for:) — Apple
- textContainerInset — Apple