watchOS With SwiftUI by Tutorials!

Build awesome apps for Apple Watch with SwiftUI,
the declarative and modern way.

Home iOS & Swift Tutorials

Dynamic Core Data with SwiftUI Tutorial for iOS

Learn how to take advantage of all the new Core Data features introduced in iOS 15 to make your SwiftUI apps even more powerful.

4.3/5 4 Ratings

Version

  • Swift 5.5, iOS 15, Xcode 13

iOS 15 offers some powerful new features for SwiftUI when used with Core Data. You can now dynamically apply sorts and filters to lists and see the results update immediately. iOS 15 also brings some ease-of-use updates to SwiftUI, such as more straightforward configuration.

In this tutorial, you’ll learn about all the exciting new features released with iOS 15 for SwiftUI apps that use Core Data.

You’ll learn how to:

  • Update existing fetch requests with new sort criteria.
  • Perform sectioned fetch requests.
  • Apply dynamic filters with predicates.

There’s a lot to cover; time to get started!

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial.

In this tutorial, you’ll build a Core Data app that helps you track information about your best friends — your besties! Don’t worry if you’re the lonesome type, the app comes with a set of pre-loaded imaginary friends for you to play with.

Open the starter project and take a peek around. The app — called Besties — lets you track your friends’ names, where you met them and the date you met. It uses Core Data for persistence.

Build and run to see the app in action:

Opening screen showing your list of friends

You can tap the + button on the top right to add a new Bestie, if the built-in ones don’t do it for you:

Adding a new friend

After you save, the new Bestie shows up in your list:

New friend in the friends list

You can remove a friend, if they were mean, by swiping to delete, or you can edit one by tapping them, but the main point of this tutorial is to work with the list, so you’ll have a quick tour of the code to orient yourself, then get started.

Getting to know your Besties

Open PersistenceManager.swift. This class handles Core Data setup and save operations. The setup is standard — you initialize an NSPersistentContainer and use that to load the persistent stores. You won’t need to make any changes to this class for the tutorial.

Open AppMain.swift. You’ll see the persistenceManager property is initialized immediately and then passed into the SwiftUI environment for use in child views. This is also the point where, if you don’t have any, the sample friends are added to the database.

Finally, open ContentView.swift. You’ll see a standard implementation of an @FetchRequest property wrapper to fetch a list of Besties sorted by name:

@FetchRequest(
  sortDescriptors: [
    NSSortDescriptor(
      keyPath: \Friend.meetingDate,
      ascending: true)
  ],
  animation: .default)
private var friends: FetchedResults<Friend>

You’ll adapt this view throughout the tutorial to perform dynamic sorting, add search via dynamic predicates and update the list to add sections for organization.

Setting Up Core Data

iOS 15 adds a small convenience with lazy entity resolution. Prior to iOS 15, you would need to set up your Core Data stack before adding it to the environment. If a view attempted to access the view context before setup, you would most likely see a crash. With iOS 15, lazy entity resolution solves that problem. You can remove some boilerplate code because of this.

Open AppMain.swift. Locate and delete the following property near the top of the class:

let persistenceManager = PersistenceManager.shared

Core Data will now lazily initialize entities when needed, so this property doesn’t have to be there to “warm up” the Core Data stack. Update the environment variable in AppMain to the following:

.environment(
  \.managedObjectContext, 
  PersistenceManager.shared.persistentContainer.viewContext)

You are now accessing the view context directly from PersistenceManager and adding it to the environment. This allows ContentView, and all its child views, to use the view context from the .managedObjectContext environment variable.

Build and run, and the app will function just as it did before.

Next, you’ll move on to creating dynamic sorting behavior.

Exploring Dynamic Sorting

Dynamic sorting is another new feature in iOS 15. With dynamic sorts, you can apply new sort descriptors directly to your list of fetched items backed by the @FetchRequest property wrapper. Updating the sort descriptors will cause the view to refresh immediately. You can apply animation by default as well.

Setting Up the Sort Model

First, create a model object representing a single sort. In the Xcode Project navigator on the left sidebar, open the Model group. Create a new Swift file and name it FriendSort.swift.

Add the following after import Foundation:

// 1
struct FriendSort: Hashable, Identifiable {
  // 2
  let id: Int
  // 3
  let name: String
  // 4
  let descriptors: [SortDescriptor<Friend>]
}

This structure defines the following:

  1. Conform to Hashable and Identifiable. This is necessary to use FriendSort in a SwiftUI view for selecting sorts from a menu.
  2. Add id to conform to Identifiable.
  3. name is the friendly name of the sort shown in the sort menu.
  4. descriptors are the SortDescriptors to apply.
Note: Notice the use of the new SortDescriptor object vs. the older NSSortDescriptor. This is new in iOS 15.

Add a list of pre-cooked sort options for the user to choose from, by adding the following static properties to FriendSort:

// 1
static let sorts: [FriendSort] = [
  // 2
  FriendSort(
    id: 0,
    name: "Meeting Place | Ascending",
    // 3
    descriptors: [
      SortDescriptor(\Friend.meetingPlace, order: .forward),
      SortDescriptor(\Friend.name, order: .forward)
    ]),
  FriendSort(
    id: 1,
    name: "Meeting Place | Descending",
    descriptors: [
      SortDescriptor(\Friend.meetingPlace, order: .reverse),
      SortDescriptor(\Friend.name, order: .forward)
    ]),
  FriendSort(
    id: 2,
    name: "Meeting Date | Ascending",
    descriptors: [
      SortDescriptor(\Friend.meetingDate, order: .forward),
      SortDescriptor(\Friend.name, order: .forward)
    ]),
  FriendSort(
    id: 3,
    name: "Meeting Date | Descending",
    descriptors: [
      SortDescriptor(\Friend.meetingDate, order: .reverse),
      SortDescriptor(\Friend.name, order: .forward)
    ])
]

// 4
static var `default`: FriendSort { sorts[0] }

Here’s what’s happening with the code above:

  1. It adds a static property to allow easy access to sorts from a SwiftUI View.
  2. It returns an array of FriendSort items.
  3. Each SortDescriptor specifies the keypath to sort on and the order in which to sort. Note the new SortDescriptor API introduces the .forward and .reverse enumeration values instead of the old Boolean-based sort direction specifier. Each option has a primary sort descriptor and a second one for cases where the first value is the same, such as when you meet lots of people in the same place.
  4. This static property is used as the initial sort option.

Take a moment to update the @FetchRequest property wrapper in ContentView.swift with the newer SortDescriptor API. Find this code at the top in @FetchRequest:

sortDescriptors: [
  NSSortDescriptor(
    keyPath: \Friend.meetingDate,
    ascending: true)
],

Replace it with this:

sortDescriptors: FriendSort.default.descriptors,

Now you are using the sort descriptors from the default sort option.

Setting Up the Sort View

Next, you’ll set up a view to present the sort menu. In File navigator, create a new SwiftUI view in the Views group and name it SortSelectionView.swift.

At the top of the file, just under the struct declaration, add the following:

// 1
@Binding var selectedSortItem: FriendSort
// 2
let sorts: [FriendSort]

The above code does the following:

  1. Creates a binding for the currently selected sort item.
  2. Creates the array to provide the list of sorts to the view.

Next, update your preview by providing a selected sort. Right under the declaration of SortSelectionView_Previews, add the following property:

@State static var sort = FriendSort.default

This property creates the initial data required by SortSelectionView. Next, update the initializer for SortSelectionView inside the preview. Replace SortSelectionView() with the following:

SortSelectionView(
  selectedSortItem: $sort, 
  sorts: FriendSort.sorts)

The code above passes the sort property in as a binding and a list of sorts from FriendSort, satisfying the compiler and rendering your preview. You may need to start the preview canvas by clicking the Resume button or using the keyboard shortcut Command-Option-P.

Finally, replace Text("Hello, World!") in the body of SortSelectionView with the following:

// 1
Menu {
  // 2
  Picker("Sort By", selection: $selectedSortItem) {
    // 3
    ForEach(sorts, id: \.self) { sort in
      // 4
      Text("\(sort.name)")
    }
  }
  // 5
} label: {
  Label(
    "Sort",
    systemImage: "line.horizontal.3.decrease.circle")
}
// 6
.pickerStyle(.inline)

The code above does the following:

  1. Builds a Menu to list the sort options.
  2. Presents a Picker and passes the selectedSortItem as the binding.
  3. Uses the sorts array as the source of data for the picker.
  4. Presents the name of the FriendSort as the menu item text.
  5. Shows a view with an icon and the word Sort as the label.
  6. Sets the picker style to .inline, so it displays a list immediately without any other interaction required.

Great! This completes the sort menu view. Click the Live Preview button in the SwiftUI preview canvas to see your results. Tap Sort to see your menu presented:

Sort Preview

Connecting the Sort View

Next, it’s time to connect SortSelectionView to ContentView. Open ContentView.swift. Right under friends, add the following:

@State private var selectedSort = FriendSort.default

The code above adds a state property that represents the selected sort option and uses the default value you defined earlier.

Finally, replace the body of the .toolbar modifier with the following:

// 1
ToolbarItemGroup(placement: .navigationBarTrailing) {
  // 2
  SortSelectionView(
    selectedSortItem: $selectedSort, 
    sorts: FriendSort.sorts)
  // 3
  .onChange(of: selectedSort) { _ in
    friends.sortDescriptors = selectedSort.descriptors
  }
  // 4
  Button {
    addViewShown = true
  } label: {
    Image(systemName: "plus.circle")
  }
}

Here’s what’s happening with this code:

  1. Instead of separating ToolbarItem wrappers, it embeds the two views for the toolbar in a ToolbarItemGroup and applies .navigationBarTrailing placement. The ToolbarItemGroup cuts down on a little bit of unnecessary code.
  2. It adds a SortSelectionView as the first toolbar item. Passes in selectedSort property as the binding for the PickerView.
  3. On change of the selected sort, it gets the SortDescriptors from the selected sort and applies them to the fetched friends list.
  4. Inserts the Add button toolbar element after the Sort view.

And that completes your sort implementation! Build and run to admire your handiwork.

Friends list showing the new sort button

Tapping the new sort button triggers a menu. The current sort is pre-selected:

New sort menu showing

Selecting a new sort will dismiss the menu and immediately sort the list:

Friends list with new sort applied

Opening the menu again shows the correct selected sort. Awesome!

Sort menu showing new sort selection

When you have this many friends, though, you can’t always find the one you want by sorting. Next, you’ll find out how to add a search.

Implementing Search and Filter

Now, you’ll implement search and live filtering. First, you need to add an @State property to hold the value of the current search. In ContentView.swift, add the following directly under selectedSort:

@State private var searchTerm = ""

Next, under searchTerm, create a binding property that will handle updating the fetch request:

var searchQuery: Binding<String> {
  Binding {
    // 1
    searchTerm
  } set: { newValue in
    // 2
    searchTerm = newValue
    
    // 3
    guard !newValue.isEmpty else {
      friends.nsPredicate = nil
      return
    }

    // 4
    friends.nsPredicate = NSPredicate(
      format: "name contains[cd] %@",
      newValue)
  }
}

The code above does the following:

  1. Creates a binding on the searchTerm property.
  2. Whenever searchQuery changes, it updates searchTerm.
  3. If the string is empty, it removes any existing predicate on the fetch. This removes any existing filters and displays the complete list of Besties.
  4. If the search term isn’t empty, it creates a predicate with the search term as criteria and applies it to the fetch request.

Finally, add a searchable modifier to the List view right before the .toolBar modifier:

.searchable(text: searchQuery)

This modifier binds the search field’s value to the searchQuery property you just created. This connects your search field to a dynamic predicate on your fetch request.

Build and run and give your new search a try. Once the list of Besties displays, pull down to expose the search field. Start typing a search, and you’ll see the list filter based on the contents of the search field. Excellent!

Friends list being filtered by entering a search term

Next, learn about another way to make your list more useful by dividing it into sections.

Updating to Sectioned Fetch Requests

With iOS 15, Apple has added the ability to render sections in your SwiftUI view right from the fetch request. This is done with a new type of fetch request property wrapper named @SectionedFetchRequest.

@SectionedFetchRequest requires generic parameters for the type of data that represents your sections and the type of Core Data entity that will compose your list. The sectioned request will give you section separators with titles in your list and will even allow collapsing and expanding sections by default.

Open ContentView.swift and replace the entire friends property and @FetchRequest property wrapper with the following:

// 1
@SectionedFetchRequest(
  // 2
  sectionIdentifier: \.meetingPlace, 
  // 3
  sortDescriptors: FriendSort.default.descriptors,
  animation: .default)
// 4
private var friends: SectionedFetchResults<String, Friend>

The code above does the following:

  1. Switches to the new property wrapper @SectionedFetchRequest.
  2. Provides a keypath for your section identifier. Here, you’ll use meetingPlace as the section identifier. The section identifier can be any type you would like, as long as it conforms to Hashable.
  3. sortDescriptors and animation stay the same as before.
  4. Updates the friends property to include a generic parameter type for the section. In this case, it is String.

Because you are switching to a sectioned fetch, you need to update the method signature of deleteItem(for:section:viewContext:) in ListViewModel to account for the addition of sections in the list. Open ListViewModel.swift and update the section argument to receive an item from the section:

section: SectionedFetchResults<String, Friend>.Element,

Finally, update ContentView.swift to render the sections along with the FriendView for each row. Replace everything inside of List with the following:

// 1
ForEach(friends) { section in
  // 2
  Section(header: Text(section.id)) {
    // 3
    ForEach(section) { friend in
      NavigationLink {
        AddFriendView(friendId: friend.objectID)
      } label: {
        FriendView(friend: friend)
      }
    }
    .onDelete { indexSet in
      withAnimation {
        // 4
        viewModel.deleteItem(
          for: indexSet,
          section: section,
          viewContext: viewContext)
      }
    }
  }
}

Here’s what’s going on with the code above:

  1. It iterates over the sectioned fetch results and performs work on each section.
  2. It creates a Section container view for each result section. It uses a text view with the value of the section ID for display. In this case, it will be the name of the meeting place.
  3. For each row in the section, it creates a FriendView the same way you did before.
  4. In the .onDelete action, it passes the section along with the indexSet so that you can locate and delete the correct row.

That covers basic support for adding sections to your fetch request. Build and run. You’ll see that your list now has built-in sections by meeting place!

Friends list divided into sections by meeting place

You can also expand and contract sections in the list by tapping on the disclosure indicators:

Sections in the friends list in a collapsed state

However, something isn’t right. Next up, learn about an extra consideration you need when working with sectioned data.

Updating Sorts

Trying to sort right now won’t work at all. Try changing the sort options — eventually, the app will crash and display this message:

Thread 1: "UITableView internal inconsistency: encountered out of bounds global row index while preparing batch updates"

This error is caused by a mismatch in section and row data when you sort on one property but group on another. The fetch results must be sorted in section order for the list to work. To fix this, you need to add an additional property to your sort option struct. Open FriendSort.swift and add a new property to FriendSort:

let section: KeyPath<Friend, String>

Next, update sorts to add the new section parameter to all of the initializers:

static let sorts: [FriendSort] = [
  FriendSort(
    id: 0,
    name: "Meeting Place | Ascending",
    descriptors: [
      SortDescriptor(\Friend.meetingPlace, order: .forward),
      SortDescriptor(\Friend.name, order: .forward)
    ],
    section: \Friend.meetingPlace),
  FriendSort(
    id: 1,
    name: "Meeting Place | Descending",
    descriptors: [
      SortDescriptor(\Friend.meetingPlace, order: .reverse),
      SortDescriptor(\Friend.name, order: .forward)
    ],
    section: \Friend.meetingPlace),
  FriendSort(
    id: 2,
    name: "Meeting Date | Ascending",
    descriptors: [
      SortDescriptor(\Friend.meetingDate, order: .forward),
      SortDescriptor(\Friend.name, order: .forward)
    ],
    section: \Friend.meetingDay),
  FriendSort(
    id: 3,
    name: "Meeting Date | Descending",
    descriptors: [
      SortDescriptor(\Friend.meetingDate, order: .reverse),
      SortDescriptor(\Friend.name, order: .forward)
    ],
    section: \Friend.meetingDayDescending)
]

Each sort option now has a section key path. Note that the two meeting date options have different key paths — without this, you get a crash when switching between the two date options.

Go back to ContentView.swift. Here, you can replace the initial section with the new property from the default sort option. In the @SectionedFetchRequest property, replace the section identifier with the following code:

sectionIdentifier: FriendSort.default.section,

Still in ContentView.swift, update the application of sorts to the list by replacing the body of the .onChange closure with this::

let request = friends
request.sectionIdentifier = selectedSort.section
request.sortDescriptors = selectedSort.descriptors

This code might seem a little strange — why is let request = friends there? Why not just update friends directly? The reason is that you are now making two changes to the fetch request, but they both need to be evaluated at the same time. Each time you reference friends, it’ll commit any changes to the fetch request. So if you change friends.sectionIdentifier, then change friends.sortDescriptors, the fetch request with the updated section identifier will be evaluated, before you have had chance to update the sort descriptors. As you’ve seen earlier, this can cause crashes, as the results have to be in section order. Pulling out the request into a local reference and updating that means that the changes won’t be evaluated until friends is accessed again when the view body is recomputed.

Build and run to test your new changes. You’ll see sorting by meeting place will group by section and perform a sort on meeting place name.

Sorted and sectioned by place

Performing a sort by meeting date will change the grouping to meeting date, then perform a sort on that property.

Sorted and sectioned by date

Where to Go From Here?

Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

If you want to dig deeper into working with Core Data and SwiftUI together, check out this Core Data with SwiftUI tutorial and this Core Data with CloudKit tutorial.

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!

More like this

Contributors

Comments