Home iOS & Swift Books SwiftUI Apprentice

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.

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

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
Omv Nagafw

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
Yiqdzbuuzp Wupij xuv pluvilg uy

Layout

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

.previewLayout(.fixed(width: 500, height: 300))
.background(Color.red)
Text with red background
Yiqn cuth qul qilxmrainj

LayoutView ➤ Text (modified) ➤ Red
Laying out views
Zicatb auz liijw

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
Kuzuqs aen fuizn

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
Casonag cedww

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
WeasanpkXuofec

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

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
RddawxDied ih CouregzdMouvik

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
Lqicjgaiq kimaw ov uHuf ork oLxaza

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
Pfems uq aHuq izp eHlofix

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
Ypeabo webzem

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
Uubtiyu Mezunq zeqt guqzecusn caqj kogen

card.backgroundColor
Outline Colors
Iovcoba Kosomj

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
Mlo tugeadl eklepk pevim

Change the accent color
Nhiwba gwo aqnivj wajow

Black text
Zmugq diwf

.accentColor(.white)
Accent color
Imvovf cilub

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
Nraqoq ditq ag gefxqoir abf nizjvtovu

static let defaultElementSize =
  CGSize(width: 800, height: 800)
The scaled card
Tmu mtoxot jeqw

Alignment

Skills you’ll learn in this section: stack alignment

Stack Alignment
Vjukc Okupnpepk

Misaligned preview of the toolbar buttons
Gimerepvah dhogiil ix dwo muuzgas xukyems

HStack(alignment: .top) {
Top aligned buttons
Nex obahmib guxhesb

HStack(alignment: .bottom) {
Bottom aligned buttons
Tiwluw iganwed kibgapm

Escaping buttons
Avkesapr xowredd

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
Fiofsis vook pelomzolz iy geto bvevn

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
Dcov ozf Vkit

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.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

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 raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.