Туториалы
14 декабря 2022
Туториалы
14 декабря 2022
Создаем оберточный HStack с помощью протокола Layout SwiftUI

Animation

Компонент, который мы собираемся создать, доступен как Swift Package.

 

Вступление

Утро понедельника, и ваш менеджер проекта дает вам задание: добавить список тегов на страницу подробной информации о продукте. Вы говорите «изи» и через 10 минут выдаете следующее.

HStack {
    ForEach(tags) {
        TagView(text: $0.text)
    }

Spacer(minLength: .zero)
}.padding(.horizontal)

 

HStack

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

HStack overflow

Вы продолжаете и делаете для них горизонтальную прокрутку.

ScrollView(.horizontal, showsIndicators: false) {
    LazyHStack {
        ForEach(tags) {
            TagView(text: $0.text)
        }
    }.padding(.horizontal)
}.frame(height: 56)

 

HStack scrollable

Недостатком является то, что вы должны заранее знать высоту вьюшек тегов.

Команда дизайнеров просит переносить теги на последующие строки, когда они не соответствуют ширине основного вью. И теперь ваше "изи" превратилось в “хардово". Один из ваших коллег предлагает обернуть пользовательский 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()

 

WrappingHStack

 


Ограничения

Контейнер по своей конструкции не поддерживает сабвью, которые бесконечно увеличиваются по вертикальной оси. Как бы вы вообще определили высоту в этом случае?

 

Заключительные мысли

Мы написали наш контейнер универсальным способом, который может обрабатывать самые разные сабвью. Это определенно было непросто, и теперь мы можем оценить простоту использования стандартных HStack и VStack.

Полный код смотрите на https://github.com/ksemianov/WrappingHStack.
 


Оцените статью
0
0
0
0
0

Чтобы добавить комментарий, авторизуйтесь
Войти
Безумова Виола
Пишет и переводит статьи для SwiftBook