Компонент, который мы собираемся создать, доступен как Swift Package.
Вступление
Утро понедельника, и ваш менеджер проекта дает вам задание: добавить список тегов на страницу подробной информации о продукте. Вы говорите «изи» и через 10 минут выдаете следующее.
HStack {
ForEach(tags) {
TagView(text: $0.text)
}
Spacer(minLength: .zero)
}.padding(.horizontal)
После проверки, команда QA сообщает о возникающей ошибке при большом количестве тегов.
Вы продолжаете и делаете для них горизонтальную прокрутку.
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(tags) {
TagView(text: $0.text)
}
}.padding(.horizontal)
}.frame(height: 56)
Недостатком является то, что вы должны заранее знать высоту вьюшек тегов.
Команда дизайнеров просит переносить теги на последующие строки, когда они не соответствуют ширине основного вью. И теперь ваше "изи" превратилось в “хардово". Один из ваших коллег предлагает обернуть пользовательский UICollectionView в UIViewRepresentable. А другой - попробовать новый протокол Layout. Вы решили продолжить с Layout…
Протокол макета
Протокол имеет 2 требования:
- sizeThatFits определяет, сколько места требует Вью.
- placeSubviews управляет размещением сабвью(-шек) внутри доступного пространства.
Обратите внимание, что sizeThatFits может вызываться несколько раз в процессе компоновки. Он будет пробовать различные предложения по размерам. На момент написания статьи, в iOS он как обычно просто попытается передать все доступное пространство. В macOS он также попытается предложить размер .zero, чтобы можно было вычислить минимальный размер окна. Таким образом, для поддержки macOS нам потребуется вычислить минимальный размер вью.
Схема подхода
За минимальный размер мы возьмем максимальный размер сабвью в предложении .zero. Всякий раз, когда предложение меньше минимального размера, мы просто вернем минимальный размер раньше.
func minSize(subviews: Subviews) -> CGSize {
subviews
.map { $0.sizeThatFits(.zero) }
.reduce(CGSize.zero) { CGSize(width: max($0.width, $1.width), height: max($0.height, $1.height)) }
}
Чтобы вычислить как размер, так и размещение, нам нужно сначала расположить эти сабвью в строки. Основная идея состоит в том, чтобы перебирать сабвью и увеличивать координату X на ширину сабвью + горизонтальный интервал, если он все еще умещается в ширину контейнера, или в противном случае переходить к следующей строке. Это позволит нам получить смещения X для всех сабвью. Затем мы будем перебирать строки и увеличивать координату Y на максимальную высоту сабвью + интервал по вертикали. Это позволит нам получить смещения Y для всех строк.
Как только у нас будет расположение строк, ширина заполнит все доступное пространство.
let width = proposal.width ?? rows.map { $0.width }.reduce(.zero) { max($0, $1) }
И высота будет равна вертикальному смещению последней строки + ее высота.
var height: CGFloat = .zero
if let lastRow = rows.last {
height = lastRow.yOffset + lastRow.height
}
Сабвью будут размещены на соответствующих им смещениях + точка минимального значения границ.
for row in rows {
for element in row.elements {
let x: CGFloat = element.xOffset
let y: CGFloat = row.yOffset
let point = CGPoint(x: x + bounds.minX, y: y + bounds.minY)
subviews[element.index].place(at: point, anchor: .topLeading, proposal: proposal)
}
}
Расположение строк
Для каждой строки нам нужно знать индексы наших сабвью, размеры и смещения по оси X. Кроме того, общее смещение строки по Y, ширину и высоту строки.
struct Row {
var elements: [(index: Int, size: CGSize, xOffset: CGFloat)] = []
var yOffset: CGFloat = .zero
var width: CGFloat = .zero
var height: CGFloat = .zero
}
func arrangeRows(proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()) -> [Row] {
let minSize = minSize(subviews: subviews)
if minSize.width > proposal.width ?? .infinity,
minSize.height > proposal.height ?? .infinity {
return []
}
let sizes = subviews.map { $0.sizeThatFits(proposal) }
var currentX = CGFloat.zero
var currentRow = Row()
var rows = [Row]()
for index in subviews.indices {
var spacing = CGFloat.zero
if let previousIndex = currentRow.elements.last?.index {
spacing = horizontalSpacing(subviews[previousIndex], subviews[index])
}
let size = sizes[index]
if currentX + size.width + spacing > proposal.width ?? .infinity,
!currentRow.elements.isEmpty {
currentRow.width = currentX
rows.append(currentRow)
currentRow = Row()
spacing = .zero
currentX = .zero
}
currentRow.elements.append((index, sizes[index], currentX + spacing))
currentX += size.width + spacing
}
currentRow.width = currentX
rows.append(currentRow)
var currentY = CGFloat.zero
var previousMaxHeightIndex: Int?
for index in rows.indices {
let maxHeightIndex = rows[index].elements
.max { $0.size.height < $1.size.height }!
.index
let size = sizes[maxHeightIndex]
var spacing = CGFloat.zero
if let previousMaxHeightIndex {
spacing = verticalSpacing(subviews[previousMaxHeightIndex], subviews[maxHeightIndex])
}
rows[index].yOffset = currentY + spacing
currentY += size.height + spacing
rows[index].height = size.height
previousMaxHeightIndex = maxHeightIndex
}
return rows}
Интервалы
Мы разрешаем переопределение горизонтального и вертикального интервалов или используем системные интервалы, если они равны nil. Прокси LayoutSubview позволяет получить системный интервал для пары сабвью.
func horizontalSpacing(_ lhs: LayoutSubview, _ rhs: LayoutSubview) -> CGFloat {
if let horizontalSpacing { return horizontalSpacing }
return lhs.spacing.distance(to: rhs.spacing, along: .horizontal)
}
func verticalSpacing(_ lhs: LayoutSubview, _ rhs: LayoutSubview) -> CGFloat {
if let verticalSpacing { return verticalSpacing }
return lhs.spacing.distance(to: rhs.spacing, along: .horizontal)
}
Свойства макета
Протокол Layout имеет опциональный параметр layoutProperties, который позволяет управлять StackOrientation. Это влияет на способ обработки Spacer и Divider. Например, при stackOrientation = .horizontal, Spacer будет расширяться только по горизонтали. Таким образом, это позволит обеспечить разрыв линии (строки) в контейнере. У него есть оговорка, что между разделенными строками будет двойной интервал, а системный интервал по умолчанию будет равен нулю.
static var layoutProperties: LayoutProperties {
var properties = LayoutProperties()
properties.stackOrientation = .horizontal
return properties
}
Выравнивание
Мы разрешим контролировать значение выравнивания внутри контейнера. Вот только протокол Layout не предоставляет простой способ реализации различных значений выравнивания базовой линии текста: .leadingFirstTextBaseline, .centerLastTextBaseline и т. д. Остальные значения соответствуют значениям UnitPoint.
extension UnitPoint {
init(_ alignment: Alignment) {
switch alignment {
case .leading:
self = .leading
case .topLeading:
self = .topLeading
case .top:
self = .top
case .topTrailing:
self = .topTrailing
case .trailing:
self = .trailing
case .bottomTrailing:
self = .bottomTrailing
case .bottom:
self = .bottom
case .bottomLeading:
self = .bottomLeading
default:
self = .center
}
}
}
let anchor = UnitPoint(alignment)
Нам нужно будет внести поправку в placeSubviews со значением привязки.
let xCorrection = anchor.x * (bounds.width - row.width)
let yCorrection = anchor.y * (row.height - element.size.height)
Кэширование
Мы будем кэшировать минимальный размер контейнера и расположение строк для повышения производительности. Расположение строк зависит как от размера предложения, так и от размера сабвью. Всякий раз, когда они изменяются, расположение строк должно быть пересчитано.
struct Cache {
var minSize: CGSize
var rows: (Int, [Row])?
}
func makeCache(subviews: Subviews) -> Cache {
Cache(minSize: minSize(subviews: subviews))
}
func updateCache(_ cache: inout Cache, subviews: Subviews) {
cache.minSize = minSize(subviews: subviews)
}
func computeHash(proposal: ProposedViewSize, sizes: [CGSize]) -> Int {
let proposal = proposal.replacingUnspecifiedDimensions(by: .infinity)
var hasher = Hasher()
for size in [proposal] + sizes {
hasher.combine(size.width)
hasher.combine(size.height)
}
return hasher.finalize()
}
// In `arrangeRows` beginning
let hash = computeHash(proposal: proposal, sizes: sizes)
if let (oldHash, oldRows) = cache.rows,
oldHash == hash {
return oldRows
}
// In `arrangeRows` end
cache.rows = (hash, rows)
Применение
После всей этой работы мы можем, наконец, переопределить наш список тегов.
WrappingHStack(alignment: .leading) {
ForEach(tags) {
TagView(text: $0.text)
}
}.padding()
Ограничения
Контейнер по своей конструкции не поддерживает сабвью, которые бесконечно увеличиваются по вертикальной оси. Как бы вы вообще определили высоту в этом случае?
Заключительные мысли
Мы написали наш контейнер универсальным способом, который может обрабатывать самые разные сабвью. Это определенно было непросто, и теперь мы можем оценить простоту использования стандартных HStack и VStack.
Полный код смотрите на https://github.com/ksemianov/WrappingHStack.