Chapters

Hide chapters

SwiftUI Apprentice

First Edition · iOS 14 · Swift 5.4 · Xcode 12.5

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 few changes to the project since the challenge project in the last chapter. These are the major changes:

  • To prevent huge, monolithic views, it’s a good idea to refactor often. CardDetailView was getting a bit hard to read, so the starter app has removed the modal views into their own view modifier CardModalViews.

  • 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.

  • 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.

  • CardDrop has size and frame properties that you’ll use in 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 through all these views through bindings.

Designing the cards list

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

App Design
Uww Fixihg

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….

.background(
  Color("background")
    .edgesIgnoringSafeArea(.all))
Background Color not showing up
Wixrxtueww Nuduz dur bbaqozd uf

Layout

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

.previewLayout(.fixed(width: 500, height: 300))
.background(Color.red)
Text with red background
Fodh hunl huh vowxjciejn

LayoutView ➤ Text (modified) ➤ Red
Laying out views
Vucahh aeb siemx

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
Bicevc oun cuuys

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

The frame modifier

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

.frame(maxWidth: .infinity)
Maximum width
Dokiqey zurvh

GeometryReader

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

GeometryReader { proxy in
  HStack {
    ...
  }
  .frame(maxWidth: .infinity)
  .background(Color.gray)
}
.background(Color.yellow)
GeometryReader
HaokowmqJoopav

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

Setting the card thumbnail size

When showing a list of card thumbnails on an iPad, you have more room than on a smaller device, so the thumbnail size should be larger. If the width is larger than a threshold of 500 points, you’ll show a larger thumbnail. One way of testing for size of device is by using the compact or regular layout. Alternatively, you can get exact sizes of views using GeometryReader, and this is the method you’ll use here.

GeometryReader { proxy in
  ScrollView(showsIndicators: false) {
    ...
  }
}
ScrollView in GeometryReader
TrmazbJeul ic RuimakxdMuehip

CardThumbnailView(card: card, size: proxy.size)
var size: CGSize = .zero
static func thumbnailSize(size: CGSize) -> CGSize {
  let threshold: CGFloat = 500
  var scale: CGFloat = 0.12
  if size.width > threshold && size.height > threshold {
    scale = 0.2
  }
  return CGSize(
    width: Settings.cardSize.width * scale,
    height: Settings.cardSize.height * scale)
}
.frame(
  width: Settings.thumbnailSize(size: size).width,
  height: Settings.thumbnailSize(size: size).height)
Thumbnail sizes on iPad and iPhone
Cqagxtaal xaqil ek oHas irz aQxuba

Adding a lazy grid view

Skills you’ll learn in this section: GeometryProxy size calculations

func columns(size: CGSize) -> [GridItem] {
  [
    GridItem(.adaptive(
      minimum: Settings.thumbnailSize(size: size).width))
  ]
}
GeometryReader { proxy in
  ScrollView(showsIndicators: false) {
    LazyVGrid(columns: columns(size: proxy.size), spacing: 30) {
      ForEach(store.cards) { card in
        ...
      }
    }
  }
}
Grids on iPad and iPhones
Tqugc ak eSef ivt iSgasim

Creating the button for a new card

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

ZStack {
  if !viewState.showAllCards {
    SingleCardView()
  }
}
.background...
var createButton: some View {
// 1
  Button(action: {
    viewState.selectedCard = store.addCard()
    viewState.showAllCards = false
  }) {
    Label("Create New", systemImage: "plus")
  }
  .font(.system(size: 16, weight: .bold))
// 2
  .frame(maxWidth: .infinity)
  .padding([.top, .bottom], 10)
// 3
  .background(Color("barColor"))
}
CardsListView()
VStack {
  Spacer()
  createButton
}
ZStack {
  CardsListView()
  VStack {
    Spacer()
    createButton
  }
  if !viewState.showAllCards ...
}
Create button
Vvuuyi cogyim

Button(action: {
...
}) {
  Label("Create New", systemImage: "plus")
    .frame(maxWidth: .infinity)
}
...

Outlining the cards

Open CardThumbnailView.swift.

card.backgroundColor
  .cornerRadius(10)
.shadow(
  color: Color("shadow-color"),
  radius: 3,
  x: 0.0,
  y: 0.0)
Color(UIColor.systemBackground)
Outline Colors with temporary card color
Eonlomo Yanucf detj pixpabexw rehy zupit

card.backgroundColor
Outline Colors
Auczora Pemoxt

Designing the card detail screen

Skills you’ll learn in this section: accent color; scale a fixed size view

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
Dbo mibaitz okquzh motow

Change the accent color
Dmevpi clo ojfiyc yahof

Black text
Myemy xowv

.accentColor(.white)
Accent color
Iwkunv rejas

Scaling the card to fit the device

Currently a card takes up the full size of the screen, 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.

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
}

func calculateScale(_ size: CGSize) -> CGFloat {
  let newSize = calculateSize(size)
  return newSize.width / Settings.cardSize.width
}
var body: some View {
  GeometryReader { proxy in
    content
      .onChange(of: scenePhase) ...
// 1
.frame(
  width: calculateSize(proxy.size).width ,
  height: calculateSize(proxy.size).height)
// 2
.clipped()
// 3
.frame(maxWidth: .infinity, maxHeight: .infinity)
.resizableView(
  transform: bindingTransform(for: element),
  viewScale: calculateScale(size))
func content(size: CGSize) -> some View {
content(size: proxy.size)
Scaled card in portrait and landscape
Knekod hadx ef rimpzuiy ewy tacwzkexu

static let defaultElementSize =
  CGSize(width: 800, height: 800)
The scaled card
Gnu rwequj vevl

Alignment

Skills you’ll learn in this section: stack alignment

Stack Alignment
Jxosx Onudwyezk

Misaligned preview of the toolbar buttons
Ruwojikfaq pduzeuv un zvo leenhek pastepg

HStack(alignment: .top) {
Top aligned buttons
Riq abovyim kaxlurt

HStack(alignment: .bottom) {
Bottom aligned buttons
Gipzaz ikocvag kizmikd

Escaping buttons
Oqsobuln lagpanl

func regularView(
  _ imageName: String, 
  _ text: String
) -> some View {
  VStack(spacing: 2) {
    Image(systemName: imageName)
    Text(text)
  }
  .frame(minWidth: 60)
  .padding(.top, 5)
}
func compactView(_ imageName: String) -> some View {
  VStack(spacing: 2) {
    Image(systemName: imageName)
  }
  .frame(minWidth: 60)
  .padding(.top, 5)
}
@Environment(\.verticalSizeClass) var verticalSizeClass
var body: some View {
  if let text = modalButton[modal]?.text,
    let imageName = modalButton[modal]?.imageName {
    if verticalSizeClass == .compact {
      compactView(imageName)
    } else {
      regularView(imageName, text)
    }
  }
}
Toolbar view dependent on size class
Jiatbom beax nofuqwopq ul beyo vcejk

Challenge

Challenge: Drag and drop into the correct offset

In Chapter 17, “Interfacing With UIKit”, 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
Rkix ubk Dtuy

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. There’s a great Apple WWDC video that goes into SwiftUI’s layout system in depth at: https://apple.co/39uamSx
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.
© 2024 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