Episode #570

Building forms to create and edit models with SwiftData

Series: Leveraging SwiftData for Persistence

18 minutes
Published on December 10, 2023

This video is only available to subscribers. Get access to this video and 571 others.

We learn how to build a form in SwiftData. We cover how to use the .sheet modifier to present a new view for creating or editing artist records. We also cover creating the state for the form, presenting the sheet, and creating the form itself. We also implement a scratch model context to ensure that our changes can be discarded if the user taps the Cancel button.

This episode uses Xcode 15.0, Swift 5.9.

Now that we know how to work with the model context,

it's time to build a form to create and edit our artist records.

We'll start by using a sheet modifier to present a new view

when the user taps to add a new artist

or when tapping a row to edit an existing one.

The sheet modifier has two flavors,

one of them that takes a binding to a bool, called isPresented,

and when you set this to true, the sheet is presented,

and if you set it to false, the sheet is dismissed,

but also, because it's a binding,

if the user swipes down to dismiss the view,

our binding is also reflected to that state.

So the state of the sheet is always kept up to date

with the state of our view, which is a good thing in SwiftUI.

The other option we have is a binding to an item,

and this binding to an item has to be some identifiable value,

and when that identifiable value becomes non-nil,

because it takes an optional, then it will present the sheet,

and when it becomes nil, it will dismiss the sheet.

This is generally my preferred approach,

because oftentimes I'm going to be presenting a sheet

to act upon some data or edit some model,

and I don't want to have to maintain a boolean and a value,

and that produces two states

that have to be updated in lockstep rather than just one,

and in general, it's a good idea to make impossible states

unable to compile using the Swift type system.

So this is the option we're going to go with.

So before we create that sheet modifier,

let's go up to the top,

and let's create a new state property.

This will be a private var for the editing artist.

When this editing artist becomes non-nil,

we can present our sheet.

So I'm going to go down to the bottom and say sheet for item.

We're going to pass in $editingartist,

and it gives us that artist inside of our closure here

for our sheet, and this is already unwrapped for us.

So at this point now, I can create, say,

a navigation stack inside of there.

We'll just have a color.blue for a moment,

and we'll give this a navigation title,

and for now, we're just going to say new artist, let's say.

And then I also want to do a navigation title display mode

to inline so we don't get the giant title,

and if we run this in the preview,

what we need to be able to do is set this sheet

$editingartist binding in order for this preview

to just show this automatically.

It will work if I run the preview in the interactive mode,

and we tap on a button to set this artist.

So let's first do that.

Where we're creating this artist here,

I'm going to change this to just be new artist.

We're going to delete this code here,

and we're going to set $editingartist equal to this new artist.

So now when I save that and I click the add button,

we get our sheet, and when I dismiss it,

it will update that binding.

Now this is a little bit too big.

After all, our artist model only has one field,

so I'm going to go down to this navigation stack,

and we're going to add the presentationDtents.

This is a set of dtents that we can add

to determine how tall the sheet wants to be.

We can give it a bunch of different values here,

large, medium, custom, a fraction of the screen,

or a given height.

And in this case, I'm just going to say 200 points.

That should be plenty for us.

So now when I click add, we get this new artist.

Now one thing I want to look at real quick is,

if we wanted to have a preview,

and I think we can give this a string showing sheet.

So if I switch over to this preview,

now we have the exact same thing,

and what I want to do is say that the artist view

is already editing that artist.

So I'm going to make this editing artist

not private for the moment,

so that it generates this initializer for us,

so that we can pass in editing artist,

and in this case, I'm going to pass in artist

with a name like that.

Now unfortunately, this is going to crash the preview.

And it crashes the preview because

we have created an artist outside of any model context.

So Swift data is actually going to crash

trying to observe this because there's no model container yet,

there's no model context.

We did create a model context here,

but this is a view modifier,

and we are passing this artist that we just created.

So what we actually need to do here is change this

to say that we're going to create a model container,

we're going to pass in the artist.self like this,

and the configurations here will be init,

is stored in memory only, true.

Once we have that, our model container modifier

just uses that container,

and then we need to create the model context in that container.

Finally, we can create our artist like this,

and then we can just pass it along.

And because we added some code to the preview,

we need to add the return keyword

so that it will actually return the view's body.

So now we build, we're going to do try.

Okay, so now we have a preview that shows the entire list

and a preview that shows the sheet, and it won't crash.

So that's pretty nice.

So the next thing I want to do

is move on to creating an artist form.

So let's create a new file.

We're going to call this artist form.

So we're basically just moving that content into here,

and let's move over to artist form for a moment.

And what I want to do here is split this into two parts.

One, I want to have this artist form,

which is going to deal with the model that we are pulling in,

as well as all the Swift data related stuff

about saving changes and canceling and things like that.

And then I want to have another private struct

for artist form content.

And this one is simply going to take an artist,

and it will be an artist.

And then this one will have its body be the form

with a text field.

Here we can say name, and then we have a binding here.

To get a binding to a Swift data model,

we can make this a bindable of our artist.

Now you may be used to the binding keyword,

and you might want to reach for that there.

But the binding keyword is really meant for value types.

And with the bindable keyword,

we can take any observable model and bind to its properties.

Now because this is an observable model,

we can tack on this bindable attribute.

And now when we do $artist.name,

we get a binding to that artist's name.

So this is exactly what we want.

Now the difference between this one and this one

is that this one might have some, say, an environment

for the model context,

and it might have other stuff related to actually saving.

But if I just want to preview the UI,

it's really nice to be able to just isolate the stuff

that has no dependencies.

So in this case, now I can have a preview.

Let's say we want a navigation stack,

and then I want to do artist form like this.

And then I'm going to pass in an artist.

Sorry, this is going to be the content

which passes in an artist.

And here I can create one called Jimmy Hendrix.

Let's finish this out here.

Now we're back at this problem of it crashes

when we try to create an artist

that doesn't have a model container.

So we're going to copy this code for now.

But this is the type of code

that we're going to have to be dealing with a lot.

And we probably need some sort of easy way

to just say build up a preview container context

and some data.

Okay, so this is our artist form.

It's a simple text field,

and it's got a way to edit the artist name.

Okay, so the next thing I want to do is in the artist form,

I want to have a toolbar.

And the toolbar will have toolbar items.

And toolbar items have a placement argument

where we can say top bar leading.

And then I can do another one for top bar trailing.

So this is how we will add the save and cancel buttons.

First we'll do the cancel button.

So this will be a button with a role of .cancel.

And it will have an action.

And here we need to dismiss the view.

For the label we will say text cancel.

So to dismiss the view,

we're actually going to grab another environment value.

And this time it's going to be the dismiss environment value.

And then we can just call it like this,

which is pretty nice.

And we'll do something similar for this for saving.

This time I will use no role.

This will be save changes,

which we'll write in a second.

This will be save.

And then the button style here,

I want to be bordered prominent.

Now let's make a private func save changes.

And this we will implement in a minute.

Okay, the thing that we're missing now

is that we need to pass in the artist.

And then we need to use the artist form content

and pass that artist here.

And it looks like we don't actually need

to create the model context here.

It just needs to have a model container created

so that it can read the schema,

which makes sense because we're not actually using the context here.

So let's remove it from there and from here.

So we just end up creating this container.

So now let's address this warning

where we don't actually need to save the container.

We just need to have created it first.

Okay, so with that in mind,

let's go back over to the artists content view, artist view.

And then here, instead of the color.blue,

we're going to create our artist form.

And here we're going to pass in the artist.

Okay, now it seems like this should do the trick.

And so what can happen now is if I hit add,

it's going to create a new artist and I can give it a name.

I'm not actually saving any changes yet,

but if I hit cancel, it goes away.

So that's good.

Let's now add the ability to tap on a row and edit it.

Now these text values are actually going to shrink to the size of the text.

And so we want to be able to tap on the whole row.

To do that, we're going to use a frame with a max width of infinity

and an alignment of leading

so that our texts will fill the whole screen.

So if I open up the preview again,

and I give this a background of color.blue,

now we can see that it takes up the full screen, the full area.

If I had it just before the frame, it would take up that area.

The next thing I want to do is add a content shape of rectangle.

And doing that will allow that whole area to be tappable.

So now I can do it on tap gesture,

and we can set editing artist equal to this artist.

Okay, now let's take a look at editing an artist.

So I'm going to delete that.

In fact, we can get rid of this on appear.

We don't need that anymore because it keeps adding new records.

And if I tap on this row, now I have this value where I can start editing.

And if I start editing, you'll notice the problem.

We're actually editing the row that exists in our main context,

and it is auto-saving.

So even if I were to hit cancel here,

those changes have been made to the model, and it will save eventually.

And that's not what we want.

And so there's a few ways to handle this sort of scenario.

One of the ones that I see quite a bit

is when people will just extrapolate all of the raw values

for the model that you're editing.

And instead of doing a binding to the model,

we'll do a binding to raw properties

and only apply those to a model later.

I think there's a better way to do this.

And the way I want to do it is instead of passing in the model context

and the artist, I'm going to make the artist private here.

I also want to have a private let model context,

but this one is one we are going to create.

And then I want to create an init that takes the artist ID,

which will be a persistent model--

sorry, persistent identifier.

It's going to take a flag for whether this is a new record or not,

and it's going to take the model container.

Now, this model container, you can get this from the environment,

but because we want this in the initializer,

there's nowhere for me to grab this in this case.

There are different ways to go around this,

but I think this is the easiest way,

because if I go to the artist view,

when we are passing in our artist form,

instead of passing that, I'm going to be passing artist ID,

which is going to be artist.id.

Now, notice that every artist, even new ones,

are going to have an ID.

However, not all of them are going to have that store identifier.

That only gets created when it's actually saved.

So I can--let's say we create an extension on persistent model,

and we can say isNewRecord as a boolean.

This will be persistent model ID.

Is that what it is? Yeah, .storeIdentifier.

And if that's nil, then it's a new record.

So we can now say artist.isNewRecord,

and then we can pass in our container.

And we have our container here

because we have our model context.

Our model context comes from the environment here.

We're free to use it here and grab the container.

So this seems to be the easiest way to do this.

I'm also going to bring in the navigation title here,

and we're going to change this to say

if artist.isNewRecord,

then we'll do new artist.

Otherwise, we'll say edit artist.

Okay, so now back on our artist form,

we now have an artist ID, isNewRecord, and a container.

So what I want to do here is I want to create a new context.

So this will be a model context for that container.

We're going to set this context autosave enabled to false.

We only want to save this when the user taps save.

And because we'll be working with a new model

that only is bound to this context,

we're in complete control of when we want to discard the changes

or save the changes.

We will next look to see if this is a new record.

If it is, we can just set artist equal to artist

with an empty name.

Otherwise, we can say artist equals context.model4,

pass in the artist ID.

We're going to have to cast this as an artist,

but we know it will be an artist, so that should be fine.

And then we'll set the model context equal to context.

And I don't need self. there.

Okay, so now we have a way to grab an artist form,

and we have a decoupled model that we load from the store

or create a new one.

So now when we hit cancel, we just dismiss,

we throw away everything in our view,

and we don't really need to worry about it.

But when we click save, now we can say that if this is a new record,

which means that we can actually use that artist.isNewRecord extension.

If it's a new record, then we're going to say modelContext.insertArtist.

And if we aren't a new record,

then we're already bound to that model context.

So then all we need to do here is say modelContext.save.

And that throws, and in a real app,

we should do better about error handling and save gracefully.

But in this case, I'm just going to crash if something went wrong.

Okay, so with that change in place,

if I tap on a row and I edit it,

you can see that it's disconnected from the actual entity.

And if I hit cancel, that change is gone.

I edit again.

This time I'm going to say Pink Floyd 3.

I hit save, and it updates the row in this context.

Now it's important to realize what actually happened there.

We had two contexts.

We had one that was a scratch context that we were making changes to,

using live bindings.

As soon as we hit save, it went to save to the store underneath,

the SQLite database.

And then that change was picked up and propagated to this context,

which we have in our query.

And it automatically updated our view.

This is a fantastic feature of working with Swift data,

where your models are generally kept up to date for you.

You don't really have to observe any changes.

It just works.

And now let's test adding a new record.

In this case, I'm going to add a Bristol Maroney.

And we'll hit save.

And there we go.

Saving works.

Editing works.

And that's it for this video.