Осваиваем ViewThatFits. Часть 2

Первая часть статьи.

Правила идеального отображения разных типов представлений

  • Прямоугольник на оси идеального состояния занимает всего 10, все фигуры следуют этому правилу.
  • Текст на оси идеального состояния, чтобы отобразиться полностью без усечения, занимает как можно больше места.
  • ScrollView: если ось идеального состояния совпадает с направлением прокрутки, на ней показываются все дочерние представления без прокрутки, предлагаемый размер родительского представления игнорируется.
  • VStack, HStack, ZStack: общее отображение всех дочерних представлений в их идеальном состоянии.

В SwiftUI с помощью fixedSize представление с его идеальным размером отображается принудительно:

struct IdealSizeDemo: View {
var body: some View {
VStack {
Text("GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
.fixedSize()
Rectangle().fill(.orange)
.fixedSize()
Circle().fill(.red)
.fixedSize()
ScrollView(.horizontal) {
HStack {
ForEach(0 ..< 50) { i in
Rectangle().fill(.blue).frame(width: 30, height: 30)
.overlay(Text("\(i)").foregroundStyle(.white))
}
}
}
.fixedSize()
VStack {
Text("GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
Rectangle().fill(.yellow)
}
.fixedSize()
}
}
}

На скриншотах видно, что «идеальное отображение» текста, фигуры и ScrollView вполне предсказуемо и соответствует описаниям выше.

Немного странно выглядит только VStack:

VStack {
Text("GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
Rectangle().fill(.yellow)
}
.fixedSize()

Для этого типа представления его «идеальное отображение»  —  составное состояние:

  • Ширина: VStack запрашивает у каждого дочернего представления его идеальную ширину, в качестве собственного требуемого размера использует максимальное значение и передает его как предлагаемый размер дочерним представлениям во время финального макета placeSubviews.
  • Высота: VStack суммирует идеальную высоту всех дочерних представлений с собственной требуемой высотой Spacing.

В SwiftUI имеется две версии fixedSize, применяемая нами требует от представления использования идеальных размеров в горизонтальной и вертикальной осях, другая версия ограничивается одной осью:

struct IdealSizeDemo2: View {
var body: some View {
Text("GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
.fixedSize(horizontal: false, vertical: true)
.border(.red, width: 2)
.frame(width: 100, height: 100)
.border(.blue, width: 2)
}
}

fixedSize(horizontal: false, vertical: true) означает, что текст отображается в его идеальном состоянии вертикально с предлагаемой в явном виде шириной (100) по горизонтали. Проще говоря, этим Text принудительно отображается все содержимое, когда явно задано ограничение по ширине:

На скриншоте видно, что из-за fixedSize текстом Text игнорируется предлагаемый размер 100 x 100 его родительского представления, для отображения полного содержимого текста полностью используется вертикальное пространство.

Это ограничение идеального размера на одной оси точно соответствует настройке ограниченных осей в инициализаторе ViewThatFits. С помощью настройки ViewThatFits оценивает идеальные размеры дочерних представлений только по определенным осям:

struct IdealSizeDemo3: View {
var body: some View {
HStack {

// Результат ViewThatFits
ViewThatFits(in: .vertical) {
Text("1: GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
Text("2: In addition, some views believe that:")
}
.border(.blue)
.frame(width: 200, height: 100, alignment: .top)
.border(.red)

// Идеальный размер Text1, только вертикально фиксированный
Text("1: GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
.fixedSize(horizontal: false, vertical: true)
.border(.blue)
.frame(width: 200, height: 100, alignment: .top)
.border(.red)

// Идеальный размер Text2, только вертикально фиксированный
Text("2: In addition, some views believe that:")
.fixedSize(horizontal: false, vertical: true)
.border(.blue)
.frame(width: 200, height: 100, alignment: .top)
.border(.red)
}
}
}

В этом коде четко демонстрируются критерии выбора ViewThatFits второго текста. Когда Text1 ограничен своей идеальной высотой по вертикали, его высота превышает то, что предоставляется во ViewThatFits: синяя граница выше красной. Зато высота Text2 соответствует требованию ViewThatFits.

На самом деле, пусть даже идеальная высота Text2 превысит то, что предоставляется во ViewThatFits, согласно правилам оценки ViewThatFits по умолчанию все равно будет выбрано последнее дочернее представление Text2, если критериям не соответствует ни то, ни другое.

Но каким будет конечное отображение в этом случае?

ViewThatFits(in: .vertical) {
Text("1: GeometryReader has been present since the birth of SwiftUI, playing a crucial role in many scenarios.")
Text("2: In addition, some views believe that:")
}
.border(.blue)
.frame(width: 200, height: 30, alignment: .top)
.border(.red)

Разработчикам должно быть ясно, что во ViewThatFits оценки выполняются на основе идеальных размеров, но выбранное дочернее представление во время конечного показа не отображается в своем идеальном состоянии.

Поскольку предоставляемая во ViewThatFits высота составляет всего лишь 30, согласно правилам отображения по умолчанию текст в Text2 во время конечного отображения усекается.

В SwiftUI отображение представления в его идеальном состоянии изменяется с помощью frame:

struct SetIdealSize: View {
@State var useIdealSize = false
var body: some View {
VStack {
Button("Use Ideal Size") {
useIdealSize.toggle()
}
.buttonStyle(.bordered)

Rectangle()
.fill(.orange)
.frame(width: 100, height: 100)
.fixedSize(horizontal: useIdealSize ? true : false, vertical: useIdealSize ? true : false)
Rectangle()
.fill(.cyan)
.frame(idealWidth: 100, idealHeight: 100)
.fixedSize(horizontal: useIdealSize ? true : false, vertical: useIdealSize ? true : false)
Rectangle()
.fill(.green)
.fixedSize(horizontal: useIdealSize ? true : false, vertical: useIdealSize ? true : false)
}
.animation(.easeInOut, value: useIdealSize)
}
}

В чем разница между .frame(width: 100, height: 100) и .frame(idealWidth: 100, idealHeight: 100)? Первое  —  это требуемый размер представления в любом сценарии, независимо от того, идеальное состояние или нет, а второе  —  требуемый размер только в идеальном состоянии.

Примеры

Все теоретические знания предназначены для практического применения. Продемонстрируем функциональность ViewThatFits на примерах.

Адаптивная прокрутка

Автоматическая прокрутка в коде ниже достигается, когда ширина содержимого превышает заданную ширину:

struct ScrollViewDemo: View {
@State var step: CGFloat = 3
var count: Int {
Int(step)
}

var body: some View {
VStack(alignment:.leading) {
Text("Count: \(count)")
Slider(value: $step, in: 3 ... 20, step: 1)
ViewThatFits {
content
ScrollView(.horizontal,showsIndicators: true) {
content
}
}
}
.frame(width: 300)
.border(.red)
}
var content: some View {
HStack {
ForEach(0 ..< count, id: \.self) { i in
Rectangle()
.fill(.orange.gradient)
.frame(width: 30, height: 30)
.overlay(
Text(i, format: .number).foregroundStyle(.white)
)
}
}
}
}

Если ширина content превысит допустимую ширину 300, во ViewThatFits выберется последнее вложенное представление, которым применяется ScrollView.

В этом примере, хотя ScrollView отображается с шириной выше допустимой во ViewThatFits в идеальном состоянии, выбирается в итоге именно ScrollView, потому что это последнее вложенное представление. И это типичный случай несоответствия между оценкой и отображением.

Выбор подходящей длины текста

А еще это типичнейший сценарий применения ViewThatFits для нахождения из заданного набора текстов наиболее подходящего для имеющегося пространства:

struct TextDemo: View {
@State var width: CGFloat = 100
var body: some View {
VStack {
Slider(value: $width, in: 30 ... 300)
.padding()
ViewThatFits {
Text("Fatbobman's Swift Weekly")
Text("Fatbobman's Weekly")
Text("Fat's Weekly")
Text("Weekly")
.fixedSize()
}
.frame(width: width)
.border(.red)
}
}
}

Чтобы гарантированно полностью отобразить текст даже в ограниченном пространстве, к последнему Text здесь применили fixedSize.

Вложенные представления разных размеров для ViewThatFits получаются таким кодом с одинаковым содержимым, но разными размерами шрифтов:

ViewThatFits {
Text("Fatbobman's Swift Weekly")
.font(.body)
Text("Fatbobman's Swift Weekly")
.font(.subheadline)
Text("Fatbobman's Swift Weekly")
.font(.footnote)
}

Но для требований, где содержимое одинаковое, а размер отличается, ViewThatFits не оптимальное решение. Результаты оптимизируются следующим кодом:

Text("Fatbobman's Swift Weekly")
.lineLimit(1)
.font(.body)
.minimumScaleFactor(0.3)
.frame(width: width)
.border(.red)

ViewThatFits оптимален в предоставлении различного альтернативного содержимого для разных пространств.

Адаптивные горизонтальные и вертикальные макеты

В заданном пространстве подходящий макет выбирается автоматически:

var logo: some View {
Rectangle()
.fill(.orange)
.frame(idealWidth: 100, maxWidth: 200, idealHeight: 100)
.overlay(
Image(systemName: "heart.fill")
.font(.title)
.foregroundStyle(.white)
)
}

var title: some View {
Text("Hello World")
.fixedSize()
.font(.headline).bold()
.frame(maxWidth: 120)
}

struct LayoutSwitchDemo: View {
@State var width: CGFloat = 100
var body: some View {
VStack {
ViewThatFits(in: .horizontal) {
HStack(spacing: 0) {
logo
title
}
VStack(spacing: 0) {
logo
title
}
}
.frame(maxWidth: width, maxHeight: 130)
.border(.blue)
Spacer()
Slider(value: $width, in: 90 ... 250).padding(50)
}
}
}

В этом примере, чтобы представление ViewThatFits всегда гарантированно заполнялось отображаемым дочерним представлением, используются режимы с различными предлагаемыми размерами, применяемые ViewThatFits во время оценки дочернего представления и конечного отображения.

Для логотипа и названия размеры в явном виде не указываются. Задав идеальный размер прямоугольника, мы предоставляем ViewThatFits варианты для выбора подходящего дочернего представления.

Как только оно выбрано, логотипом внутри регулируется собственный размер согласно тому, что предоставляется во ViewThatFits во время конечного отображения.

Воссоздание ViewThatFits с протоколом макета

При изучении SwiftUI я часто воссоздаю контейнеры и модификаторы макетов. В ходе этого процесса закрепляются понимание и освоение проверяемых предположений.

Подробно разобрав детали реализации ViewThatFits  —  правила оценки, логику отображения,  —  сделаем контейнер макета согласно протоколу Layout:

struct _MyViewThatFitsLayout: Layout {
let axis: Axis.Set
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Int?) -> CGSize {
// Нет вложенных представлений, возвращается ноль
guard !subviews.isEmpty else { return .zero }
// Одно вложенное представление, возвращается его требуемый размер
guard subviews.count > 1 else {
cache = subviews.endIndex - 1
return subviews[subviews.endIndex - 1].sizeThatFits(proposal)
}
// С первого по предпоследнего вложенного представления получаем идеальный размер одного за другим для оценки по ограниченной оси.
for i in 0..<subviews.count - 1 {
let size = subviews[i].dimensions(in: .unspecified)
switch axis {
case [.horizontal, .vertical]:
if size.width <= proposal.replacingUnspecifiedDimensions().width && size.height <= proposal.replacingUnspecifiedDimensions().height {
cache = i
// Если условия оценки удовлетворены, возвращается требуемый размер вложенного представления, запрашиваемый с обычным рекомендуемым размером
return subviews[i].sizeThatFits(proposal)
}
case .horizontal:
if size.width <= proposal.replacingUnspecifiedDimensions().width {
cache = i
return subviews[i].sizeThatFits(proposal)
}
case .vertical:
if size.height <= proposal.replacingUnspecifiedDimensions().height {
cache = i
return subviews[i].sizeThatFits(proposal)
}
default:
break
}
}
// Если ни одно из условий выше не выполняется, используется последнее вложенное представление
cache = subviews.endIndex - 1
return subviews[subviews.endIndex - 1].sizeThatFits(proposal)
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Int?) {
for i in subviews.indices {
if let cache, i == cache {
subviews[i].place(at: bounds.origin, anchor: .topLeading, proposal: proposal)
} else {
// Вложенные представления, которые отображать не нужно, помещаем в неотображаемое место
subviews[i].place(at: .init(x: 100_000, y: 100_000), anchor: .topLeading, proposal: .zero)
}
}
}

func makeCache(subviews _: Subviews) -> Int? {
nil
}
}

public struct MyViewThatFitsByLayout<Content>: View where Content: View {
let axis: Axis.Set
let content: Content
public init(axis: Axis.Set = [.horizontal, .vertical], @ViewBuilder content: @escaping () -> Content) {
self.axis = axis
self.content = content()
}
public var body: some View {
_MyViewThatFitsLayout(axis: axis) {
content
}
}
}

После проверки наша версия ViewThatFits идентична оригиналу по производительности:

Весь код статьи доступен здесь.

Заключение

ViewThatFits  —  это мощный, гибкий компонент в инструментарии SwiftUI для элегантного решения различных задач макетов, совершенствования пользовательского взаимодействия и адаптивности интерфейсов в приложениях. Однако эффективность этого инструмента зависит от глубины понимания того, как его использовать, и ограничений.

Мы подробно изучили контейнер ViewThatFits в SwiftUI  —  от его базового определения до сложных механизмов макета  —  и попытались раскрыть логику и потенциал этого мощного инструмента. Посредством детального анализа идеальных размеров и адаптивности макета показали роль ViewThatFits в различных сценариях применения.

ViewThatFits очень полезен при работе с различными представлениями и задачами макетов, но не всемогущ. При наличии сложных требований необходим более точный контроль или применение других стратегий.

Поэтому, чтобы полностью воспользоваться преимуществами ViewThatFits, избегая потенциальных проблем макетов, разработчикам так важно понимание внутренних принципов его работы и ограничений.

Надеюсь, в статье вы почерпнули много ценной информации о разработке макетов с помощью SwiftUI.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи fatbobman ( 东坡肘子): Mastering ViewThatFits

Предыдущая статьяPHP: поймай меня, если сможешь
Следующая статьяСовременный подход к разработке с использованием Next.js