На WWDC 2023 компания Apple представила модификатор представления containerRelativeFrame для SwiftUI. Этот модификатор упрощает некоторые операции размещения элементов на экране, которые ранее было сложно выполнить обычными методами. В этой статье мы подробно рассмотрим модификатор containerRelativeFrame, его определение, правила компоновки, примеры использования и важные соображения. Чтобы еще больше расширить наше понимание его функциональных возможностей, в конце статьи мы также создадим обратно совместимую реплику containerRelativeFrame для старых версий SwiftUI.

Определение

В официальной документации Apple containerRelativeFrame описывается следующим образом:

Помещает представление в невидимый фрейм, размеры которого задаются относительно ближайшего контейнера.

Используйте этот модификатор, чтобы задать размер ширины и/или высоты представления в зависимости от размера ближайшего контейнера. В качестве такого контейнера могут выступать разные вещи, среди которых:

  • Окно, содержащее представление, на iPadOS или macOS, или экран устройства на iOS.

  • Колонка NavigationSplitView

  • NavigationStack

  • Вкладка TabView

  • Представление с возможностью прокрутки, например ScrollView или List

Размер, указанный в этом модификаторе, — это размер контейнера, подобного перечисленным выше, за вычетом любых вставок безопасной области, которые могут быть применены к этому контейнеру.

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

Модификатор containerRelativeFrame, начиная с представления, к которому он применяется, ищет в иерархии представлений ближайший контейнер, который вписывается в список допустимых контейнеров. Основываясь на правилах трансформации, заданных разработчиком, он вычисляет размер, полученный из найденного контейнера, и использует его для своего представления. В некотором смысле его можно рассматривать как специальную версию модификатора frame, которая позволяет задавать пользовательские правила трансформации.

Конструкторы

containerRelativeFrame предлагает три вида конструкторов, каждый из которых отвечает различным потребностям в размещении элементов на экране:

1) Базовая версия: Используя этот конструктор, модификатор никак не преобразует размер контейнера. Вместо этого он напрямую принимает размер, полученный от ближайшего контейнера, в качестве размера для представления.

public func containerRelativeFrame(_ axes: Axis.Set, alignment: Alignment = .center) -> some View

2) Версия с предварительно заданными параметрами: С помощью этой версии разработчики могут указать разбиение размера на количество столбцов или строк, которые нужно охватить, и расстояние между ними, трансформируя соответствующим образом размер по заданным осям. Этот метод особенно подходит для настройки размера представления пропорционально размеру контейнера.

public func containerRelativeFrame(_ axes: Axis.Set, count: Int, span: Int = 1, spacing: CGFloat, alignment: Alignment = .center) -> some View

3) Полностью кастомизируемая версия: Этот конструктор обеспечивает максимальную гибкость, позволяя разработчикам задать собственную логику расчета в зависимости от размера контейнера. Он подходит для крайне специфических требований к макету.

public func containerRelativeFrame(_ axes: Axis.Set, alignment: Alignment = .center, _ length: @escaping (CGFloat, Axis) -> CGFloat) -> some View

Эти конструкторы предоставляют разработчикам мощные инструменты для создания сложных макетов, отвечающих различным требованиям к интерфейсу.

Пара слов о понятиях

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

Список контейнеров

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

VStack {
  Rectangle()
    .frame(width: 200, height: 200)
    // остальные представления
    ...
}
.frame(width: 400, height: 500)

Например, при работе на iPhone, если мы хотим, чтобы высота Rectangle была равна половине доступной высоты экрана, мы можем использовать следующую логику:

var screenAvailableHeight: CGFloat // Получаем доступную высоту экрана каким-либо способом

VStack {
  Rectangle()
    .frame(width: 200, height: screenHeight / 2)
    // остальные представления
    ...
}
.frame(width: 400, height: 500)

До появления containerRelativeFrame для получения размеров экрана приходилось использовать методы типа GeometryReader или UIScreen.main.bounds. Теперь мы можем добиться того же эффекта более удобным способом:

@main
struct containerRelativeFrameDemoApp: App {
  var body: some Scene {
    WindowGroup {
      VStack {
        Rectangle()
          // Разделяем вертикальную ось на два и возвращаем
          .containerRelativeFrame(.vertical){ height, _ in height / 2}
      }
      .frame(width: 400, height: 500)
    }
  }
}

Или

@main
struct containerRelativeFrameDemoApp: App {
  var body: some Scene {
    WindowGroup {
      VStack {
        Rectangle()
          // Разделяем вертикальную ось на две равные части, занимаем одну, без промежутков
          .containerRelativeFrame(.vertical, count: 2, span: 1, spacing: 0)
      }
      .frame(width: 400, height: 500)
    }
  }
}

В приведенном выше коде Rectangle() игнорирует предложенный VStack размер 400 x 500 и вместо этого ищет подходящий контейнер непосредственно сверху по иерархии представлений. В данном примере подходящим контейнером является экран iPhone.

Это означает, что containerRelativeFrame предоставляет доступ к размерам контейнера в разных иерархиях представлений. Однако он может получить доступ только к размерам, предоставляемым конкретными контейнерами, перечисленными в списке допустимых контейнеров (таким как окно, ScrollView, TabView, NavigationStack и т. д.).

Ближайшие контейнеры

Если в иерархии представлений сразу несколько контейнеров соответствуют критериям, containerRelativeFrame выберет из них ближайший к текущему представлению. Например, в следующем фрагменте кода конечная высота Rectangle равна 100, потому что используется высота NavigationStack (200), деленная на 2, а не половина доступной высоты экрана.

@main
struct containerRelativeFrameDemoApp: App {
  var body: some Scene {
    WindowGroup {
      NavigationStack {
        VStack {
          Rectangle() // высота равна 100
            .containerRelativeFrame(.vertical) { height, _ in height / 2 }
        }
        .frame(width: 400, height: 500)
      }
      .frame(height: 200) // высота NavigationStack равна 200
    }
  }
}

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

Кроме того, необходимо соблюдать особую осторожность при использовании containerRelativeFrame в overlay или background представлениях, которые попадают в список допустимых контейнеров. В таких случаях containerRelativeFrame будет игнорировать текущий контейнер при поиске ближайшего контейнера. Это поведение отличается от типичного поведения overlay или background представлений.

Обычно считается, что представление и его overlay и background находятся в отношениях "master-slave". Чтобы узнать больше, читайте статью Разбираемся с модификаторами Overlay и Background в SwiftUI.

Ниже приведен пример, в котором к NavigationStack применяется overlay, содержащий Rectangle, который использует для определения высоты containerRelativeFrame. Здесь containerRelativeFrame не будет использовать высоту NavigationStack, а вместо этого будет искать размеры контейнера более высокого уровня — в данном случае это размер экрана.

@main
struct containerRelativeFrameDemoApp: App {
  var body: some Scene {
    WindowGroup {
      NavigationStack {
        VStack {
          Rectangle()
        }
        .frame(width: 400, height: 500)
      }
      .frame(height: 200) // высота NavigationStack равна 200
      .overlay(
        Rectangle()
          .containerRelativeFrame(.vertical) { height, _ in height / 2 } // доступная высота экрана / 2
      )
    }
  }
}

Правила трансформации

Среди конструкторов, предлагаемых containerRelativeFrame, есть два метода, которые позволяют динамически изменять размеры. Последний обеспечивает наибольшую гибкость:

public func containerRelativeFrame(_ axes: Axis.Set, alignment: Alignment = .center, _ length: @escaping (CGFloat, Axis) -> CGFloat) -> some View

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

Rectangle()
  .containerRelativeFrame([.horizontal, .vertical]) { length, axis in
    if axis == .vertical {
      return length / 2
    } else {
      return length * (2 / 3)
    }
  }

Для осей, не указанных в параметре конструктора axes, containerRelativeFrame не будет устанавливать размеры (сохранятся предложенные размеры, заданные родительским представлением).

struct TransformsDemo: View {
  var body: some View {
    VStack {
      Rectangle()
        .containerRelativeFrame(.horizontal) { length, axis in
          if axis == .vertical {
            return length / 2 // Эта строка не будет выполнена, потому что .vertical не задана в axes
          } else {
            return length * (2 / 3)
          }
        }
    }.frame(height: 100)
  }
}

В приведенном выше коде ширина Rectangle устанавливается равной двум третям доступной ширины ближайшего контейнера, а высота остается равной 100 (что соответствует высоте родительского VStack).

Подробное объяснение второго конструктора будет рассмотрено в следующем разделе.

Размер, предоставляемый контейнером 

В официальной документации размер, используемый модификатором containerRelativeFrame, описывается следующим образом: "Размер, предоставляемый этому модификатору, — это размер контейнера за вычетом любых вставок безопасной области, которые могут быть применены к этому контейнеру". Это описание в принципе верно, но есть несколько важных деталей, на которые следует обратить внимание при его реализации с различными контейнерами:

  • При использовании в NavigationSplitView containerRelativeFrame получает размеры текущей колонки (SideBar, Content, Detail). Помимо учета уменьшения за счет безопасных областей, в верхней области также должна быть вычтена высота панели инструментов (navigationBarHeight). Однако при использовании в NavigationStack высота панели инструментов не вычитается.

  • При использовании containerRelativeFrame в TabView вычисляемая высота — это общая высота TabView минус высота безопасной области вверху и TabBar внизу.

  • В ScrollView, если разработчик добавил padding через safeAreaPadding, то containerRelativeFrame также вычтет значения заполнения.

  • В средах, поддерживающих несколько окон (iPadOS, macOS), размер корневого контейнера соответствует доступным размерам окна, в котором в данный момент отображается представление.

  • Хотя в официальной документации указано, что containerRelativeFrame можно использовать с List, по факту в Xcode версии 15.3 (15E204a) этот модификатор пока не способен корректно вычислять размеры списка.

Примеры использования

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

Создание галерей, пропорциональных размерам области прокрутки

Это распространенный сценарий, который часто упоминается в статьях, посвященных использованию containerRelativeFrame. Рассмотрим следующую задачу: нам нужно создать горизонтально прокручиваемый макет галереи, похожий то, что мы видим в App Store или Apple Music, где каждое дочернее представление (изображение) занимает одну треть ширины прокручиваемой области и две трети высоты от ее ширины.

Обычно, если не используется containerRelativeFrame, разработчики могут использовать метод, представленный в руководстве SwiftUI geometryGroup(): От теории к практике, который включает в себя добавление background‘а к ScrollView для получения его размеров, а затем передачу каким-либо образом этой информации для установки конкретных размеров дочерних представлений. Это означает, что мы не можем добиться этого только за счет манипуляций с дочерними представлениями, сначала мы должны получить размеры ScrollView.

Эту задачу можно легко выполнить, используя второй конструктор containerRelativeFrame,:

struct ScrollViewDemo:View {
  var body: some View {
    ScrollView(.horizontal) {
      HStack(spacing: 10) {
        ForEach(0..<10){ _ in
          Rectangle()
            .fill(.purple)
            .aspectRatio(3 / 2, contentMode: .fit)
            // Горизонтально делим на три части, занимаем одну, без интервалов
            .containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 0)
        }
      }
    }
  }
}

Внимательные читатели могут заметить, что, поскольку сам HStack имеет spacing: 10, третье представление (крайнее справа) будет отображаться не полностью — небольшая часть не влезет в области прокрутки. Если вы хотите учитывать интервал в HStack при установке ширины дочерних представлений, то вам нужно будет указать его в настройке spacing containerRelativeFrame. Благодаря этой настройке ширина каждого дочернего представления будет чуть меньше одной трети ширины видимой области ScrollView с учетом расстояния между ними, и мы сможем наблюдать все три представления полностью на начальном экране.

struct ScrollViewDemo:View {
  var body: some View {
    ScrollView(.horizontal) {
      HStack(spacing: 10) {
        ForEach(0..<10){ _ in
          Rectangle()
            .fill(.purple)
            .aspectRatio(3 / 2, contentMode: .fit)
            .border(.yellow, width: 3)
            // Учет расстояний в расчетах
            .containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 10)
        }
      }
    }
  }
}

Параметр spacing в containerRelativeFrame отличается от параметра spacing в таких контейнерах, как VStack и HStack. Он не добавляет пространство напрямую, а используется во втором варианте конструкторе в качестве коэффициента в правилах трансформации.

В официальной документации роль count, span и spacing в правилах трансформации объясняется на примере вычисления ширины:

let availableWidth = (containerWidth - (spacing * (count - 1)))
let columnWidth = (availableWidth / count)
let itemWidth = (columnWidth * span) + ((span - 1) * spacing)

Важно отметить, что из-за особенностей компоновки ScrollView (в направлении прокрутки он использует все предлагаемые размеры, а в направлении без прокрутки зависит от требуемых размеров дочерних представлений) при использовании containerRelativeFrame в ScrollView параметр axes должен как минимум включать обработку размеров в направлении прокрутки (если дочерние представления не указали конкретные требуемые размеры). В противном случае это может привести к аномальному поведению. Например, следующий код в большинстве случаев будет вызывать краш приложения:

struct ScrollViewDemo:View {
  var body: some View {
    ScrollView {
      HStack(spacing: 10) {
        ForEach(0..<10){ _ in
          Rectangle()
            .fill(.purple)
            .aspectRatio(3 / 2, contentMode: .fit)
            .border(.yellow, width: 3)
            // вычисляемое направление не совпадает с направлением прокрутки
            .containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 0)
        }
      }
    }
    .border(.red)
  }
}

Примечание: Из-за различий в логике компоновки между LazyHStack и HStack использование LazyHStack вместо HStack может привести к тому, что ScrollView будет занимать все доступное пространство, что может не соответствовать ожидаемой компоновке (в официальных примерах документации используется LazyHStack). В сценариях, где LazyHStack необходим, лучшим выбором может быть использование GeometryReader для получения ширины ScrollView и вычисления результирующей высоты, чтобы макет соответствовал ожиданиям.

Установка пропорциональных размеров

Когда требуемые пропорции размеров неравномерны, больше подходит третий вид конструктора, позволяющий полностью кастомизировать правила трансформации. Рассмотрим следующий сценарий: нам нужно отобразить фрагмент текста внутри контейнера (например, NavigationStack или TabView) и задать фон, состоящий из двух цветов — синего сверху и оранжевого снизу, с разделением на золотом сечении контейнера (0.618).

Не используя containerRelativeFrame, мы могли бы реализовать это следующим образом:

struct SplitDemo:View {
  var body: some View {
    NavigationStack {
      ZStack {
        Color.blue
          .overlay(
            GeometryReader { proxy in
              Color.clear
                .overlay(alignment: .bottom) {
                  Color.orange
                    .frame(height: proxy.size.height * (1 - 0.618))
                }
            }
          )
        Text("Hello World")
          .font(.title)
          .foregroundStyle(.yellow)
      }
    }
  }
}

С containerRelativeFrame наша логика будет совершенно другой:

struct SplitDemo: View {
  var body: some View {
    NavigationStack {
      Text("Hello World")
        .font(.title)
        .foregroundStyle(.yellow)
        .background(
          Color.blue
            // Синий цвет занимает все свободное пространство контейнера
            .containerRelativeFrame([.horizontal, .vertical])
            .overlay(alignment: .bottom) {
              Color.orange
                // Высота оранжевого цвета — это высота контейнера умноженная на (1 - 0.618), выровненная по низу синего цвета
                .containerRelativeFrame(.vertical) { length, _ in
                  length * (1 - 0.618)
                }
            }
        )
    }
  }
}

Если вы хотите, чтобы синий и оранжевый фоны выходили за пределы безопасной области, вы можете добиться этого, добавив модификатор ignoresSafeArea:

NavigationStack {
  Text("Hello World")
    .font(.title)
    .foregroundStyle(.yellow)
    .background(
      Color.blue
        .ignoresSafeArea()
        .containerRelativeFrame([.horizontal, .vertical])
        .overlay(alignment: .bottom) {
          Color.orange
            .ignoresSafeArea()
            .containerRelativeFrame(.vertical) { length, _ in
              length * (1 - 0.618)
            }
        }
    )
}

В статье GeometryReader: Дар или проклятие? мы рассмотрели, как использовать GeometryReader для размещения двух представлений в определенном соотношении в заданном пространстве. Хотя containerRelativeFrame поддерживает получение размеров только из конкретного списка контейнеров, мы все равно можем использовать определенные техники для удовлетворения аналогичных требований к расположению.

Вот пример реализации этого с помощью GeometryReader:

struct RatioSplitHStack<L, R>: View where L: View, R: View {
    let leftWidthRatio: CGFloat
    let leftContent: L
    let rightContent: R
    init(leftWidthRatio: CGFloat, @ViewBuilder leftContent: @escaping () -> L, @ViewBuilder rightContent: @escaping () -> R) {
        self.leftWidthRatio = leftWidthRatio
        self.leftContent = leftContent()
        self.rightContent = rightContent()
    }

    var body: some View {
        GeometryReader { proxy in
            HStack(spacing: 0) {
                Color.clear
                    .frame(width: proxy.size.width * leftWidthRatio)
                    .overlay(leftContent)
                Color.clear
                    .overlay(rightContent)
            }
        }
    }
}

В версии, использующей containerRelativeFrame, для предоставления размеров без включения функции прокрутки мы можем использовать ScrollView:

struct RatioSplitHStack<L, R>: View where L: View, R: View {
  let leftWidthRatio: CGFloat
  let leftContent: L
  let rightContent: R
  init(leftWidthRatio: CGFloat, @ViewBuilder leftContent: @escaping () -> L, @ViewBuilder rightContent: @escaping () -> R) {
    self.leftWidthRatio = leftWidthRatio
    self.leftContent = leftContent()
    self.rightContent = rightContent()
  }

  var body: some View {
    ScrollView(.horizontal) {
      HStack(spacing: 0) {
        Color.clear
          .containerRelativeFrame(.horizontal) { length, _ in length * leftWidthRatio }
          .overlay(leftContent)
        Color

.clear
          .overlay(rightContent)
          .containerRelativeFrame(.horizontal) { length, _ in length * (1 - leftWidthRatio) }
      }
    }
    .scrollDisabled(true) // Используем ScrollView исключительно для получения размеров, прокрутка отключена
  }
}

struct RatioSplitHStackDemo: View {
    var body: some View {
        RatioSplitHStack(leftWidthRatio: 0.25) {
            Rectangle().fill(.red)
        } rightContent: {
            Color.clear
                .overlay(
                    Text("Hello World")
                )
        }
        .border(.blue)
        .frame(width: 300, height: 60)
    }
}

Получение размеров контейнера для вложенных представлений

Важной особенностью containerRelativeFrame является его способность напрямую получать доступные размеры ближайшего подходящего контейнера в иерархии представления. Эта возможность особенно удобна для создания вложенных представлений или модификаторов представлений, которые содержат независимую логику и должны знать размеры своих контейнеров, но не хотят нарушать текущую компоновку представления, как это может сделать GeometryReader.

Следующий пример демонстрирует, как создать ViewModifier под названием ContainerSizeGetter, назначение которого — получать и передавать доступные размеры своего контейнера (который является частью списка):

// Сохраняем полученные размеры, чтобы предотвратить их обновление во время цикла обновления представления
class ContainerSize {
  var width: CGFloat? {
    didSet {
      sendSize()
    }
  }

  var height: CGFloat? {
    didSet {
      sendSize()
    }
  }

  func sendSize() {
    if let width = width, let height = height {
      publisher.send(.init(width: width, height: height))
    }
  }

  var publisher = PassthroughSubject<CGSize, Never>()
}

// Получаем и передаем доступные размеры ближайшего контейнера
struct ContainerSizeGetter: ViewModifier {
  @Binding var size: CGSize?
  @State var containerSize = ContainerSize()
  func body(content: Content) -> some View {
    content
      .overlay(
        Color.yellow
          .containerRelativeFrame([.vertical, .horizontal]) { length, axes in
            if axes == .vertical {
              containerSize.height = length
            } else {
              containerSize.width = length
            }
            return 0
          }
      )
      .onReceive(containerSize.publisher) { size in
        self.size = size
      }
  }
}

extension View {
  func containerSizeGetter(size: Binding<CGSize?>) -> some View {
    modifier(ContainerSizeGetter(size: size))
  }
}

Этот ViewModifier использует containerRelativeFrame для измерения и обновления размеров контейнера и PassthroughSubject для уведомления внешнего привязанного свойства size о любых изменениях размеров. Преимущество этого метода в том, что он не нарушает исходный макет представления, служа лишь инструментом для контроля и передачи размеров.

Реплика containerRelativeFrame

В своих статьях, посвященных компоновке, я часто пытаюсь создать реплики контейнеров компоновки. Такая практика не только помогает глубже понять механизмы компоновки контейнеров, но и позволяет проверить гипотезы о некоторых аспектах логики. Кроме того, там, где это возможно, эти реплики могут быть применены к более ранним версиям SwiftUI (например, iOS 13+).

Чтобы упростить работу с репликацией в этой статье, текущая версия поддерживает только iOS. Полный код можно посмотреть здесь.

Определение ближайшего контейнера

Официальный containerRelativeFrame может получить размеры ближайшего контейнера одним из двух способов:

  • Позволяя контейнерам передавать свои размеры вниз по иерархии.

  • Позволяя containerRelativeFrame автономно искать ближайший контейнер вверх по иерархии и получать его размеры.

Учитывая, что первый метод может увеличить нагрузку на систему (поскольку контейнеры будут постоянно отправлять изменения размеров, даже если containerRelativeFrame не используется) и что сложно разработать точную логику передачи размеров для разных контейнеров, для нашей реплики мы выберем второй метод — автоматический поиск ближайшего контейнера вверх по иерархии.

extension UIView {
  fileprivate func findRelevantContainer() -> (container: UIResponder, type: ContainerType)? {
    var responder: UIResponder? = this

    while let currentResponder = responder {
      if let viewController = currentResponder as? UIViewController {
        if viewController is UITabBarController {
          return (viewController, .tabview) // UITabBarController
        }
        if viewController is UINavigationController {
          return (viewController, .navigator) // UINavigationController
        }
      }
      if let scrollView = currentResponder as? UIScrollView {
        return (scrollView, .scrollView) // UIScrollView
      }
      responder = currentResponder.next
    }

    if let currentWindow = UIApplication.shared.windows.filter({$0.isKeyWindow}).first {
      return (currentWindow, .window) // UIWindow
    } else {
      return nil
    }
  }
}

Добавив метод расширения findRelevantContainer к UIView, мы можем определить конкретный контейнер (возвращающий тип UIResponder), который находится ближе всего к текущему представлению (UIView).

Расчет размеров, получаемых от контейнера

После определения ближайшего контейнера необходимо настроить вставки безопасной области, высоту TabBar, высоту NavigationBarHeight и другие размеры в зависимости от типа контейнера. Это делается путем отслеживания изменений в свойстве frame, чтобы динамически реагировать на изменения размеров:

@MainActor
  class Coordinator: ObservableObject {
    weak var container: UIResponder?
    var type: ContainerType?
    var size: Binding<CGSize?>
    var cancellables = Set<AnyCancellable>()

    init(size: Binding<CGSize?>) {
      self.size = size
    }

    func trackContainerSizeChanges(_ container: UIResponder, ofType type: ContainerType) {
      self.container = container
      self.type = type
      switch type {
      case .window:
        if let window = container as? UIWindow {
          window.publisher(for: \.frame)
            .receive(on: RunLoop.main)
            .sink(receiveValue: { _ in
              let size = self.calculateContainerSize(container, ofType: type)
              self.size.wrappedValue = size
            })
            .store(in: &cancellables)
        }
      ....
    }

    func calculateContainerSize(_ container: UIResponder, ofType type: ContainerType) -> CGSize? {
      switch type {
      case .window:
        if let window = container as? UIWindow {
          let windowSize = window.frame.size
          let safeAreaInsets = window.safeAreaInsets
          let width = windowSize.width - safeAreaInsets.left - safeAreaInsets.right
          let height = windowSize.height - safeAreaInsets.top - safeAreaInsets.bottom
          return CGSize(width: width, height: height)
        }
      ....
      return nil
    }
  }
}

Создание ViewModifier

Мы инкапсулируем описанную выше логику в представление SwiftUI с помощью UIViewRepresentable и применяем ее к представлению, в конечном итоге используя модификатор frame для применения преобразованных размеров к представлению:

private struct ContainerDetectorModifier: ViewModifier {
  let type: DetectorType
  @State private var containerSize: CGSize?
  func body(content: Content) -> some View {
    content
      .background(
        ContainerDetector(size: $containerSize)
      )
      .frame(width: result.width, height: result.height, alignment: result.alignment)
  }
  
  ...
}

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

Результаты показывают, что containerRelativeFrame действительно можно рассматривать как специальную версию модификатора frame, позволяющую использовать кастомные правила трансформации. Поэтому в этой статье я не стал уделять внимание использованию параметра alignment, так как он полностью соответствует логике frame.

Соображения:

  • На версиях iOS ниже 17, если реплика изменяет размеры по двум осям одновременно, ScrollView может вести себя некорректно.

  • По сравнению с официальной версией, реплика производит более точное извлечение размеров для List.

Заключение

Благодаря подробному разбору и примерам, представленным в этой статье, вы должны иметь полное представление о модификаторе containerRelativeFrame в SwiftUI, включая его определение, применение и рекомендации. Мы не только научились использовать этот мощный модификатор для оптимизации и инновации наших стратегий компоновки, но и узнали, как расширить его применение на старые версии SwiftUI, воспроизведя существующий инструмент, тем самым улучшив наше понимание механизмов компоновки SwiftUI. Я надеюсь, что этот материал пробудит ваш интерес к верстке в SwiftUI и окажется полезным для вас в разработке.

26 декабря пройдет открытый урок, посвященный теме навигации на SwiftUI без UIKit. На нем:

  • Разберем навигацию в проектах на SwiftUI.

  • Научимся писать приложение с нативной навигацией на SwiftUI с поддержкой iOS 14, используя OpenSource-решения и авторские разработки без UIKit.

  • Разберем интеграцию диплинков в проект в декларативном стиле.

Записаться на урок можно на странице курса "iOS Developer. Professional".

Комментарии (0)