iOS with SwiftUI · SwiftUI Foundations: Views, Layout, Lists and State
Layout with Stacks, Modifiers and Adaptive Design
Arrange views with VStack, HStack and ZStack, size and space them with frames, padding and Spacer, understand why modifier order matters, and support Dynamic Type, dark mode and iPad.
What you'll build
A product card: an image placeholder on the left, title and price stacked on the right, and a full-width "Add to cart" button underneath, which becomes a grid of cards on iPad and keeps working at the largest text sizes.
The idea
SwiftUI layout is a negotiation: a parent proposes a size, the child chooses its size, and the parent places it.
VStack,HStackandZStackarrange children vertically, horizontally and in layers;Spacertakes free space.- Modifiers wrap views, so order matters:
.padding().background(.blue)colours the padding,.background(.blue).padding()doesn't. .frame(maxWidth: .infinity)makes a view fill the available width.- Dynamic Type: text styles (
.headline,.body) scale with the user's text-size setting; layouts must grow vertically rather than truncate. - Size classes (
compactandregularhorizontal width) tell you whether you have phone-like or iPad-like space.ViewThatFitspicks the first layout that fits. - Semantic colours (
.primary,.secondary,Color.accentColor) adapt to dark mode automatically.
The code
struct ProductCard: View {
let product: Product
let onAdd: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 16) {
RoundedRectangle(cornerRadius: 12)
.fill(.tint.opacity(0.2))
.frame(width: 64, height: 64)
VStack(alignment: .leading, spacing: 4) {
Text(product.title).font(.headline).lineLimit(2)
Text(product.priceLabel).foregroundStyle(.tint)
}
Spacer(minLength: 0)
}
Button("Add to cart", action: onAdd)
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
}
.padding()
.background(.background.secondary, in: .rect(cornerRadius: 16))
}
}
struct ProductGrid: View {
let products: [Product]
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
let columns = Array(repeating: GridItem(.flexible(), spacing: 12), count: sizeClass == .regular ? 3 : 1)
ScrollView {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(products) { product in
ProductCard(product: product, onAdd: {})
}
}
.padding()
}
}
}
#Preview("Large text, dark") {
ProductGrid(products: Product.samples)
.environment(\.dynamicTypeSize, .accessibility2)
.preferredColorScheme(.dark)
}
Try the logic
Column counts and price formatting are logic the views call:
enum SizeClass { case compact, regular }
func columns(for sizeClass: SizeClass, isLandscape: Bool) -> Int {
switch (sizeClass, isLandscape) {
case (.compact, _): 1
case (.regular, false): 2
case (.regular, true): 3
}
}
func priceLabel(cents: Int, currency: String = "EUR") -> String {
let fraction = cents % 100
return "\(currency) \(cents / 100).\(fraction < 10 ? "0" : "")\(fraction)"
}
for (device, size, landscape) in [("iPhone", SizeClass.compact, false), ("iPad portrait", .regular, false),
("iPad landscape", .regular, true)] {
print("\(device): \(columns(for: size, isLandscape: landscape)) column(s)")
}
print(priceLabel(cents: 1999))
print(priceLabel(cents: 505, currency: "USD"))
Output
iPhone: 1 column(s) iPad portrait: 2 column(s) iPad landscape: 3 column(s) EUR 19.99 USD 5.05
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Fixed frames for text.
.frame(height: 44)around text clips it at large Dynamic Type sizes; let text define its height. - Wrong modifier order. A background before padding doesn't cover the padding; a tap area added before padding is smaller than the visible view.
- Hard-coded colours.
Color.whitetext disappears in dark mode backgrounds; use semantic colours and materials. - Assuming iPhone only. iPad split view gives your app a compact width too; adapt to size classes, not device models.
Key points
- Stacks,
Spacerand frames cover most layouts; parents propose, children choose their size. - Modifiers wrap views, so their order changes the result.
- Support Dynamic Type and dark mode by using text styles and semantic colours.
- Adapt layouts with size classes and
ViewThatFits.
Further reading: Layout fundamentals, Human Interface Guidelines: Layout.
Practice questions
How does SwiftUI decide the size of a view?
Answer
The parent proposes a size to the child, the child chooses its own size (text takes what it needs, shapes and Color take everything proposed, frames constrain), and then the parent positions the child. Stacks divide the proposal among their children, giving flexible children what's left.
How do you make a SwiftUI layout work with Dynamic Type?
Answer
Use built-in text styles (.body, .headline) so fonts scale, avoid fixed heights and widths around text, allow multiple lines, and switch to a vertical layout when an HStack no longer fits (for example with ViewThatFits or by checking dynamicTypeSize.isAccessibilitySize). Test with large sizes in previews.
Open in the app to mark lessons complete, take notes and track your progress.