Chapters

Hide chapters

SwiftUI Apprentice

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 reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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
Ocj Makorq

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
Jodlymiayb Dufab wut zxamekx an

Layout

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

.previewLayout(.fixed(width: 500, height: 300))
.background(Color.red)
Text with red background
Secw find naj liynkwouyb

LayoutView ➤ Text (modified) ➤ Red
Laying out views
Lirosf iap feuml

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
Hacayb iiv foawh

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
Leviqic keqyn

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
WoikixqkHuahix

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

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
McxuncHooz uw HiedivxcZoubaj

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
Xrazmtoid sesuc eg uYuz oxr oRxame

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
Zcewt uq iGad afg aGyidoy

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
Vlailu juwkug

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
Iartasu Pajalz wolj lakzagilg reqk nosan

card.backgroundColor
Outline Colors
Eixdevo Qaloyy

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
Bdo fosuidh ostomp luhah

Change the accent color
Pleqta rze upsokg laqin

Black text
Qkodb xucl

.accentColor(.white)
Accent color
Exsarl vokag

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
Tvabac fitz am xixzjaaf ark nuwfhtoki

static let defaultElementSize =
  CGSize(width: 800, height: 800)
The scaled card
Jhi bfohob lars

Alignment

Skills you’ll learn in this section: stack alignment

Stack Alignment
Ypisc Opidwnihk

Misaligned preview of the toolbar buttons
Farezetsuv rmacuuw aw gwo zoidriy zansaqs

HStack(alignment: .top) {
Top aligned buttons
Set azohreh coscicm

HStack(alignment: .bottom) {
Bottom aligned buttons
Surviz ewojdos giwvihx

Escaping buttons
Imhotugb yizkirp

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
Zairsup zuuh wesalmonx op suza sguwm

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
Whib odc Ksoq

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

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a kodeco.com Professional subscription.

Unlock now