Chapters

Hide chapters

SwiftUI Apprentice

Third Edition · iOS 18 · Swift 5.9 · Xcode 16.2

Section I: Your First App: HIITFit

Section 1: 12 chapters
Show chapters Hide chapters

Section II: Your Second App: Cards

Section 2: 9 chapters
Show chapters Hide chapters

20. Delightful UX — Layout
Written by Caroline Begbie

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

With the functionality completed and your app working so well, it’s time to make the UI look and feel delightful. Following the Pareto 80/20 principle, this last twenty percent of code can often take eighty percent of the time. But it’s worth it, because while it’s important to make sure that the app works, nobody is going to want to use your app unless it looks and feels great.

The Starter app

There are a couple of changes to the project since the challenge project in the previous chapter. These are the major changes:

  • The asset catalog has more pleasing random colors to use for backgrounds, as well as other colors that you’ll use in these last chapters. ColorExtensions.swift now uses these colors.
  • ResizableView uses a view scale factor so that later on, you can easily scale the card. The default scale is 1, so you won’t notice it to start with.
  • CardsApp initializes the app data with the default preview data provided, so that you have the same data as the chapter. Remember to change to @StateObject var store = CardStore() in CardsApp.swift when you want to start saving your own cards again.
  • Fixed card deletion in CardStore so that a deleted card removes all the image files from Documents as well as from cards.
  • Settings.swift contains a method you’ll use to complete the challenge.

This is the view hierarchy of the app you’ve created so far.

CardsListView CardToolbar BottomToolbar CardThumbnail CardDetailView ElementContextMenu SingleCardView CardElementView
View Hierarchy

As you can see, it’s very modular. For example, you can change the way the card thumbnail looks and slot it right back in. You can easily add buttons to the toolbar and add a corresponding modal.

You instantiate the one single source of truth — CardStore — and pass it down to all these views through the environment.

Designing the Cards List

The designer of this app has suggested this design for Light and Dark Modes:

App Design
Izc Jagilx

Adding the List Background Color

➤ Open CardsListView.swift and add a modifier to the top VStack in body:

.background(
  Color.background
    .ignoresSafeArea())
Background Color not showing up
Dotqfciikw Bevux nap yferapv il

Layout

Skills you’ll learn in this section: control view layout

#Preview(traits: .sizeThatFitsLayout) {
    LayoutView()
    .frame(width: 500, height: 300)
}
Selectable
Kowunrikje

.background(Color.red)
Text with red background
Lixw fadv yen ladzbpaenv

LayoutView ➤ Text (modified) ➤ Red
GuruunBain 456 946 Gaqop qime Kotn Kega ir pigf Temel Nujnp ofioqegbe ygipe Mayja, vegfp
Xilett aec jaofq

struct LayoutView: View {
  var body: some View {
    HStack {
      Text("Hello, World!")
        .background(Color.red)
      Text("Hello, World!")
        .padding()
        .background(Color.red)
    }
    .background(Color.gray)
  }
}
Laying out views
Vuhuxm uoy seopj

LayoutView ➤ HStack ➤ Text (modified) ➤ Red
                    ➤ Text (modified) ➤ Padding (modified) ➤ Red
                    ➤ Gray

The Frame Modifier

In previous code, you changed the default size of views using frame(width:height:alignment:), giving absolute values to width and height.

.frame(maxWidth: .infinity)
Maximum width
Jexedut bojsr

Views That use Their Parents’ Size

Some views use all the available space from the view at the top of the view hierarchy. You’ve already come across Color, which only fills when the parent has resolved the size from its child views.

LazyHStack
LazyHStack fills the container vertically
HonlHWnoty qawhr fha geygiikod kazgukarmt

Adding a Lazy Grid View

Skills you’ll learn in this section: shadows; accent color

var columns: [GridItem] {
  [
    GridItem(.adaptive(
      minimum: Settings.thumbnailSize.width))
  ]
}
LazyVGrid(columns: columns, spacing: 30)
.padding(.top, 20)
Orientation variants
Afoupriwaof hehiutgp

Setting the Card Thumbnail Size

In Chapter 16, “Adding Assets to Your App”, you learned about size classes and loaded a different launch image depending on the size class. When showing a list of card thumbnails on an iPad (not in split screen), you have more room available than on a smaller device, so the thumbnail size should be larger.

@Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.verticalSizeClass) var verticalSizeClass
var thumbnailSize: CGSize {
  var scale: CGFloat = 1
  if verticalSizeClass == .regular,
    horizontalSizeClass == .regular {
    scale = 1.5
  }
  return Settings.thumbnailSize * scale
}
GridItem(.adaptive(
  minimum: thumbnailSize.width))
.frame(
  width: thumbnailSize.width,
  height: thumbnailSize.height)
Thumbnails resize according to size class
Wjinlwuejy dihagi ajmohpawt go lomo gyors

Creating the Button for a new Card

You’ll now place a button at the foot of the screen to create a new card.

var createButton: some View {
// 1
  Button {
    selectedCard = store.addCard()
  } label: {
    Label("Create New", systemImage: "plus")
  }
  .font(.system(size: 16, weight: .bold))
// 2
  .frame(maxWidth: .infinity)
  .padding([.top, .bottom], 10)
// 3
  .background(Color.bar)
}
createButton
Create button
Kvueca qatfec

Button {
  selectedCard = store.addCard()
} label: {
  Label("Create New", systemImage: "plus")
    .frame(maxWidth: .infinity)
}
...

Outlining the Cards

➤ Open CardThumbnail.swift.

card.backgroundColor
  .cornerRadius(10)
.shadow(
  color: .shadow,
  radius: 3,
  x: 0.0,
  y: 0.0)
Outline Colors
Ouvyora Xaqeyz

Color(UIColor.systemBackground)
Outline Colors with temporary card color
Aervoli Tafepn huvl mesnorocx qosg lufoy

card.backgroundColor

Adding a Button When There Are No Cards

When users first open your app, they need some prompting to add a new card. As well as the Create New button, you’ll add a single card with a plus sign.

var initialView: some View {
  VStack {
    let card = Card(
      backgroundColor: Color(
        uiColor: .systemBackground))
    ZStack {
      CardThumbnail(card: card)
      Image(systemName: "plus.circle.fill")
        .font(.largeTitle)
    }
    .onTapGesture {
      selectedCard = store.addCard()
    }
  }
  .frame(
    width: thumbnailSize.width * 1.2,
    height: thumbnailSize.height * 1.2)
  .padding(.bottom, 20)
}
.overlay {
  if store.cards.isEmpty {
    ContentUnavailableView {
      initialView
    } description: {
      Text("Tap the plus button to add a card")
    }
  }
}
CardStore(defaultData: false)
xcrun simctl --set previews delete all
Add card prompt showing Color Scheme variants
Efj xuxb vdovdy cxayufk Revaw Nwbaru hijuiskd

CardStore(defaultData: true)

Customizing the Accent Color

The app’s accent color determines the default color of the text on app controls. You can set this for the entire application by changing the color AccentColor in the asset catalog, or you can change the accent color per view with the accentColor(_:) modifier. The default is blue, which doesn’t work at all well for the text button:

The default accent color
Xro coguocw etjevs koheq

Change the accent color
Rwange bje ukfujz dafun

Black text
Gnebr sigt

.accentColor(.white)
Accent color
Aqnuzs modac

Scaling the Card to fit the Device

Skills you’ll learn in this section: scale a fixed size view; GeometryReader; use given view size to layout child views

GeometryReader

GeometryReader is a container view that takes up the entire available space and returns its preferred size in points. Using this size, you can determine the size of CardDetailView, based upon the width of the available space. Given precise card size coordinates, you’ll also be able to drop items dragged from other apps at the correct drop position.

GeometryReader { proxy in
  HStack {
    ...
  }
  .background(Color.gray)
}
.background(Color.yellow)
GeometryReader
HuituvchDeijuy

.frame(width: proxy.size.width * 0.8)
.background(Color.gray)
.padding(
  .leading, (proxy.size.width - proxy.size.width * 0.8) / 2)
GeometryProxy size
VoecocfqFxehx loqu

var body: some View {
  NavigationStack {
    GeometryReader { proxy in
      CardDetailView(card: $card)
        .modifier(...
.frame(
  width: Settings.cardSize.width,
  height: Settings.cardSize.height)
.scaleEffect(0.8)
Card scaled to 80%
Yotx hnefud mo 29%

View frame stays original size
Jaor fbobi tyufz uhowuqab gove

static func calculateSize(_ size: CGSize) -> CGSize {
  var newSize = size
  let ratio =
    Settings.cardSize.width / Settings.cardSize.height

  if size.width < size.height {
    newSize.height = min(size.height, newSize.width / ratio)
    newSize.width = min(size.width, newSize.height * ratio)
  } else {
    newSize.width = min(size.width, newSize.height * ratio)
    newSize.height = min(size.height, newSize.width / ratio)
  }
  return newSize
}

static func calculateScale(_ size: CGSize) -> CGFloat {
  let newSize = calculateSize(size)
  return newSize.width / Settings.cardSize.width
}
// 1
.frame(
  width: Settings.calculateSize(proxy.size).width,
  height: Settings.calculateSize(proxy.size).height)
// 2
.frame(maxWidth: .infinity, maxHeight: .infinity)
Incorrect scaling
Eshogtuqf xtuvuqf

var viewScale: CGFloat = 1
.resizableView(
  transform: $element.transform,
  viewScale: viewScale)
CardDetailView(
  card: $card,
  viewScale: Settings.calculateScale(proxy.size))
static let defaultElementSize =
  CGSize(width: 800, height: 800)
@StateObject var store = CardStore(defaultData: false)
Card covers the screen
Xoys kofeyq nwi fndees

initialCards[1]
The card is the correct size
Kva cavd an vdi yefhosj fada

Size of views

Remember from earlier in the chapter that the parent proposes a size to child views. The child view then responds with the size that the child needs.

.onGeometryChange(for: CGSize.self) { proxy in
  print("Size change:", proxy.size)
  return proxy.size
} action: { _ in }
Size change: (0.0, 0.0)
Size change: (402.0, 618.4615384615385)
Size change: (340.0, 523.0769230769231)
Size change: (198.25, 305.0)
Size change: (800.0, 800.0)
Expanded card view
Ujrecxoj xasg poub

ZStack {
.overlay {
.clipped()
Scaled card in portrait and landscape
Zhupud numd ej juzvfooy ipf qiyrcsere

Alignment

Skills you’ll learn in this section: stack alignment

bim gouxabz zerqot JQfapw RCwiby nseojelz mijhug gohnaz
Ycahy Enapjquml

Misaligned preview of the toolbar buttons
Cekahuxnoc cpanaeq af gko duildez piqxudd

HStack(alignment: .top) {
Top aligned buttons
Qex agezkat hagkepw

HStack(alignment: .bottom) {
Bottom aligned buttons
Selruf epowron qeysert

Challenges

Challenge 1: Resize the Bottom Toolbar Icons

When you build and run the app on iPhone and rotate to landscape, you’ll see that because the images and text escape from the constrained size of the toolbar, the alignment is lost. In addition, the home bar covers the text.

Escaping buttons
Uwnuxerz duckurd

Toolbar view dependent on size class
Zuuydax siic dujektirp oc goju lbaqn

Challenge 2: Drag and Drop to the Correct Offset

In Chapter 17, “Adding Photos to Your App”, you implemented drag and drop. However, when you drop an item, it adds to the card in the center, at offset zero. With GeometryReader, you can now convert the dropped location into the correct offset on the card.

Drag and Drop
Fqam emv Xwif

Key Points

  • Even though your app works, you’re not finished until your app is fun to use. If you don’t have a professional designer, try lots of different designs and layouts until one clicks.
  • Layout in SwiftUI needs careful thought, as sometimes it can be unpredictable. The golden rule is that views take their size from their children.
  • GeometryReader is a view that returns its preferred size and frame in a GeometryProxy. That means that any view in the GeometryReader view hierarchy can access the size and frame to size itself.
  • Stacks have alignment capabilities. If these aren’t enough, you can create your own custom alignments, too. The Apple video, Building Custom Views with SwiftUI, examines SwiftUI’s layout system in depth.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2025 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now