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

15. Structures, Classes & Protocols
Written by Caroline Begbie

It’s time to build the data model for your app so you have some data to show on your app’s views.

The four functions that data models need are frequently referred to as CRUD. That’s Create, Read, Update, Delete. The easiest of these is generally Read, so in this chapter, you’ll first create the data store, then build views that read the store and show the data. You’ll then learn how to Update the data and store it. That will leave Create and Delete. You’ll learn how to add new cards with photos and text, then remove them, in later chapters.

Starter Project Changes

There are a few differences between the challenge project from the last chapter and the starter project of this chapter:

  • Operators.swift: contains new operators.
  • Preview Assets.xcassets: contains three cute hedgehogs from https://pexels.com.
  • PreviewData.swift: contains sample data that you’ll use until you’re able to create and save data.
  • TextExtensions.swift: contains a new view modifier to scale text.

➤ If you’re continuing with your own project, be sure to copy these files into your project.

Data Structure

Take another look at the back of the napkin sketch:

Back of the napkin sketch
Back of the napkin sketch

Even with this rough sketch, you can get an idea of how to shape your data.

You’ll need a top level data store that will hold an array of all the cards. Each card will have a list of elements, and these elements could be an image or text.

Data structure
Data structure

You don’t want to constrain yourself to image or text though, as you might add new features to your app in the future. Any data model you create now should be extensible, meaning as flexible as possible, to allow future capabilities.

Value and Reference Types

Skills you’ll learn in this section: differences between value and reference types

Before creating the data model, you’ll need to decide what types to use to store your data. Should you use structures or classes?

A Swift data type is either a value type or a reference type. Value types, like structures and enumerations, contain data, while reference types, like classes, contain a reference to data.

Value and reference types
Value and reference types

At runtime, your app instantiates properties and assigns them to separate areas of memory, called the stack and the heap. Value types go on the stack, which the CPU manages and optimizes, so it’s fast and efficient. You can instantiate structures, enumerations and tuples without counting the cost. The heap, however, is much more dynamic and allows an app to allocate and deallocate areas of memory, while maintaining reference counts. This makes allocating reference types less efficient. When you instantiate a class, that piece of data should stick around for a while.

Swift Dive: Structure vs Class

Skills you’ll learn in this section: how to use structures and classes

When initializing classes and structures in code, they look very similar. For example:

let iAmAStruct = AStruct()
let iAmAClass = AClass()

The important difference here is that iAmAStruct contains immutable data, whereas iAmAClass contains an immutable reference to the data. The data itself is still mutable and you can change it.

iAmAStruct.number = 10  // compile error
iAmAClass.number  = 10  // no error - `number` will update to 10

When you assign value types, such as a CGPoint, you make a copy. For example:

let pointA = CGPoint(x: 10, y: 20)
var pointB = pointA    // make a copy
pointB.x = 20          // pointA.x is still 10

pointA and pointB are two different objects.

With a reference type, you access the same data. For example:

let iAmAClass = AClass()
let iAmAClassToo = iAmAClass
iAmAClassToo.number = 20     // this updates iAmAClass
print(iAmAClass.number)      // prints 20

Swift keeps a count of the number of references to the AClass object created in the heap. The reference count here would be two, and Swift won’t deallocate the object until its reference count is zero.

Changing the data like this can be a source of errors for unwitting developers. One of Swift’s principles is to prevent accidental errors, and if you favor value types over reference types, you’ll end up with fewer of those accidents. In this app, you’ll favor structures and enumerations over classes where possible.

Creating the Card Store

Skills you’ll learn in this section: when to use classes and structures

Returning to the complex matter of deciding how to store your data, you need to choose between a structure and a class.

In general, when you hold a simple piece of data, such as a Card or a CardElement, those are lightweight objects that you won’t need forever. Typically, you’d make those a structure. However, when you hold a data store that you’re going to use throughout your app, that’s a good candidate for a class. In addition, if your data has publisher properties, it must conform to ObservableObject, which requires you to use a class.

Now, you’ll get started creating your data model, beginning at the bottom of the data hierarchy with the element.

➤ In the Model group, create a new Swift file called CardElement.swift and replace the code with:

import SwiftUI

struct CardElement {
}

This is the file where you’ll describe the card elements. You’ll come back to this shortly to define the data you’ll hold.

➤ Create a new Swift file called Card.swift and replace the code with:

import SwiftUI

struct Card: Identifiable {
  let id = UUID()
  var backgroundColor: Color = .yellow
  var elements: [CardElement] = []
}

You set up Card to conform to Identifiable by defining the protocol’s required property id. Later, you can use this unique id to locate a card and to iterate through the cards.

You also hold a background color for the card and an array of elements for all the images and text that you’ll place on the card.

➤ Create a new Swift file named CardStore.swift and replace the code:

import SwiftUI

class CardStore: ObservableObject {
  @Published var cards: [Card] = []
}

CardStore is your main data store and your single source of truth. As such, you’ll make sure that it stays around for the duration of the app. It isn’t, therefore, a lightweight object, and you choose to make it a class.

There is a second reason for it to be a class. The protocol ObservableObject requires any type that conforms to it to be a class.

ObservableObject is part of the Combine framework. A class that conforms to ObservableObject can have published properties in it. When any changes happen to these properties, any view that uses them will automatically refresh. So when any card in the published array changes, views will react.

You’ve now set up a data model that SwiftUI can observe and write to. There is a difficulty with card elements, however. These can be either an image or text.

Class Inheritance

Skills you’ll learn in this section: class inheritance; composition vs inheritance

You might have come across object oriented programming (OOP) in Swift or other languages. This is where you have a base object, and other classes derive — or inherit — from this base object. Swift classes allow inheritance. Swift structures do not.

You might set up your card element data in this way:

class CardElement {
  var transform: Transform
}

class ImageElement: CardElement {
  var image: Image?
}

class TextElement: CardElement {
  var text: String?
}

Here you have a base class CardElement with two sub-classes inheriting from CardElement. ImageElement and TextElement both inherit the transform property, but each type has its own separate relevant data.

As discussed earlier, however, lightweight objects such as card elements should be value types, not classes.

Composition vs Inheritance

With inheritance, you have tightly coupled objects. Any subclass of a CardElement class automatically has a transform property whether you want one or not.

You might possibly decide in a future release to require some elements to have a color. With inheritance, you could add color to the base class, but you’d then be holding redundant data for the elements that don’t use a color.

An alternative scenario is to use composition with protocols, where you add only relevant properties to an object. This means that you can hold your data in structures.

This diagram shows a CardElement protocol with ImageElement and TextElement structures. It also shows a possible future expansion if you want to include a new ColorElement. This would be much harder with inheritance.

Composition
Composition

Traditionally, inheritance is considered to be an “is a” relationship, while composition is a “has a” relationship. But, you should avoid tightly-coupled objects as much as you can, and composition gives you much more freedom in design.

Protocols

Skills you’ll learn in this section: create protocol; conform structures to protocol; protocol method

You’ve used several protocols so far — such as View and Identifiable — and, possibly, been slightly mystified as to what they actually are.

Protocols are like a contract. You create a protocol that defines requirements for a structure, a class or an enumeration. These requirements may include properties and whether they are read-only or read-write. A protocol might also define a list of methods that any type adopting the protocol must include.

Protocols can’t hold data; they are simply a blueprint or template. You create structures or classes to hold data and they, in turn, conform to protocols.

View is the protocol you’ve used most. It has a required property body. Every view that you’ve created has contained body and, if you don’t provide one, you get a compile error.

You’ve also used Identifiable. id is a required property, so each time you conform a type to Identifiable, you create an id property that is guaranteed to be unique.

In your app, every card element will have a transform, so you’ll change CardElement to be a protocol that requires any structure adopting it to have a transform property.

➤ Open CardElement.swift and replace the structure with:

protocol CardElement {
  var id: UUID { get }
  var transform: Transform { get set }
}

Here you create a blueprint of your CardElement structure. Every card element type will have an id and a transform. id is read-only, and transform is read-write.

➤ In the same file as CardElement, create the image element:

struct ImageElement: CardElement {
  let id = UUID()
  var transform = Transform()
  var image: Image
}

ImageElement conforms to CardElement with its required id and transform. It also holds an image.

➤ Create the text element after the image element:

struct TextElement: CardElement {
  let id = UUID()
  var transform = Transform()
  var text = ""
  var textColor = Color.black
  var textFont = "Gill Sans"
}

TextElement also conforms to CardElement and holds a string for text, the default text color and the default font.

With protocols, you future-proof the design. If you later want to add a new card element that is just a solid color, you can simply create a new structure ColorElement that conforms to CardElement.

Card holds an array of CardElements. Card doesn’t care what type of CardElement it holds in its elements array, so it’s easy to add new element types.

Creating a Default Protocol Method

A protocol blueprint might require the conforming type to implement a method. For example, this protocol requires all types that conform to it to implement find():

protocol Findable {
  func find()
}

Sometimes you want a default method that is the same across all conforming types. For example, in your app, a card will hold an array of card elements. Later, you’ll want to find the index for a particular card element.

The code for this would be:

let index = card.elements.firstIndex { $0.id == element.id }

This is quite hard to read and you have to remember the closure syntax. Instead, you can create a new method in CardElement to replace it.

➤ In CardElement.swift, under the protocol declaration, add a new method in an extension:

extension CardElement {
  func index(in array: [CardElement]) -> Int? {
    array.firstIndex { $0.id == id }
  }
}

This method takes in an array of CardElement and passes back the index of the element. If the element doesn’t exist, it passes back nil. The way you’ll use it is:

let index = element.index(in: card.elements)

This is a lot easier to read than the earlier code, and the complicated closure syntax is abstracted away in index(in:). Any type that conforms to CardElement can use this method.

Now that you have your views and data model implemented, you have reached the exciting point of showing the data in the views. Your app doesn’t allow you to add any data, so your starter project has some preview data to work with until you can add your own.

The Preview Data

Skills you’ll learn in this section: using preview data

➤ In the Preview Content group, take a look at PreviewData.swift and remove the comment tags /* */. This code was commented to remove compile errors while you built your data model.

There are five cards. The first card uses the array of four elements, which are a mixture of images and text. You’ll use this data to test new views. The card elements are positioned for portrait orientation on iPhone 14 Pro. As they are hard-coded, if you run the app in landscape mode or on a smaller device, some of the elements will be off the screen. Later, your card will take on a fixed size, and the elements will scale to fit in the available space.

➤ Open CardStore.swift and add an initializer to CardStore:

init(defaultData: Bool = false) {
  if defaultData {
    cards = initialCards
  }
}

When you first instantiate CardStore, the initializer will load the preview data when defaultData is true.

Later, when you can save and load cards from files, you’ll update this to use saved cards. For the moment, you’ll use the preview data.

You’ll need to instantiate CardStore, and the best place to do that is at the start of the app.

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

@StateObject var store = CardStore(defaultData: true)

You use @StateObject to ensure that the data store persists throughout the app.

➤ Add a modifier to CardsListView() so you can address the data store through the environment:

.environmentObject(store)

➤ Open CardsListView.swift and add the new environment object to CardsListView:

@EnvironmentObject var store: CardStore

Whenever you create an environment object property, you should make sure that the SwiftUI preview instantiates it. If you don’t do this, your preview will crash mysteriously with no error message.

➤ In previews, add a modifier to CardsListView:

.environmentObject(CardStore(defaultData: true))

Listing the Cards

Skills you’ll learn in this section: observing full screen cover property

➤ Still in CardsListView.swift, in list, change ForEach(0..<10) { _ in to:

ForEach(store.cards) { card in

Here you iterate through store.cards. Remember that ForEach in this format requires Card to be Identifiable.

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

let card: Card

You don’t need card to be mutable here, as you’ll only read from it to get the card’s background color for the thumbnail.

➤ Replace .foregroundColor(.random()) with:

.foregroundColor(card.backgroundColor)

Instead of a random color, you use the background color of the card for the thumbnail.

➤ Update the preview to use the first card in the provided preview data:

CardThumbnail(card: initialCards[0])

➤ Back in CardsListView, change CardThumbnail() to:

CardThumbnail(card: card)

You pass the current card to the thumbnail view.

➤ Preview the view and check that the scrolling card thumbnails use the background colors from the preview data:

The card thumbnails
The card thumbnails

Choosing a Card

When you tap a card, you set isPresented to true, which triggers the full screen modal for the single card. SingleCardView should now use the data for the selected card.

➤ Add a new property to CardsListView:

@State private var selectedCard: Card?

➤ Remove the property isPresented as you won’t need it any more.

➤ In .onTapGesture, replace isPresented = true with:

selectedCard = card

➤ Replace .fullScreenCover(isPresented: $isPresented) { with:

.fullScreenCover(item: $selectedCard) { card in

When selectedCard is not nil, the system will show SingleCardView in the full screen modal. When you tap the Done button and dismiss the modal, the system will reset selectedCard to nil.

Displaying the Single Card

You can now pass the selected card to the single card view.

➤ Still in CardsListView.swift, change SingleCardView() to:

SingleCardView(card: card)

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

let card: Card

➤ Update SingleCardView_Previews to:

SingleCardView(card: initialCards[0])

➤ Change content to:

var content: some View {
  card.backgroundColor
}

With the background color, you’ll be able to tell whether the app is displaying the correct selected card.

➤ Return to CardsListView.swift and Live Preview the app.

Selected card passed
Selected card passed

As you select each card, the correct color for the card shows on the single card view.

Mutability

Skills you’ll learn in this section: mutability

But wait! In SingleCardView, is card mutable? You’ll want to add images and text to the card later on, so it does need to be mutable.

The answer, of course, is that you passed card with a let and therefore it is read-only. To get a mutable card, you need to access the selected card in the data store’s cards array by index.

➤ Open CardStore.swift and create a new method:

func index(for card: Card) -> Int? {
  cards.firstIndex { $0.id == card.id }
}

This finds the first card in the array that matches the selected card’s id and returns the array index, if there is one.

➤ Open CardsListView.swift and, in body, replace SingleCardView(card:) with:

if let index = store.index(for: card) {
  SingleCardView(card: $store.cards[index])
} else {
  fatalError("Unable to locate selected card")
}

You work out the array index of the selected card in the data store’s cards array and pass it as a binding to SingleCardView. This should never fail but, just in case, you add a fatal error message.

➤ Open SingleCardView.swift and change let card: Card to:

@Binding var card: Card

The selected card is now mutable in this view.

➤ Change SingleCardView_Previews to:

SingleCardView(card: .constant(initialCards[0]))

You update the preview with a binding to the preview data.

➤ Preview CardsListView.swift and the result is the same as previously, but you’re now all set up to update the card with new elements.

Adding Elements to the Card

➤ In the Single Card Views group, create a new SwiftUI View file named CardDetailView.swift.

This view will contain only the card and its elements.

➤ Replace the code in CardDetailView.swift with:

import SwiftUI

struct CardDetailView: View {
  // 1
  @EnvironmentObject var store: CardStore
  @Binding var card: Card

  var body: some View {
    // 2
    ZStack {
      card.backgroundColor
    }
  }
}

struct CardDetailView_Previews: PreviewProvider {
  static var previews: some View {
    // 3
    CardDetailView(card: .constant(initialCards[0]))
      .environmentObject(CardStore(defaultData: true))
  }
}

Here you:

  1. Add a reference to the CardStore environment object and a Card binding.
  2. Use the card’s background color and put it inside a ZStack.
  3. Pass a constant binding to CardDetailView and an instance of CardStore using environmentObject(_:).

➤ Preview the view to see the background color from the first card in your preview data.

Card background from the preview data
Card background from the preview data

Creating the Card Element View

➤ In the Single Card Views group, create a new SwiftUI View file named CardElementView.swift. This view will show a single card element.

➤ Under the existing CardElementView, create a new view for an image element:

struct ImageElementView: View {
  let element: ImageElement

  var body: some View {
    element.image
      .resizable()
      .aspectRatio(contentMode: .fit)
  }
}

This simply takes in an ImageElement and uses the stored image as the view.

➤ Create a new view for text under ImageElementView:

struct TextElementView: View {
  let element: TextElement

  var body: some View {
    if !element.text.isEmpty {
      Text(element.text)
        .font(.custom(element.textFont, size: 200))
        .foregroundColor(element.textColor)
        .scalableText()
    }
  }
}

In the same way, this view takes in a TextElement and uses the stored text, color and font.

Swift Tip: To find out what fonts are on your device, first list the font families in UIFont.familyNames. A font family might be “Avenir” or “Gill Sans”. For each family, you can find the font names using UIFont.fontNames(forFamilyName:). These are the weights available in the family, such as “Avenir-Heavy” or “GillSans-SemiBold”.

scalableText(font:) is in your starter project in TextExtensions.swift and is the same code as you used for scaling text in the previous chapter, refactored into a method for easy reuse.

Depending on whether the card element is text or image, you’ll use one of these two views. Note the ! in front of !element.text.isEmpty. isEmpty will be true if text contains "", and ! reverses the conditional result. This way you don’t create a view for any blank text.

With these two views as examples, when “future you” adds a new type of element, it will be easy to add a new view specifically for that element.

➤ Change CardElementView to this code:

struct CardElementView: View {
  let element: CardElement

  var body: some View {
    if let element = element as? ImageElement {
      ImageElementView(element: element)
    }
    if let element = element as? TextElement {
      TextElementView(element: element)
    }
  }
}

When presented with a CardElement, you can find out whether it’s an image or text depending on its type.

➤ Change the preview to:

CardElementView(element: initialElements[0])

Here you show the first element which contains a hedgehog image. To test the text view, change the parameter to initialElements[3].

➤ Preview the view.

The card element view
The card element view

Showing the Card Elements

➤ Open CardDetailView.swift and, in body, add this after card.backgroundColor:

ForEach($card.elements, id: \.id) { $element in
  CardElementView(element: element)
    .resizableView()
    .frame(
      width: element.transform.size.width,
      height: element.transform.size.height)
}

Always be aware of whether your data is mutable. Later, you’ll update the element’s Transform within this ForEach loop. Generally, when you iterate through an array in a loop, the individual item is immutable. However, this variant of ForEach allows binding syntax by adding the $ in front of the array and the individual item.

➤ Live Preview the view and see the elements all in the center of the view:

The card elements
The card elements

➤ Open SingleCardView.swift and, in body, replace content with:

CardDetailView(card: $card)

➤ Remove the content property, as you no longer need it.

➤ Live Preview the app, or run in Simulator. Load the first card and move around the elements.

Move the card elements
Move the card elements

Notice that when you return to the card list and reload the card, the element positions all reset to the center.

You’ve now completed the R in CRUD. Your views read and display all the data from the store. You’ll now move on to U — updating the model when you resize, move and rotate card elements.

Understanding @State and @Binding Property Wrappers

Skills you’ll learn in this section: @State; binding; generics

At the moment, you’re using a state property transform inside ResizableView. You’ll replace this with a binding to the current element’s Transform property.

As you’ve learned already, inside a View, all properties are immutable unless they are created with a special property wrapper. A state property is the owner of a piece of data that is a source of truth. A binding connects a source of truth with a view that changes the data.

Your source of truth for all data is CardStore. When you select a particular card, you pass a binding to the card to SingleCardView.

➤ Open SingleCardView.swift and locate where you call CardDetailView. Option-click the card parameter to see the declaration.

CardDetailView declarations
CardDetailView declarations

CardDetailView expects an environment object and a binding. The type of these are in angle brackets. store is an environment object of type CardStore, and card is a binding of type Card.

Swift Dive: A Very Brief Introduction to Generics

Swift is a strongly typed language, which means that Swift has to understand the exact type of everything you declare. Binding has a generic type parameter <Value>. A generic type doesn’t actually exist except as a placeholder. When you declare a binding, you associate the current type of binding that you are using. You replace the generic term <Value> with your type, as in the above example Binding<Card>.

Another common place where you might find this language construct is an Array. You defined an array in CardStore like this:

var cards: [Card] = []

That is actually syntactic sugar for:

var cards: Array<Card> = []

Array is a structure defined as Array<Element>. When you declare an array, you specify what the generic type Element actually is. In this example, Element is a Card. If you try and put anything other than a Card into that array, you’ll get a compile error.

Binding Transform Data

Now that you’ve seen how generics work when composing a binding declaration, you’ll be able to pass the element’s transform to resizableView(), and ResizableView will connect to this binding instead of updating its own internal state transform property.

➤ Open ResizableView.swift and replace @State private var transform = Transform() with:

@Binding var transform: Transform

transform is now connected to the transform property in the parent view.

➤ In the View extension, replace resizableView() with:

func resizableView(transform: Binding<Transform>) -> some View {
  modifier(ResizableView(transform: transform))
}

The method receives a binding that is of Transform type and passes it on to the view modifier.

➤ In ResizableView_Previews, change .resizableView() to:

.resizableView(transform: .constant(Transform()))

This passes in a new transform instance as a binding.

➤ Open CardDetailView.swift and, in body, replace .resizableView() with:

.resizableView(transform: $element.transform)

ResizableView will now operate on the mutable element’s transform property, and the preview places the card elements in the correct position.

Elements in their correct position
Elements in their correct position

Updating the Previews

Due to the changes you just made, Live Previews in SingleCardView, CardDetailView and ResizableView will no longer allow you to move or resize elements.

➤ In CardDetailView.swift, locate the preview.

The parameter to CardDetailView is .constant(initialCards[0]). As its name implies, the binding value sent to CardDetailView is constant and, therefore, doesn’t allow updates. You can get around this by creating a separate View structure.

➤ Replace CardDetailView_Previews with:

struct CardDetailView_Previews: PreviewProvider {
  struct CardDetailPreview: View {
    @EnvironmentObject var store: CardStore

    var body: some View {
      CardDetailView(card: $store.cards[0])
    }
  }

  static var previews: some View {
    CardDetailPreview()
      .environmentObject(CardStore(defaultData: true))
  }
}

In the preview, you’re more closely reproducing the parent code that calls CardDetailView, and you’re able to move and resize the elements. Any changes you make in position or size will save to the data store.

Note: If you want your Live Previews to work in SingleCardView.swift and ResizableView,swift, you can reproduce this technique in those files.

There is still one problem. When you first reposition any element except for the central one, it jumps to a different position.

➤ Open ResizableView.swift and look at dragGesture.

dragGesture relies on previousOffset being set to an existing offset. On first loading the view, you should copy transform.offset to previousOffset.

➤ In body, add a new modifier to content:

.onAppear {
  previousOffset = transform.offset
}

When the view first appears, you initialize previousOffset. This will happen only once.

➤ Build and run and choose the first card. In the detail view, the initial position jump has gone away, and you can now move, rotate and resize the card elements.

Updating the card
Updating the card

You have now achieved both Read and Update in the CRUD functions. In the next chapter, you’ll learn how to Create new image elements, and later, you’ll tackle Deletion.

Key Points

  • Use value types in your app almost exclusively. However, use a reference type for persistent stored data. Your stored data should be in one central place in your app.
  • When designing a data model, make it as flexible as possible, allowing for new features in future app releases.
  • Use protocols to describe data behavior. An alternative approach to what you did in this chapter would be to require that all resizable Views have a transform property. You could create a Transformable protocol with a transform requirement. Any resizable view must conform to this protocol.
  • You had a brief introduction to generics in this chapter. Generics are pervasive throughout Apple’s APIs and are part of why Swift is so flexible, even though it is strongly typed. Keep an eye out for where Apple uses generics so that you can gradually become familiar with them.
  • When designing an app, consider how you’ll implement CRUD. In this chapter, you implemented Read and Update. Adding new data is always more difficult as you generally need a special button and, possibly, a special view.

Where to Go From Here?

You covered a lot of Swift theory in this chapter. Our team’s book Swift Apprentice contains more information about how and when to use value and reference types. It also covers generics and protocol oriented programming.

If you’re still confused about when to use class inheritance and OOP, watch this classic WWDC video where the protagonist, Crusty, firmly declares “I don’t do object-oriented”.

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.