Chapters

Hide chapters

Real-World iOS by Tutorials

First Edition · iOS 15 · Swift 5.5 · Xcode 13

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

4. Defining the Data Layer - Databases
Written by Josh Steele

In the last chapter, you learned how to fetch data from the Petfinder API and convert that data into objects in your model. To this point, the model objects, represented as structs in the code, exist in memory only. Unfortunately, when the user closes the app, any data the app has in memory gets released.

In this chapter, you’ll learn about data persistence in iOS apps. In the broadest sense, persistence involves storing data on the user’s device to prevent the app from having to download the data again in the future, at least in its entirety.

Some of the native persistence frameworks in iOS are User Defaults, Core Data and CloudKit, and of course, writing directly to the file system. You can also use third-party frameworks, such as those in Google Firebase.

Your focus for this chapter is Core Data, Apple’s framework for working with databases. It’s time to persist information and understand why persistence is a good standard practice in real-world iOS development.

Note: There are other iOS libraries that support persistence via databases. This book stays with the native framework, Core Data, to take advantage of tight integration with other frameworks such as SwiftUI. You’ll see this later in the chapter, and later in the book.

The benefits of persistence

Persistence is a must-have feature in most modern-day mobile apps. Here are some reasons why you should consider persistence early on in your design process.

Saving resources

One of the guiding principles in developing mobile apps is to be considerate of the device resources your app uses. For example, a call to the network touches on the following resources:

  • Power: The device’s antenna requires power to communicate with the network.
  • Data plan: Communication with the network may also use the user’s data plan if they are on a cellular network.

Repeated calls to the network can spend these resources at a higher rate than normal, especially if the app downloads the same data over and over again. If the app uses persistence, it can focus on only retrieving new data from the network, saving on valuable device resources.

Your app is always available, immediately

Once data is on a device, your app no longer needs network access to get data. Even though it may be stale, the onboard data can populate your app’s views until the networking layer can fetch new data. Without persistence, your views would remain unpopulated, which would make for a poor user experience.

Your app can maintain user state

Persistence isn’t only for data retrieved from the network. It can also store lightweight items such as user preferences inside User Defaults. The ability to store the user state of the app lets the user continue where they left off the last time they used the app.

Your app can live on many devices

If your app syncs data to iCloud via CloudKit, the user can also continue their session on their other devices. Recent changes to the CloudKit framework make cloud sync as easy as changing a few lines of code.

Now with a better understanding of why persistence is valuable in your app, it’s time to add it to the project!

Note: The starter project for this chapter will not compile! You’ll fix this as you add some code later on.

Defining a database schema

Earlier in the book, you set up structs to represent the various domain objects from the Petfinder API. Now you need to map those domain objects into something that Core Data can understand. You do this with a database schema.

Updating the database schema

In the starter project for this chapter, open Core/data/coreData. You’ll find a mostly completed PetSave.xcdatamodeld that contains the schema for the project:

PetSave database schema
PetSave database schema

You’re missing one entity in the schema - the pet’s breed! Add BreedEntity and the following attributes:

Newly added breed entity
Newly added breed entity

The attributes are:

  1. id: Although entities have a built-in unique id, the entities here have their own id to help better translate between struct and classes. You’ll read more on using this later.
  2. mixed: A Boolean which states if the pet is a mixed breed.
  3. primary and secondary: Strings that describe the pet’s primary and secondary breeds.
  4. unknown: A Boolean that describes if the breed is unknown. This attribute is useful if that data isn’t retrieved from the API.

Core Data synthesizes classes behind the scenes for each of the entities in the schema by default. You can add extra functionality to the classes via extensions, which you’ll learn about later in this chapter.

Special case: enums

Many of the types in the project are enums, which don’t map to any of the types available in the schema. But enums in Swift can have associated values which means built-in Swift types can represent the enums in the database schema.

One of AnimalEntity‘s properties is a String that describes the pet’s age called ageValue. You must store it as a string since you can’t store enums in the schema. But to take advantage of Swift’s type safety features, you should use the enum when dealing with that value for the animal’s age elsewhere in the code.

Open Core/data/coreData/extensions/. Then open Animal+CoreData.swift. Find the extension to the AnimalEntity, and declare a computed property for the age:

extension AnimalEntity {
  var age: Age {
    //1
    get {
      guard let ageValue = ageValue,
            let age = Age(rawValue: ageValue) else {
        return Age.unknown
      }
      return age
    }
    //2
    set {
      self.ageValue = newValue.rawValue
    }
  }
  //.....
}

In this computed property:

  1. get uses a pair of guard let statements to do some checks. First, it makes sure that a current value for the age exists. Second, it ensures that it’s convertible to an Age enum with the Age(rawValue:) initializer. If the guard fails, it returns Age.unknown. Otherwise, it returns the Age enum.
  2. set only deals with the string you store in the entity, so it sets ageValue to the newValue.rawValue property.

Defining relationships

So far, the entities you’ve defined have been basic building blocks for the bigger Animal entity you need to build. The starter project includes many of the relationships that you’ll need. Here’s the current state of the AnimalEntity in the schema:

Current state of animal entity
Current state of animal entity

AnimalEntity connects to other entities in the schema by way of relationships. As you can see at the bottom of the image above, the entity stores relationships below the attributes. Some of the relationships are One-to-One, denoted by the red O, and others are One-to-Many, denoted by the red M.

To finish setting up AnimalEntity, first select the newly created BreedEntity and add a One-to-Many animal relationship.

One-to-Many relationship in BreedEntity
One-to-Many relationship in BreedEntity

Note: At first, the Inverse part of the relationship will be set to No Inverse. This will look like the screenshot after the step below, once you set the other part of the relationship.

Then, select AnimalEntity and add the breeds relationship, which is a One-to-One relationship:

One-to-One relationship in AnimalEntity
One-to-One relationship in AnimalEntity

Note: Even though you call the relationship breeds, it’s only a One-to-One relationship. The pet may be a mix of many breeds, and the entity captures that in the breed’s primary and secondary properties.

OK, that completes your schema. So, how do you use it?

The Persistence class

Xcode projects set up to use Core Data include Persistence.swift. This file sets up the Core Data Stack and in-memory and on-disk stores, which act as scratch pads for your work until you persist them to the database. As part of this setup, it has access to the entities you declared in the schema earlier.

In-Memory store

Persistence.swift sets up two distinct stores for your app. The first is for in-memory objects. Near the top of the file you’ll find:

static var preview: PersistenceController = {
  let result = PersistenceController(inMemory: true)
  let viewContext = result.container.viewContext
  for _ in 0..<10 {
    let newItem = Item(context: viewContext)
    newItem.timestamp = Date()
  }
  //......

The code currently uses the default Item entity that comes with the Core Data-based project template. Later, you’ll change this to use AnimalEntity, which will provide a set of data to your view previews, so you don’t have to rely on an on-disk database.

On-Disk store

For the on-disk store, there’s a much simpler declaration near the top of Persistence.swift:

struct PersistenceController {
  static let shared = PersistenceController()
  //...

This code provides access to a singleton of PersistenceController, which lets you access the database from anywhere in your project.

To aid you in saving the context, add a static method to Persistence.swift:

static func save() {
  // 1
  let context =
    PersistenceController.shared.container.viewContext
  // 2
  guard context.hasChanges else { return }

  // 3
  do {
    try context.save()
  } catch {
      fatalError("""
        \(#file), \
        \(#function), \
        \(error.localizedDescription)
      """)
  }
}

This code does a few simple things:

  1. You get a reference to the Core Data Context. In this case, using the on-disk store.
  2. You don’t need to save unless there are pending changes, so return if hasChanges is false.
  3. The call to context.save() can throw, so wrap it in a do/catch. Any errors get their information sent to a fatalError call.

The latter half of Persistence.swift initializes the NSPersistentContainer and attempts to load the persistent stores.

There’s a place to handle errors encountered when loading the persistent stores. Typically, they only happen during development, but it may be worth alerting the user if the app encounters a disk space error.

Note: For much more on the Core Data stack, be sure to check out Core Data by Tutorials

Swift structs and Core Data classes

As you learned when working with entities in PetSave.xcdatamodeld earlier, Core Data works with classes. The PetSave app, up to this point, uses structs for the data model objects. Here’s how you can add some code to convert from the structs to Core Data classes and back again.

Implementing a CoreDataPersistable protocol

If you’ve had any experience with Core Data in the past and have had to worry about converting back and forth between structs and classes, you know there’s a lot of boilerplate code. Consider an example Person struct, which has a corresponding PersonEntity Core Data entity.

Here’s the code that you need to handle converting back and forth between struct and class:

struct Person {
  var age: Int
  var gender: Gender
  var height: Double
  var weight: Double
  //....
}

extension Person {
  init(managedObject: PersonEntity) {
    self.age = managedObject.age
    self.gender = Gender(rawValue: managedObject.gender)
    self.height = managedObject.height
    self.weight = managedObject.weight
    //...
  }
}

extension PersonEntity {
  init (valueObject: Person) {
    self.setValue(valueObject.age, forKey: "age")
    self.setValue(
      valueObject.gender.rawValue, forKey: "gender")
    self.setValue(valueObject.height, forKey: "height")
    self.setValue(valueObject.weight, forKey: "weight")
    //...
  }
}

This amount of code seems manageable for one class, but what if you have ten or twenty, or more, entities in your schema or many more properties? This creates a lot of code for each struct. On the surface, that code does the same thing: converting between structs and classes. You can reduce this boilerplate code by defining some protocols and default implementations.

First, add a new file in Core/data/coreData/extensions called CoreDataPersistable.swift. Here, add import CoreData and the following protocol, UUIDIdentifiable, that adopts Identifiable:

import CoreData

protocol UUIDIdentifiable: Identifiable {
  var id: Int? { get set }
}

This code ensures that each of the data model objects is identifiable by an Integer id.

Next, add a CoreDataPersistable protocol that adopts UUIDIdentifiable. Add the following initializers and methods:

protocol CoreDataPersistable: UUIDIdentifiable {
  //1
  associatedtype ManagedType

  //2
  init()

  //3
  init(managedObject: ManagedType?)

  //4
  var keyMap: [PartialKeyPath<Self>: String] { get }

  //5
  mutating func toManagedObject(
  context: NSManagedObjectContext) -> ManagedType

  //6
  func save(context: NSManagedObjectContext) throws
}

Here’s a breakdown of this protocol:

  1. This protocol uses generics and has an associated type. Associated types are placeholders for the concrete types you’ll pass in later when you adopt this protocol, which will let you bind a value type, struct, with a class type, ManagedType, at compile time.
  2. This initializer sets up the object’s basic state.
  3. This initializer takes in the ManagedType object as a parameter. The initializer’s body will handle the conversion from class to struct.
  4. To set values from the managed object to the struct, you need to map key paths in the struct to keys in the managed object. This array stores that mapping.
  5. toManagedObject(context:) saves the struct-based object to the Core Data store.
  6. save(context:) saves the view context to disk, persisting the data.

Using KeyPaths to make initializers

With the protocol defined, it’s time to add some default method implementations. By doing this inside a protocol extension, you let the actual type extensions be as small as possible. Under the protocol definition, add the following protocol extension:

//1
extension CoreDataPersistable
  where ManagedType: NSManagedObject {
  //2
  init(managedObject: ManagedType?) {
    self.init()
    //3
    guard let managedObject = managedObject else { return }

    //4
    for attribute in managedObject.entity.attributesByName {  	        
      if let keyP = keyMap.first(
            where: { $0.value == attribute.key })?.key {
          let value =
	    managedObject.value(forKey: attribute.key)
          storeValue(value, toKeyPath: keyP)
      }
    }
  }
}

For this method:

  1. Only types where ManagedType inherits from NSManagedObject can use this extension.
  2. The initializer takes in an optional ManagedType and calls the class’s default initializer.
  3. A guard statement checks to confirm the passed in managedObject isn’t nil.
  4. For each attribute of the managedObject, the struct stores each KeyPath-Value pair via the storeValue(_: toKeyPath:). This only gets attributes, not relationships.

Now add this after the previous code:

private mutating func storeValue(_ value: Any?,
  toKeyPath partial: AnyKeyPath) {
  switch partial {
  case let keyPath as WritableKeyPath<Self, URL?>:
    self[keyPath: keyPath] = value as? URL
  case let keyPath as WritableKeyPath<Self, Int?>:
    self[keyPath: keyPath] = value as? Int
  case let keyPath as WritableKeyPath<Self, String?>:
    self[keyPath: keyPath] = value as? String
  case let keyPath as WritableKeyPath<Self, Bool?>:
    self[keyPath: keyPath] = value as? Bool
  default:
    return
  }
}

This method takes in a value and a KeyPath, specifically, an AnyKeyPath. You then use a switch to check for the real form of the AnyKeyPath. In this case, the KeyPath is some flavor of WritableKeyPath. WritableKeyPath lets you store the value in the struct. Note here that you have to specify each basic type that you could potentially handle. For example, there’s no handling of Double values here.

Note: Curious why you have to jump through all these hoops? Structs in Swift don’t have the same methods available that classes do when accessing their properties. It’s one of the downsides of using structs instead of classes. Hopefully, Apple will provide better APIs in future versions of Swift.

Using the Mirror API to store values

Now you have to convert the struct objects to Core Data managed objects. Add the following implementation for toManagedObject(context:):

//1
mutating func toManagedObject(context: NSManagedObjectContext =
  PersistenceController.shared.container.viewContext
) -> ManagedType {
  let persistedValue: ManagedType
  //2
  if let id = self.id {
    let fetchRequest = ManagedType.fetchRequest()
    //3
    fetchRequest.predicate = NSPredicate(
      format: "id = %@", id as CVarArg)
    if let results = try? context.fetch(fetchRequest),
       let firstResult = results.first as? ManagedType {
        persistedValue = firstResult
    } else {
      persistedValue = ManagedType.init(context: context)
      self.id = persistedValue.value(forKey: "id") as? Int
    }
  } else {
    //4
    persistedValue = ManagedType.init(context: context)
    self.id = persistedValue.value(forKey: "id") as? Int
  }

  return setValuesFromMirror(persistedValue: persistedValue)
}

In this method:

  1. toManagedObject(context:) is mutating because the id gets saved back in the struct when creating the managed object. This lets you check for existing entries in the database.
  2. This if block checks to see if the struct has a non-nil id value. If so, the code within the if block attempts to fetch that entry from the database. If successful, persistedValue is set to that object. Otherwise, the initializer makes a new object and sets it to persistedValue.
  3. This is where you set the predicate for the fetch request. This uses a string with substitution variables and a variadic list of values that replace those arguments. Here, the id is cast to a CVarArg and replaces the %@ in the string.
  4. If the struct’s id is nil, the initializer makes a new object and sets the struct’s id to the managed object’s id.

Below the previous code, add code using Mirror to help assign values to the managed object:

private func setValuesFromMirror(persistedValue: ManagedType) -> ManagedType {
  //1
  let mirror = Mirror(reflecting: self)
  //2
  for case let (label?, value) in mirror.children {
    //3
    let value2 = Mirror(reflecting: value)
    //4
    if value2.displayStyle != .optional || !value2.children.isEmpty {
      //5
      persistedValue.setValue(value, forKey: label)
    }
  }

  return persistedValue
}

The Mirror API performs some introspection on the struct. The goal here is to map the values at the struct’s keyPaths to those in the managed object. Unfortunately, there isn’t a straightforward way to get a hold of the values at the key paths, so one has to resort to using Mirror to look inside.

Here’s what this code does:

  1. Create a mirror of the current struct, self.
  2. Loop over each of the (label, value) pairings in the mirror’s children property.
  3. Make a mirror object for the current value in the loop.
  4. Check to make sure the child value isn’t optional, and ensure that the child value’s children collection isn’t empty.
  5. If you make it this far, set the (label, value) pair on the managed object via its setValue(_:, forKey:).

Finally, add:

func save(context: NSManagedObjectContext =
  PersistenceController.shared.container.viewContext) throws {
    try context.save()
}

This method saves the managed object context to disk. You implement it here in CoreDataPersistable, so you don’t have to duplicate it in every structure that extends CoreDataPersistable.

That’s a lot of code to transform back and forth between structs and Core Data classes. Was it worth it? Time to make a concrete implementation and find out.

Making a concrete implementation

Open Core/data/coreData/extensions. You’ll find many implementations of this protocol already in place. Create a new file, Breed+CoreData.swift, and add:

import CoreData

//1
extension Breed: CoreDataPersistable {
  //2
  var keyMap: [PartialKeyPath<Breed>: String] {
    [
      \.primary: "primary",
      \.secondary: "secondary",
      \.mixed: "mixed",
      \.unknown: "unknown",
      \.id: "id"
    ]
  }

  //3
  typealias ManagedType = BreedEntity
}

Here’s a breakdown of this default implementation:

  1. This is an extension of the Breed struct and adopts CoreDataPersistable.
  2. This is the key map connecting those keyPaths in Breed with the keys from the managed object.
  3. The managed type for Breed is BreedEntity.

That’s it! With this simple extension on the data model types, you can now convert back and forth between struct and Core Data class. Next, you’ll look at how to use this functionality to add data to the database.

Storing data

Storing, deleting and fetching data are three common interactions with the Core Data database. Here’s how easy it is to store data and test your methods.

Saving entities

You’ve seen that toManagedObject(context:) gives you a flexible way to save the struct-based data model objects as Core Data entities. With this functionality in place, you can now start to convert the structs from the data model into Core Data objects.

In Persistence.swift, replace the contents of the for loop near the top with:

for i in 0..<10 {
  var animal = Animal.mock[i]
  animal.toManagedObject(context: viewContext)
}

This code initializes entries into the in-memory store. It grabs the ith entry from the mock Animal array and uses toManagedObject(context:) to persist it to Core Data which will come in handy when previewing views later.

Converting model objects from the network

The project so far only has one place that uses the data from the network API. Open AnimalsNearYouView.swift and replace fetchAnimals with:

func fetchAnimals() async {
  do {
    // 1
    let animalsContainer: AnimalsContainer = try await
    requestManager.perform(
      AnimalsRequest.getAnimalsWith(
        page: 1,
        latitude: nil,
        longitude: nil
      )
    )

    for var animal in animalsContainer.animals {
      // 2
      animal.toManagedObject()
    }

    await stopLoading()
  } catch {
    print("Error fetching animals...\(error)")
  }
}

Here’s what’s happening:

  1. perform(_:) connects to the Petfinder API and gets the animals in a structure.
  2. Iterate over each animal and call toManagedObject(context:) to convert it from the structure to a Core Data object.

Since you’re transitioning to using AnimalEntity instead of Animal, update the type of the animals property at the top of the struct:

@State var animals: [AnimalEntity] = []

Then update the previews struct to match this new property type:

struct AnimalsNearYouView_Previews: PreviewProvider {
  static var previews: some View {
    if let animals = CoreDataHelper.getTestAnimalEntities() {
      AnimalsNearYouView(animals: animals, isLoading: false)
    }
  }
}

This code uses a helper method in CoreDataHelper.swift to get an array of entities to test with from the in-memory database.

AnimalRow.swift should also use AnimalEntity, so change the type of the animal property accordingly:

let animal: AnimalEntity

Since the entity’s name property may be nil, update the Text view that displays the animal’s name:

Text(animal.name ?? "No Name Available")

Finally, update the previews struct to use a test AnimalEntity using a helper method from CoreDataHelper:

struct AnimalRow_Previews: PreviewProvider {
  static var previews: some View {
    if let animal = CoreDataHelper.getTestAnimalEntity() {
      AnimalRow(animal: animal)
    }
  }
}

Preview the AnimalRow in the preview canvas. You’ll see it’s identical to the view from the last chapter, possibly with a different name, but now populated with an AnimalEntity:

A single animal row. It's OK if yours is populated with a different name.
A single animal row. It's OK if yours is populated with a different name.

Testing storing data

One way to test that structs are getting converted to entities correctly is to write unit tests.

Go to PetSaveTests/Tests/Core/data/coreData and open CoreDataTests.swift. Add a new test method called testToManagedObject():

func testToManagedObject() throws {
  //1
  let previewContext =
    PersistenceController.preview.container.viewContext

  //2
  let fetchRequest = AnimalEntity.fetchRequest()
  fetchRequest.fetchLimit = 1
  fetchRequest.sortDescriptors =
    [NSSortDescriptor(keyPath: \AnimalEntity.name,
    ascending: true)]
  guard let results = try? previewContext.fetch(fetchRequest),
    let first = results.first else { return }

  //3
  XCTAssert(first.name == "CHARLA", """
    Pet name did not match, was expecting Kiki, got
    \(String(describing: first.name))
  """)
  XCTAssert(first.type == "Dog", """
    Pet type did not match, was expecting Cat, got
    \(String(describing: first.type))
  """)
  XCTAssert(first.coat.rawValue == "Short", """
    Pet coat did not match, was expecting Short, got
    \(first.coat.rawValue)
  """)
}

Here’s what’s happening in this test code:

  1. This test method takes advantage of the in-memory store, which has a fixed set of pets already persisted.
  2. The fetchRequest on AnimalEntity generates a fetch request. fetchLimit limits the fetch to one result, and a guard checks for a valid result.
  3. If the result is valid, a series of XCTestAsserts test various fields of the result against the expected value from AnimalsMock.json.

Run the test. As expected, the testToManagedObject passes.

As expected, test toManagedObject passes.
As expected, test toManagedObject passes.

Previewing views is another way to test the toManagedObject(context:). In fact, you did that in the last section! Great job! You were testing and you didn’t even realize it!

Deleting data

Deleting data from the Core Data store doesn’t use the CoreDataPersistable protocol. CoreDataHelper.swift contains an extension on the Collection type you can use to delete a collection of NSManagedObjects.

extension Collection where Element == NSManagedObject, Index == Int {
  func delete(at indices: IndexSet,
      inViewContext viewContext: NSManagedObjectContext =
      CoreDataHelper.context) {
        indices.forEach { index in
          viewContext.delete(self[index])
        }

    do {
      try viewContext.save()
    } catch {
      fatalError("""
        \(#file), \
        \(#function), \
        \(error.localizedDescription)
      """)
    }
  }
}

This method removes the objects at the provided indices from the data store. It does so by calling viewContext.delete(_:) over each element. It then calls viewContext.save to push the changes to the database.

Testing deletion

To test object deletion, go back to CoreDataTests.swift and add:

func testDeleteManagedObject() throws {
  let previewContext =
    PersistenceController.preview.container.viewContext

  let fetchRequest = AnimalEntity.fetchRequest()
  guard let results = try? previewContext.fetch(fetchRequest),
    let first = results.first else { return }

  let expectedResult = results.count - 1
  previewContext.delete(first)

  guard let resultsAfterDeletion = try? previewContext.fetch(fetchRequest)
    else { return }

  XCTAssertEqual(expectedResult, resultsAfterDeletion.count, """
    The number of results was expected to be \(expectedResult) after deletion, was \(results.count)
  """)
}

This test again uses the previewContext but removes the first entry from the database, which causes the number of entries in the database to reduce by one. Run the test in Xcode. It passes! The deletion operation is working now.

As expected, the testDeleteManagedObject method passes.
As expected, the testDeleteManagedObject method passes.

Note: Throughout the book, tests will focus on the bigger features of the app, instead of attempting to attain a high level of test coverage. iOS Test-Driven Development by Tutorials is a great resource to learn more about testing your iOS apps.

Fetching data

Now with data stored in the Core Data database, you need to be able to fetch it to use it in your views. There are three ways to do this, the first of which is NSFetchRequest.

Fetching from Core Data with NSFetchRequest

NSFetchRequest can fetch data from a Core Data store. For example, CoreDataHelper.swift has this method:

// 1
static func getTestAnimalEntities() -> [AnimalEntity]? {
  // 2
  let fetchRequest = AnimalEntity.fetchRequest()
  // 3
  guard let results = try? previewContext.fetch(fetchRequest),
      !results.isEmpty else { return nil }
  return results
}

Here’s what’s going on:

  1. The method may return nil if no objects in the database match the request.
  2. Each entity has a fetchRequest that returns an NSFetchRequest for that entity. If necessary, you can use sortDescriptors and predicate properties to customize the returned results.
  3. A compound guard statement checks that a non-nil set of results comes back, and if so, it isn’t empty. If the guard fails, the method returns nil. Otherwise, the method returns the results.

NSFetchRequest is powerful, but Apple started to add some Core Data features to SwiftUI, starting with @FetchRequest.

Using @FetchRequest

iOS 14 introduced the @FetchRequest property wrapper, which gets entries from a Core Data store and provides them to a SwiftUI view. When the database changes, views with @FetchRequest properties update automatically. This behavior is like the view having an @ObservedObject property. Those properties respond to changes in the @Published items of the ObservableObject.

With @FetchRequest, you can also perform operations on the data before the property returns values to the view, including sorting with SortDescriptors and filtering data with NSPredicates.

You may be thinking to yourself, “Self, won’t this possibly break patterns like MVVM that I may use when making my features?” Well, you’d be right.

In the AnimalsNearYouView you updated earlier, replace the animals property with:

@FetchRequest(
  sortDescriptors: [
    NSSortDescriptor(
      keyPath: \AnimalEntity.timestamp, ascending: true)
  ],
  animation: .default
)
var animals: FetchedResults<AnimalEntity>

This code now binds the fetch request in the property wrapper with the animals property. Like @State and other state-related property wrappers, @FetchRequest will cause the body of the view to refresh if the underlying database data changes.

Notice how you added an NSSortDescriptor. It’ll order the results by timestamp in an ascending manner. It also has an animation property you can use to indicate how the result should be animated when displayed.

Don’t forget to also update the previews struct since you’re no longer passing in a collection of animals and are instead letting the database get them for you:

static var previews: some View {
  AnimalsNearYouView(isLoading: false)
    .environment(\.managedObjectContext,
      PersistenceController.preview.container.viewContext)
}

To make this work, you need to inject the view context into the SwiftUI Environment.

Open ContentView.swift. Add the following modifier to the AnimalsNearYouView and SearchView views:

.environment(\.managedObjectContext, managedObjectContext)

At the top of ContentView.swift, define the managedObjectContext property:

let managedObjectContext =
  PersistenceController.shared.container.viewContext

As you build out features later in the book, these views will fetch data with @FetchRequests.

So is @FetchRequest in your view better than NSFetchRequest in your view model?

Pros:

  1. UI updates: SwiftUI updates the UI for you behind the scenes.
  2. Code savings: You potentially save on code you may have written to keep the data structures up-to-date.

Cons:

  1. Testing: You lose the ability to do model-based testing on the code that fetches from the database.
  2. Data manipulation: You lose the ability to perform other methods on your data before the view can display it.

Which is better? It depends on how integrated you are with SwiftUI. Deep integration is the direction Apple is going since they introduced an improvement to @FetchRequest in iOS 15.

Using @SectionedFetchRequest

In iOS 15, Apple introduced a new twist on @FetchRequest: @SectionedFetchRequest. Open AnimalsNearYouView.swift and replace the @FetchRequest at the top with this @SectionedFetchRequest below:

@SectionedFetchRequest<String, AnimalEntity>(
  sectionIdentifier: \AnimalEntity.animalSpecies,
  sortDescriptors: [
    NSSortDescriptor(keyPath: \AnimalEntity.timestamp,
                   ascending: true)
    ],
  animation: .default
) private var sectionedAnimals:
    SectionedFetchResults<String, AnimalEntity>

Besides sortDescriptors and an animation parameter, which were possible with @FetchRequest, you now can specify a sectionIdentifier that uses a keyPath from the fetched type to group the fetched results by section. Views, such as Lists, use this sectioned data to help users organize the data they’re viewing.

Replace the existing ForEach in the List with:

ForEach(sectionedAnimals) { animals in
  Section(header: Text(animals.id)) {
    ForEach(animals) { animal in
      NavigationLink(destination: AnimalDetailsView()) {
        AnimalRow(animal: animal)
      }
    }
  }
}

This code iterates through each section, generates a header from the section’s id and builds an AnimalRow for each animal in the section. AnimalRow is now inside a NavigationLink that will push an AnimalDetailsView when the user taps over a row. You’ll work on this view in a later chapter.

Build and run the app.

New in iOS 15 - Sectioned Fetch Request
New in iOS 15 - Sectioned Fetch Request

Enabling CloudKit support

Many users have different devices - an iPhone, an iPad or even a Mac - that may have the ability to run your app. They’re also logged into those devices with their Apple ID, which lets them share data across those devices if the app supports it.

Luckily it’s easy to add that support, so you’ll make those changes in PetSave next.

Updating the persistent container

It’s really simple to add support for CloudKit, at least when syncing data with your app’s private cloud database. In Persistence.swift, change NSPersistentContainer to NSPersistentCloudKitContainer:

let container: NSPersistentCloudKitContainer

init(inMemory: Bool = false) {
  container = NSPersistentCloudKitContainer(name: "PetSave")
  if inMemory {
    container.persistentStoreDescriptions
      .first?.url = URL(fileURLWithPath: "/dev/null")
  }
  //...

That’s it! Your app’s Core Data database will sync with the app’s private cloud instance as long as:

  • iCloud capability: Your project has the iCloud capability enabled. You can find this in your project’s “Signing and Capabilities” tab.
  • Apple ID: Your user signs in with their Apple ID when using your app.

Challenge

You’ve been testing NSFetchRequest while testing saving and deleting. For a challenge, add a new test method for fetch. Here’s a list of general steps you’ll need to complete this challenge:

  1. Use the previewContext.
  2. Make a fetch request for AnimalEntity.
  3. Limit the number of results to one.
  4. Only accept results with the name “Ellie”.
  5. Assert that your results have the correct name.

Check out the project in the challenges folder for the solution.

Key points

  • Persistence is vital to many modern-day mobile apps.
  • Persistence lets your app have data when offline and helps maintain user state between sessions.
  • Swift structs and Core Data classes don’t mix, but techniques like default protocol implementations, generics and key paths can help go back and forth between the two.
  • In-memory stores are useful for testing, especially with previews, while your deployed app uses an on-disk store.
  • @FetchRequest and @SectionedFetchRequest are SwiftUI property wrappers that help keep your views up-to-date as the database changes underneath.

Where to go from here?

Congratulations, you learned a lot about using Core Data to implement persistence in your app! But you’ve only scratched the surface. There’s a lot more to discover about the concepts in this chapter.

Check out the tutorial on Core Data with SwiftUI that touches on properties like @FetchRequest, and several tutorials on CloudKit, where you can learn how to see your database in CloudKit Dashboard.

Finally, you can learn more about Core Data with the Core Data by Tutorials book!

By finishing this chapter, you’ve also finished the first section of the book! Give yourself a pat on the back. When you’re ready, head on over to the next chapter, where you’ll start putting some of the techniques from this section into practice.

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.