Chapters

Hide chapters

SwiftUI Apprentice

Second Edition · iOS 16 · Swift 5.7 · Xcode 14.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

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.

View Hierarchy
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
App Design

When there are no cards, the user will see a large add button. There will also be a wide Create New button at the bottom. This is the design that you’ll attempt to duplicate.

Adding the List Background Color

➤ Before adding anything to the project, build and run the app in Simulator and choose Device ▸ Erase All Contents and Settings….

This will delete all the data you have so far created for the app. For the moment, you’ll use the default data provided with the app.

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

.background(
  Color("background")
    .ignoresSafeArea())

This will use a color from the asset catalog named background for the background color. This is defined as light gray for light appearance and dark gray for dark appearance. By using default parameters for ignoresSafeArea(_:edges:), you ensure the background covers all the screen.

➤ Preview the view. In this image, the background color is pink for clarity; yours will be light gray.

Background Color not showing up
Background Color not showing up

Instead of the background color showing across the whole view, even though you’re ignoring all the safe areas, the background color is only showing up in the area of the scroll view. This is because VStack only takes up as much space as required by its child views.

Layout

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

It’s time to take a deeper look at how SwiftUI handles view layout. Most of the time, SwiftUI views lay themselves out and look great, and you don’t have to think about the layout at all. But then comes the time where you want exact positioning, or a view isn’t behaving the way that you thought it would, and you might start fighting the system. Once you understand layout and treat it logically, then it all becomes much easier.

Layout starts from the top of the view hierarchy. The parent view tells its children, “I propose this size”. Each child then takes as much room as it needs within the parent’s available space and tells the parent “I only need this size”. This continues all the way down the view hierarchy. The parent then resizes itself to the size of its child views.

➤ Create a new SwiftUI View file named LayoutView.swift to experiment with various layouts.

➤ In LayoutView_Previews, add a new modifier to LayoutView:

.previewLayout(.fixed(width: 500, height: 300))

This gives a fixed size to the preview of 500 x 300.

➤ In the canvas, switch from Live to Selectable, to see the correct view preview size.

Selectable
Selectable

➤ In LayoutView, add a new modifier to Text:

.background(Color.red)

In the canvas, the red color shows how much space the Text view takes up on screen.

Text with red background
Text with red background

There are three views in the view tree hierarchy here:

LayoutView ➤ Text (modified) ➤ Red

LayoutView has a fixed size of 500 by 300 points. Text takes up the amount of space needed for the letters in the assigned font size. Color is a bit different. It’s a late binding token, which means that the size is assigned at the last moment.

A Color view fills the whole space of its parent.

Laying out views
Laying out views

➤ Change LayoutView to:

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

Here you create a horizontal stack with two Text views. The second Text has padding.

Laying out views
Laying out views

The view tree is now:

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

LayoutView still has the fixed size of 500 by 300 points. HStack presents 500 by 300 points to its children. The first Text returns the space it needs, but the second text has a padding modifier, so returns its space plus the padding. HStack then takes up only the space required by its two child views plus HStack’s default padding between the two child views. HStack’s gray background color fills out the space taken up by HStack underneath the two Text views.

Every time you add a modifier, you create a new layer in the view hierarchy. But don’t worry about the efficiency of this — SwiftUI views are lightweight and adding new views is incredibly fast.

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.

When you want to lay out views relative to parent view sizes, you can specify minimum and maximum widths and heights using frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:).

➤ Before .background(Color.gray), add this:

.frame(maxWidth: .infinity)

The HStack now tells its parent that it wants the maximum available width, so HStack, with its gray color, expands to the whole width of the view.

Maximum width
Maximum width

Remember your earlier problem with the background color only taking up the width of the ScrollView? Specifying a frame with maxWidth and maxHeight of infinity would be one way of filling up the entire available background.

Note: If you want to visualize how much space views take up, try adding .background(Color.red.clipped()) as a modifier to the various views. You clip the color, as the background can sometimes render outside the view frame.

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.

Lazy views fill the vertical or horizontal space, depending on the type of view, of the top level view. These views are finalized late, as their content is only loaded when it is necessary.

➤ In LayoutView, remove .frame(maxWidth: .infinity) and change HStack to:

LazyHStack

LazyHStack fills the container vertically
LazyHStack fills the container vertically

Because the stack can’t determine the height of items that might be loaded later, it takes the vertical size of the parent container, and the color fills that area. Similarly, LazyVStack and LazyVGrid fill the horizontal size of the parent container view.

➤ Undo LazyHStack back to HStack.

Later in the chapter, you’ll explore GeometryReader, which takes up the entire available space of its parent and returns the size in points. Use GeometryReader as a last resort as there are usually other ways to achieve a fluid, animatable layout.

Adding a Lazy Grid View

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

Instead of showing one column of scrolling cards, you’ll add a LazyVGrid to show the cards in multiple columns. This should be adaptive depending on the device’s current display width. The LazyVGrid expands horizontally to fit the parent’s size, so you’ll coincidentally solve the problem of the background color that you had earlier.

➤ Open CardsListView.swift and add a new property to CardsListView:

var columns: [GridItem] {
  [
    GridItem(.adaptive(
      minimum: Settings.thumbnailSize.width))
  ]
}

This returns an array of GridItem — in this case, with one element — you can use this to tell the LazyVGrid the size and position of each row. This GridItem is adaptive, which means the grid will fit as many items as possible with the minimum size provided.

➤ In list, replace the VStack inside ScrollView with:

LazyVGrid(columns: columns, spacing: 30) 

You now have a flexible grid with vertical spacing of 30 points.

➤ Add some padding to ScrollView:

.padding(.top, 20)

In Live Preview, the background color now fills the entire screen. Check out the orientation variants to see how the number of columns changes.

Orientation variants
Orientation variants

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.

However, when you change the layout to split screen, the thumbnail should size smaller. You’ll test for the size of the device by using the compact or regular layout.

Currently, you set the size of the thumbnail in CardThumbnail, but since the number of columns in CardsListView depends on the size of the thumbnail, you’ll size the thumbnail in CardsListView.

➤ Still in CardsListView.swift, add these new properties to CardsListView:

@Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.verticalSizeClass) var verticalSizeClass

These environment properties contain whether the size class is compact or regular so that you’ll know how much space you have available.

➤ Add another new property to CardsListView:

var thumbnailSize: CGSize {
  var scale: CGFloat = 1
  if verticalSizeClass == .regular,
    horizontalSizeClass == .regular {
    scale = 1.5
  }
  return Settings.thumbnailSize * scale
}

If both size classes are regular, you can show a larger size thumbnail.

➤ Change the grid items in columns to:

GridItem(.adaptive(
  minimum: thumbnailSize.width))

➤ In list, add this modifier to CardThumbnail(card:):

.frame(
  width: thumbnailSize.width,
  height: thumbnailSize.height)

You use your new conditional size for the thumbnail and for the column layout.

➤ Open CardThumbnail.swift, cut the frame modifier from RoundedRectangle(cornerRadius:) and paste it to modify CardThumbnail(card:) in CardThumbnail_Previews.

➤ Change the run destination to iPad and build and run the app. On iPad with split screen, you can check both compact and regular sizes.

➤ Add a split screen with Safari and change the size of the split screen. Check out the changing size of the thumbnails.

Thumbnails resize according to size class
Thumbnails resize according to size class

Creating the Button for a new Card

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

➤ Open CardsListView.swift and, in CardsListView, create a new button property:

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("barColor"))
}

Going through this code:

  1. Create a simple button using a Label format so that you can specify a system image. When tapped, you create a new card and assign it to selectedCard. When selectedCard changes, SingleCardView will show.
  2. The button stretches all the way across the screen, less the padding.
  3. The background color is in the asset catalog. You’ll customize the button text color shortly.

In body, replace the current Add button with:

createButton

➤ Test your new button in Live Preview.

Create button
Create button

The button code has a “gotcha”. Although the button frame extends all the way across the screen, only the text is tappable.

➤ In createButton, move frame(maxWidth: .infinity) from being a modifier on Button to a modifier on Label:

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

The button looks the same but is tappable all the way across.

Outlining the Cards

➤ Open CardThumbnail.swift.

An alternative to using a RoundedRectangle is to use the card background color as the view.

➤ Change RoundedRectangle(cornerRadius:) and foregroundColor(_:) to:

card.backgroundColor
  .cornerRadius(10)

This changes the corner radius to match the design, but otherwise produces the same result as before.

➤ Add a modifier to card.background, after cornerRadius(10):

.shadow(
  color: Color("shadow-color"),
  radius: 3,
  x: 0.0,
  y: 0.0)

Here you add a shadow with your specified color and a radius of 3. With the x and y positions both being zero, the shadow will be three points all around the view.

Outline Colors
Outline Colors

This is a very subtle outline color, just to raise up the cards from the background slightly, but if your designer tells you to add it, trust the designer. :]

➤ Temporarily change card.backgroundColor to:

Color(UIColor.systemBackground)

In the Variants preview, the card color is now the same as the screen’s background color and you’ll be able to see the shadow.

Outline Colors with temporary card color
Outline Colors with temporary card color

➤ Change Color(UIColor.systemBackground) back to:

card.backgroundColor

This restores your card’s background color.

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.

➤ Open CardsListView.swift and create the new initial view:

var initialView: some View {
  VStack {
    Spacer()
      let card = Card(
        backgroundColor: Color(uiColor: .systemBackground))
    ZStack {
      CardThumbnail(card: card)
      Image(systemName: "plus.circle.fill")
        .font(.largeTitle)
    }
    .frame(
      width: thumbnailSize.width * 1.2,
      height: thumbnailSize.height * 1.2)
    .onTapGesture {
      selectedCard = store.addCard()
    }
    Spacer()
  }
}

This creates a new temporary card with a plus symbol on it. With the device in Dark Mode, the card should be black, and in Light Mode, the card should be white. Color.primary gives black in Light Mode, and sometimes you can use colorInvert() for the inverse. However, this results in some View rather than the Color you need here. UIKit provides a systemBackground color, so you can use that instead.

Use Spacers to center the card in the view, keeping createButton at the foot of the screen.

➤ In body, replace list with:

Group {
  if store.cards.isEmpty {
    initialView
  } else {
    list
  }
}

You place these two views in a Group so that fullScreenCover(item:onDismiss:content:) modifies both views.

➤ In CardsListView_Previews, replace CardStore(defaultData: true) with:

CardStore(defaultData: false)

Your new card shows up in place of the ScrollView, and you can either use the Create New button or this card to create a new card.

Add card prompt showing Color Scheme variants
Add card prompt showing Color Scheme variants

➤ Test your card prompt in Live Preview and see that, when you add a card, the prompt no longer appears. Delete all the cards and the card prompt reappears. When you’re happy that your card prompt works, in CardsListView_Previews, replace CardStore(defaultData: false) with:

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
The default accent color

➤ Open Assets.xcassets and select AccentColor.

AccentColor is automatically created when you create a new project using the App template.

➤ Change the color to black for Any Appearance and white for Dark Appearance.

Change the accent color
Change the accent color

This will change the default accent color of all the controls throughout the app.

➤ Open CardsListView.swift and preview it.

The Create button text is now black and doesn’t show on the black bar. Black is a great color for buttons the card detail view, but not so great for this button.

Black text
Black text

➤ In createButton, add a new modifier after background(Color("barColor"):

.accentColor(.white)

As the button is dark in both light and dark appearances, you set the button’s accent color to always be white, overriding the app’s default color.

➤ Preview the color scheme variants of both CardsListView and SingleCardView.

Accent color
Accent color

Throughout the app, text takes on AccentColor as defined in Assets.xcassets, except for where you specify accentColor(_:) on specific views.

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

Currently a card takes up the full size of the screen, less the top and bottom safe areas, no matter what device or orientation you’re using. This obviously doesn’t work when you’ve created a portrait card and then turn the device to landscape.

You’re going to create cards with a fixed size of 1300 by 2000. The entire card will be visible at one time, no matter the orientation, and you’ll calculate the appropriate size of the card view using a geometry reader proxy size.

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.

➤ To see how this will work, open LayoutView.swift, embed HStack in a GeometryReader and give it a yellow background:

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

GeometryReader takes up the size of the parent, in this case the whole 500 x 300 point view. It returns a value of type GeometryProxy, which includes a size property so that you can find out exactly the size of the view. You can then lay out child views using this size.

GeometryReader
GeometryReader

Notice that GeometryReader changes alignment behavior. Instead of HStack being centered in its parent view, it is now aligned to the top left of its parent view. You’ll discover more about alignment later in this chapter.

➤ Change HStack’s modifiers to:

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

frame(width:height:alignment) now uses a relative value of 80% of the width of the available area. If the parent view gets larger, for example on device rotation, proxy.size will update and refresh the view. The view will resize to 80% of the new parent size.

To center HStack, you calculate the leading padding, using the geometry proxy width.

GeometryProxy size
GeometryProxy size

Notice the order of the modifiers. If you change the order of any one of these, you’ll get a different result. Before filling with color, you must set the size of the view. If you calculate the padding before filling with gray, then you’ll center the text views but not the background gray color.

Now, you’ll put this knowledge into action.

➤ Open SingleCardView.swift and, in body, embed CardDetailView(card:) in a GeometryReader:

var body: some View {
  NavigationStack {
    GeometryReader { proxy in
      CardDetailView(card: $card)
        .modifier(...

You can now calculate the frame of CardDetailView using the geometry reader proxy size.

➤ Temporarily add these modifiers to CardDetailView(card:):

.frame(
  width: Settings.cardSize.width,
  height: Settings.cardSize.height)
.scaleEffect(0.8)

You set the card frame to the final card size and scale it by 80%.

Card scaled to 80%
Card scaled to 80%

Knowing that views lose their alignment under GeometryReader, you might be surprised to see that the card now appears offset. However, it’s only the rendered view that is showing up. CardDetailView’s frame is still taking up 1300 x 2000.

View frame stays original size
View frame stays original size

You have to take into account the size of CardDetailView on any size device, so it’s easier to convert the frame to the correct size than use scaleEffect(_:anchor:). However, this does mean that in views further down the view hierarchy, you’ll have to take into account how much CardDetailView is scaled.

➤ Open Settings.swift and add these new methods to Settings:

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
}

These methods calculate the size and scale of a view with the correct aspect ratio using a given size. This size comes from the view’s’ GeometryReader’s GeometryProxy.

➤ Open SingleCardView.swift and replace the existing frame and scale modifiers on CardDetailView(card:) with these:

// 1
.frame(
  width: Settings.calculateSize(proxy.size).width,
  height: Settings.calculateSize(proxy.size).height)
// 2
.clipped()
// 3
.frame(maxWidth: .infinity, maxHeight: .infinity)

There’s a lot of layout going on in these few modifiers:

  1. Calculate the size of the card view given the available space.
  2. The background color will spill out of the frame, so clip it.
  3. Make sure that CardDetailView takes up all of the space available to it. This will center the card view in the geometry reader.

In portrait mode, the card probably looks fine in the canvas. The problem comes when you view the card in landscape, and find that the elements aren’t scaling properly.

Incorrect scaling
Incorrect scaling

➤ In the Views group, open ResizableView.swift.

Notice that the new changes in this file adjust all the offsets and sizes to be scaled to viewScale. This defaults to 1, so you don’t have to specify a view scale if you don’t want to.

➤ Open CardDetailView.swift and add a new property:

var viewScale: CGFloat = 1

You’ll pass in the calculated view scale from SingleCardView.

➤ Locate .resizableView(transform: $element.transform) and replace it with:

.resizableView(
  transform: $element.transform,
  viewScale: viewScale)

When ResizableView transforms the size of each element, it now uses the new view scale.

➤ Open SingleCardView.swift and replace CardDetailView(card: $card) with:

CardDetailView(
  card: $card,
  viewScale: Settings.calculateScale(proxy.size))

You calculate the scale of the view and pass it down the hierarchy.

With the view scaled, the element’s default size will be too small.

➤ Open Settings.swift and change defaultElementSize to:

static let defaultElementSize =
  CGSize(width: 800, height: 800)

➤ In CardsApp.swift, change @StateObject var store = CardStore(defaultData: true) to:

@StateObject var store = CardStore(defaultData: false)

➤ Build and run on various devices and orientations and check out your newly scaled card view. The card stays in portrait and is fixed to a scaled 1300 by 2000 size. The elements are also scaled and you can manipulate them in the same way as you did before.

Scaled card in portrait and landscape
Scaled card in portrait and landscape

Alignment

Skills you’ll learn in this section: stack alignment

The final subject in layout that you’ll cover is alignment. Take another look at the previous image. Currently, the images in your toolbar buttons are different sizes which misaligns the button text. Your attention-to-detail gene should have been crying inwardly because of this.

VStack(alignment:spacing:) and HStack(alignment:spacing:) have optional alignment parameters.

Stack Alignment
Stack Alignment

With an HStack, you describe how child views should align vertically, and with a VStack, you describe the horizontal view alignment

➤ Open BottomToolbar.swift and preview the view.

Xcode Tip: Don’t forget your keyboard shortcut Shift-Command-O to quickly open a file by name. To see the current file in the Project navigator, press Shift-Command-J.

Misaligned preview of the toolbar buttons
Misaligned preview of the toolbar buttons

Currently, in BottomToolbar, your toolbar buttons are in a center aligned HStack. This means that the ToolbarButtons, which consist of a VStack with an Image above and Text below, are all center aligned.

➤ In BottomToolbar, change HStack { to:

HStack(alignment: .top) {

This aligns the buttons at the top of the HStack.

Top aligned buttons
Top aligned buttons

➤ Now try bottom alignment. Change the alignment to:

HStack(alignment: .bottom) {

This is the best result as all the text is now aligned.

Bottom aligned buttons
Bottom aligned buttons

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
Escaping buttons

For your challenge, you’ll check the size class of the device use a different icon view for each size class. The compact size class will only show the image, whereas the regular size class will show both image and text.

To achieve this:

  1. In BottomToolbar.swift, in ToolbarButton, add a regularView that shows the image and text and a compactView that only shows the image. You’ll construct these views in methods that take in the image name and the text, if necessary.
  2. Use the environment’s vertical size class to determine whether to show either regularView or compactView.

Toolbar view dependent on size class
Toolbar view dependent on size class

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.

Settings.calculateDropOffset(proxy:location:) returns the offset calculated from the geometry proxy and the drop location. Use this method in CardDetailView.swift to drop items at the correct location. You’ll need to pass the geometry proxy from SingleCardView to CardDetailView. You’ll also need to amend the methods where you add the element to include the offset.

Try out drag and drop on iPad, and you have an infinite number of Google images to decorate your card.

Drag and Drop
Drag and Drop

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.
© 2026 Kodeco Inc.