Site icon AppTractor

Layout Protocol: новые возможности SwiftUI

WWDC 2022 привнесла много изменений и улучшений в SwiftUI, и обновленный протокол Layout — одно из самых значимых. Об особенностях работы с новыми инструментами компоновки элементов, анонсированными в iOS 16.0, рассказывает iOS-разработчик студии CleverPumpkin Даниил Апальков.

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

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

На практике все чуть сложнее. В текущих версиях iOS мы взаимодействуем с механизмом Layout’a в основном через .frame модификатор. View в SwiftUI имеет несколько свойств и ограничений, определяющих необходимое ему место. Через .frame-модификатор можно указать максимальный, минимальный и идеальный размеры. Также у View есть предпочтения по отступам с разных сторон в различных контекстах, подробнее об этом написано в статье автора javier на swiftui-lab.

При таком подходе в комбинации с .frame модификатором используются GeometryReader для прочтения предлагаемого контейнером размера и PreferenceKey для сохранения параметров одной или нескольких View, чтобы на их основании еще раз вернуться в корень иерархии и подкорректировать Layout.

Такой подход абсолютно не очевиден, сильно усложняет код, а в случае с PreferenceKey при неправильном обращении может привести к бесконечному циклу Layout’ов и падению приложения.

Давайте разберем новый Layout протокол, предложенный Apple, который позволяет внедриться непосредственно в описанный выше процесс. Весь протокол представляет собой небольшой набор методов и полей.

Для начала сфокусируемся на двух основных:

public protocol Layout : Animatable {
    //...
    associatedtype Cache = Void
    //...
    func sizeThatFits(
        proposal: ProposedViewSize, 
        subviews: Self.Subviews, 
        cache: inout Self.Cache
    ) -> CGSize

    func placeSubviews(
        in bounds: CGRect, 
        proposal: ProposedViewSize,
        subviews: Self.Subviews, 
        cache: inout Self.Cache
    )
    //...
}

Можно отметить несколько особенностей: во-первых, протокол использует определенный тип для дочерних элементов — Subviews. Эта коллекция рандомного доступа предоставляет интерфейс для взаимодействия с элементами определенным путем, поэтому просто достать непосредственно View в процессе Layout’а у нас не выйдет.

Обратите внимание, что ProposedViewSize — отдельный тип, который может быть инициализирован из привычного нам CGSize. Разница в том, что и предложенная ширина и высота в ProposedViewSize могут быть nil, размер может быть бесконечным или вовсе неуказанным, и таким образом мы можем позволить объекту занять свой идеальный размер.

Также у ProposedViewSize есть метод, который заменяет незаданные измерения исходя из значения размера, который передается как параметр метода. Здесь мы видим те самые магические 10 пойнтов отступа как значение по умолчанию:

public func replacingUnspecifiedDimensions(
    by size: CGSize = CGSize(width: 10, height: 10)
) -> CGSize

Давайте перейдем к практике и разберем работу протокола на примере простенького кругового Layout’а:

Чтобы написать этот Layout, требуется небольшая подготовка:

Начнем с первого пункта:

struct CircleLayout: Layout {
    
    let radius: CGFloat
    
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let maxSize = getMaxSize(for: subviews)
        
        return CGSize(
            width: radius * 2 + maxSize.width,
            height: radius * 2 + maxSize.height
        )
    }
    //...

    private func getMaxSize(for subviews: Subviews) -> CGSize {
        subviews.reduce(.zero) { currentMaxSize, subview in
            let currentSize = subview.sizeThatFits(.unspecified)
            return CGSize(
                width: max(currentSize.width, currentMaxSize.width),
                height: max(currentSize.height, currentMaxSize.height)
            )
        }
    }
}

Обратите внимание: чтобы узнать размер у Subview, необходимо использовать метод sizeThatFits, предлагая ProposedViewSize. В нашем случае он указан как .unspecified, и Subview занимает столько места, сколько ему необходимо. В расчете мы используем “диаметр” плюс “высота”/“ширина” самого большого элемента в коллекции, чтобы все наши Subview поместились внутри.

Теперь, когда мы попросили место, пора расположить элементы по кругу. Для этого высчитываем координату и размер, которые будем использовать, чтобы расположить Subview, используя метод place(at:proposal:):

struct CircleLayout: Layout {
    //...
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let maxSize = getMaxSize(for: subviews)
        let proposal = ProposedViewSize(maxSize)
        for (index, subview) in subviews.enumerated() {
            let center = CGPoint(x: bounds.midX, y: bounds.midY)
            let origin = getPosition(
                forSubviewIndex: index,
                radius: radius,
                center: center,
                subviewsCount: subviews.count
            )
            subview.place(at: origin, proposal: proposal)
        }
    }

    //...

    private func getPosition(
        forSubviewIndex index: Int,
        radius: CGFloat,
        center: CGPoint,
        subviewsCount: Int
    ) -> CGPoint {
        let theta = (CGFloat.pi * 2 / CGFloat(subviewsCount)) * CGFloat(index)
        return CGPoint(
            x: center.x + radius * cos(theta),
            y: center.y + radius * sin(theta)
        )
    }
}

Готово!

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

public protocol Layout : Animatable {
    static var layoutProperties: LayoutProperties { get }
    associatedtype Cache = Void
    typealias Subviews = LayoutSubviews
    func makeCache(subviews: Self.Subviews) -> Self.Cache
    func updateCache(_ cache: inout Self.Cache, subviews: Self.Subviews)
    func spacing(subviews: Self.Subviews, cache: inout Self.Cache) -> ViewSpacing
    func sizeThatFits(proposal: ProposedViewSize, subviews: Self.Subviews, cache: inout Self.Cache) -> CGSize
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Self.Subviews, cache: inout Self.Cache)
    func explicitAlignment(of guide: HorizontalAlignment, in bounds: CGRect, proposal: ProposedViewSize, subviews: Self.Subviews, cache: inout Self.Cache) -> CGFloat?
    func explicitAlignment(of guide: VerticalAlignment, in bounds: CGRect, proposal: ProposedViewSize, subviews: Self.Subviews, cache: inout Self.Cache) -> CGFloat?
}

И еще одна особенность, привнесенная iOS 16 в процесс Layout’а в SwiftUI — AnyLayout. С его помощью мы можем динамически менять Layout в зависимости от State с анимацией:

enum LayoutKind: Int, CaseIterable {
        case vertical
        case horizontal
        case z
        case grid
        case circle
    }
    @State var layoutKind: LayoutKind = .vertical
    // Между бетами стандартные классы Layout-ов могут меняться
    // В будущем, в идеале можно будет использовать стандартные VStack и HStack
    var layout: AnyLayout {
        switch layoutKind {
        case .vertical:
            return AnyLayout(_VStackLayout())
        case .horizontal:
            return AnyLayout(_HStackLayout())
        case .z:
            return AnyLayout(_ZStackLayout())
        case .grid:
            return AnyLayout(_GridLayout())
        case .circle:
            return AnyLayout(CircleLayout(radius: 100))
        }
    }

Layout протокол — большой скачок для SwiftUI, решающий массу проблем. С его появлением в iOS 16 стало заметно проще решать задачи, связанные с выделением места и расположением элементов на экране с минимумом кода и максимумом читаемости. Но не стоит забывать, что этот новый инструмент пока на Бета-стадии, и до релиза он может добраться в несколько измененном виде.

Exit mobile version