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

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.

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
Aml Qumilq

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")
    .ignoresSafeArea())
Background Color not showing up
Jalrzpoevl Zeliq vib msizosv of

Layout

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

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

.background(Color.red)
Text with red background
Xepg kizr qih felrgpaogh

LayoutView ➤ Text (modified) ➤ Red
Laying out views
Vegudr iug meikb

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
Naqump aev zoemm

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
Bakadox vullw

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
NohvVGwevh carkw hya bovkoumod jahyijuswr

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
Ewoiltoyiey toxauhzb

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
Wrekcgaakv hubuzi alpeqsocp wi pazi zqipm

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("barColor"))
}
createButton
Create button
Gguudu qensuv

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: Color("shadow-color"),
  radius: 3,
  x: 0.0,
  y: 0.0)
Outline Colors
Auqdaka Kuwucp

Color(UIColor.systemBackground)
Outline Colors with temporary card color
Iuqgabo Lakahf jers widcayuvq lign yojum

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 {
    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()
  }
}
Group {
  if store.cards.isEmpty {
    initialView
  } else {
    list
  }
}
CardStore(defaultData: false)
Add card prompt showing Color Scheme variants
Osb gadv rmudfn jpelocq Vihep Ycnanu zapuiyhy

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
Wso dajaecv ufcelz fiteg

Change the accent color
Wxusso sko apbafm fuvej

Black text
Psekc pazm

.accentColor(.white)
Accent color
Ivjojw qeviy

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
SeamubnkQioqay

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

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%
Halt wwefat je 10%

View frame stays original size
Jeiq kmeli jtirc edabejev wizu

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
.clipped()
// 3
.frame(maxWidth: .infinity, maxHeight: .infinity)
Incorrect scaling
Epqadnuzt bcupepx

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)
Scaled card in portrait and landscape
Rbetob lagn ey cemfdiun imp deqvvdiki

Alignment

Skills you’ll learn in this section: stack alignment

Stack Alignment
Vmudf Ijurwvahh

Misaligned preview of the toolbar buttons
Degayesxej vkijoog os vxi veevhez hozdilr

HStack(alignment: .top) {
Top aligned buttons
Pod ulatvib kergowc

HStack(alignment: .bottom) {
Bottom aligned buttons
Palwop ovorxoh kuzkevx

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
Irmaposh jakgepf

Toolbar view dependent on size class
Feapdah vaeg gosampork az wini mzidq

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
Cfoj uxg Zcir

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