Компонент, который мы собираемся создать, доступен как 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.

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


  1. DevlabStudio
    16.12.2022 12:09

    Вот только недавно делал похожие вещи в своем проекте, страдать пришлось =). Но там из-за версии нельзя было Layout использовать, пришлось читать размер View через GeometryReader в расширении View

    import SwiftUI
    
    extension View {
        func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
            background(
                GeometryReader { geometryProxy in
                    Color.clear
                        .preference(
                            key: SizePreferenceKey.self,
                            value: geometryProxy.size
                        )
                }
            )
            .onPreferenceChange(
                SizePreferenceKey.self,
                perform: onChange
            )
        }
    }
    
    private struct SizePreferenceKey: PreferenceKey {
        static var defaultValue: CGSize = .zero
        static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
    }

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


    1. DevlabStudio
      16.12.2022 12:15

      Вот, что в итоге удалось добиться!