2022 SwiftUI & UI Frameworks
WWDC22 · 27 min · SwiftUI & UI Frameworks
Compose custom layouts with SwiftUI
SwiftUI now offers powerful tools to level up your layouts and arrange views for your app’s interface. We’ll introduce you to the Grid container, which helps you create highly customizable, two-dimensional layouts, and show you how you can use the Layout protocol to build your own containers with completely custom behavior. We’ll also explore how you can create seamless animated transitions between your layout types, and share tips and best practices for creating great interfaces.
Watch at developer.apple.com ↗Code shown on screen · 16 snippets
Grid with explicit rows
struct Leaderboard: View {
var body: some View {
Grid {
GridRow {
Text("Cat")
ProgressView(value: 0.5)
Text("25")
}
GridRow {
Text("Goldfish")
ProgressView(value: 0.2)
Text("9")
}
GridRow {
Text("Dog")
ProgressView(value: 0.3)
Text("16")
}
}
}
} Data model
struct Pet: Identifiable, Equatable {
let type: String
var votes: Int = 0
var id: String { type }
static var exampleData: [Pet] = [
Pet(type: "Cat", votes: 25),
Pet(type: "Goldfish", votes: 9),
Pet(type: "Dog", votes: 16)
]
} Final Leaderboard
struct Leaderboard: View {
var pets: [Pet]
var totalVotes: Int
var body: some View {
Grid(alignment: .leading) {
ForEach(pets) { pet in
GridRow {
Text(pet.type)
ProgressView(
value: Double(pet.votes),
total: Double(totalVotes))
Text("\(pet.votes)")
.gridColumnAlignment(.trailing)
}
Divider()
}
}
.padding()
}
} Layout protocol stubs for required methods
struct MyEqualWidthHStack: Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize {
// Return a size.
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
// Place child views.
}
} Maximum size helper method
private func maxSize(subviews: Subviews) -> CGSize {
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in
CGSize(
width: max(currentMax.width, subviewSize.width),
height: max(currentMax.height, subviewSize.height))
}
return maxSize
} Spacing helper method
private func spacing(subviews: Subviews) -> [CGFloat] {
subviews.indices.map { index in
guard index < subviews.count - 1 else { return 0 }
return subviews[index].spacing.distance(
to: subviews[index + 1].spacing,
along: .horizontal)
}
} Size that fits implementation
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize {
// Return a size.
guard !subviews.isEmpty else { return .zero }
let maxSize = maxSize(subviews: subviews)
let spacing = spacing(subviews: subviews)
let totalSpacing = spacing.reduce(0) { $0 + $1 }
return CGSize(
width: maxSize.width * CGFloat(subviews.count) + totalSpacing,
height: maxSize.height)
} Place subviews implementation
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
// Place child views.
guard !subviews.isEmpty else { return }
let maxSize = maxSize(subviews: subviews)
let spacing = spacing(subviews: subviews)
let placementProposal = ProposedViewSize(width: maxSize.width, height: maxSize.height)
var x = bounds.minX + maxSize.width / 2
for index in subviews.indices {
subviews[index].place(
at: CGPoint(x: x, y: bounds.midY),
anchor: .center,
proposal: placementProposal)
x += maxSize.width + spacing[index]
}
} Custom layout instantiation
MyEqualWidthHStack {
ForEach($pets) { $pet in
Button {
pet.votes += 1
} label: {
Text(pet.type)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
} Buttons helper view
struct Buttons: View {
var pets: [Pet]
var body: some View {
ForEach($pets) { $pet in
Button {
pet.votes += 1
} label: {
Text(pet.type)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
} Final voting buttons view
struct StackedButtons: View {
var pets: [Pet]
var body: some View {
ViewThatFits {
MyEqualWidthHStack {
Buttons(pets: $pets)
}
MyEqualWidthVStack {
Buttons(pets: $pets)
}
}
}
} Radial size that fits
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize {
// Take whatever space is offered.
return proposal.replacingUnspecifiedDimensions()
} Radial place subviews without offsets
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
let radius = min(bounds.size.width, bounds.size.height) / 3.0
let angle = Angle.degrees(360.0 / Double(subviews.count)).radians
let offset = 0 // This depends on rank...
for (index, subview) in subviews.enumerated() {
var point = CGPoint(x: 0, y: -radius)
.applying(CGAffineTransform(
rotationAngle: angle * Double(index) + offset))
point.x += bounds.midX
point.y += bounds.midY
subview.place(at: point, anchor: .center, proposal: .unspecified)
}
} Rank value
private struct Rank: LayoutValueKey {
static let defaultValue: Int = 1
}
extension View {
func rank(_ value: Int) -> some View {
layoutValue(key: Rank.self, value: value)
}
} Radial place subviews with offsets
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
let radius = min(bounds.size.width, bounds.size.height) / 3.0
let angle = Angle.degrees(360.0 / Double(subviews.count)).radians
let ranks = subviews.map { subview in
subview[Rank.self]
}
let offset = getOffset(ranks)
for (index, subview) in subviews.enumerated() {
var point = CGPoint(x: 0, y: -radius)
.applying(CGAffineTransform(
rotationAngle: angle * Double(index) + offset))
point.x += bounds.midX
point.y += bounds.midY
subview.place(at: point, anchor: .center, proposal: .unspecified)
}
} Final profile view
struct Profile: View {
var pets: [Pet]
var isThreeWayTie: Bool
var body: some View {
let layout = isThreeWayTie ? AnyLayout(HStackLayout()) : AnyLayout(MyRadialLayout())
Podium() // Creates the background that shows ranks.
.overlay(alignment: .top) {
layout {
ForEach(pets) { pet in
Avatar(pet: pet)
.rank(rank(pet))
}
}
.animation(.default, value: pets)
}
}
} Resources
Related sessions
-
8 min -
18 min -
34 min -
22 min -
1 min -
19 min -
40 min