9 сентября 2022

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

WWDC 2022 привнесла много изменений и улучшений в SwiftUI, и обновленный протокол Layout — одно из самых значимых. Об особенностях работы с новыми инструментами компоновки элементов, анонсированными в iOS 16.0, рассказывает iOS-разработчик студии CleverPumpkin Даниил Апальков.
Layout Protocol - иллюстрация
Прежде чем говорить о новых возможностях протокола, давайте посмотрим, как выглядит текущий механизм 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, требуется небольшая подготовка:
  • Нужно запросить размер. Для этого попросим в виде входных данных радиус окружности, и посчитаем самый большой элемент в коллекции, так как они могут быть разного размера. Для каждого из элементов мы будем опираться на его идеальный размер, то есть предложим нашим Subview размер шириной и высотой nil, чтобы они заняли необходимое пространство.
  • Опираясь на количество Subview и радиус окружности, мы сможем посчитать координаты и выложить наши элементы по кругу внутри запрошенного места
Начнем с первого пункта:
Круговой 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?
}

  • layoutProperties - информация о Layout'е, например о его направлении (горизонтальное или вертикальное);

  • Cache - тип, который может быть использован для улучшения быстродействия Layout'a. Как вы могли заметить, мы несколько раз и в методе sizeThatFits и в методе placeSubviews вызывали метод getMaxSize. В некоторых случаях, между первыми двумя методами могут быть общие и дорогостоящие расчеты, которые как раз можно сохранить в Cache. Он является inout параметром и может быть модифицирован из любого места. В случае CircleLayout, я бы мог сделать Cache типа CGSize, и сохранить туда мой максимальный размер, чтобы не считать его дважды;

  • makeCache - место для таких расчетов и сохранений. Также SwiftUI может по мере обновления экрана попросить обновить Cache, для чего и существует updateCache;

  • Методы spacing, и explicitAlignment нужны для того, чтобы в зависимости от соответствующих свойств у Subviews определить, необходим ли spacing у нас как контейнера.

И еще одна особенность, привнесенная 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 стало заметно проще решать задачи, связанные с выделением места и расположением элементов на экране с минимумом кода и максимумом читаемости. Но не стоит забывать, что этот новый инструмент пока на Бета-стадии, и до релиза он может добраться в несколько измененном виде.
Круговой Layout
Есть идея?