In part eight of this task, you improved the user interface of the app in these ways:
- cleared the input field after a to-do item was added
- disabled the Add button, until the user types valid input for a to-do item
- used a sheet to show the user interface for adding a new to-do item
- provided a cue to users to add new items, when none exist
In today’s optional continuation of that tutorial, you will learn how to attach images to a to-do item.
Here is a 90-second video showing how the revised app will function when you have completed today’s tutorial:
Extend the model
When a user imports a photo from the device’s photo library, our app must have a data structure that can hold the photo’s data in memory.
Later, we will write the photo, or image, to Supabase, where it will be persisted even if we close the to-do list app.
Find the Model group in your project, and add a new Swift file named TodoItemImage.swift
:
Now copy the following code into your clipboard:
import SwiftUI
struct TodoItemImage: Transferable, Equatable {
// MARK: Stored properties
let image: Image
let data: Data
// MARK: Computed properties
// Required to conform to Transferable protocol
// Is invoked when user picks (attempts to import) an image from photo library into this app
static var transferRepresentation: some TransferRepresentation {
return DataRepresentation(importedContentType: .image) { importedImageData in
// Attempt to create an instance of TodoItemImage from the imported data
guard let image = TodoItemImage(rawImageData: importedImageData) else {
// If the important did not work, throw an error
throw TransferError.importFailed
}
// The import worked, so return the imported image
return image
}
}
}
// Extend the structure to add new capabilities
extension TodoItemImage {
// MARK: Initializer(s)
// An initializer to create an instance of TodoItemImage from the image data
// returned by the PhotosPicker
init?(rawImageData: Data) {
// Create an instance of UIImage from the raw image data provided
guard let uiImage = UIImage(data: rawImageData) else {
return nil
}
// Create a SwiftUI Image structure from the UIImage instance
let image = Image(uiImage: uiImage)
// Initialize and return TodoImage instance
self.init(image: image, data: rawImageData)
}
}
// List the possible errors that can occur when importing an image
enum TransferError: Error {
case importFailed
}
… and paste it into the new file in your project, like this:
Let’s examine what is happening in this code, but at a high level first. We will look at details in a moment.
At a high level, we have:
DISCUSSION
A new structure named
TodoItemImage
is defined. It’s purpose is to hold an image that goes along with a givenTodoItem
.The structure conforms to the
Transferable
protocol, which is a must to allow for importing from the device’s photo library. More on this in a moment.Separately the definition of the
TodoItemImage
structure is extended to add new functionality. More on this in a moment.An enumeration named
TransferError
is defined. An enumeration is a way of defining a data type with only certain defined states. You can think of a Boolean as an enumeration of sorts – a Boolean, orBool
in Swift, can only containtrue
orfalse
. The purpose of theTransferError
enumeration is to define all the possible error states that can occur when importing an image from the device’s photo library.
Let’s look in more detail at the implementation of TodoItemImage
now:
In this structure:
DISCUSSION
TodoItemImage
has two stored properties. The first,image
, holds an actual instance of theImage
data type used by SwiftUI. This will display the image in the user interface. The second stored property,data
, holds the raw stream of data – the literal 1’s and 0’s – that make up the imported image.On line 20, a computed property named
transferRepresentation
is defined. This is required to conform to theTransferable
protocol. The user will select a photo whose data is copied, or transferred, out of the photo library and into our app. The job of this computed property is to attempt to take that data and create an instance of theTodoItemImage
structure from it.At this point, we try to create an instance of the
TodoItemImage
structure. If this does not work, an error is thrown, indicating that the import of the image from the photo library did not work.If the import works, an instance of
TodoItemImage
is returned.
Let’s look at the enumeration next:
In the enumeration:
DISCUSSION
- There is only one possible error condition that can occur when transferring image data out of a device’s photo library – a failure of the entire import procedure. As such, there is just one possible state, or case, for the
TransferError
enumeration.
Finally, let’s examine the extension:
In the extension:
DISCUSSION
The extension adds a custom initializer to the
TodoItemImage
structure. Recall that the job of an initializer in a structure or class is to get the instance of that structure or class ready for use.It is not possible (currently) to create a SwiftUI
Image
structure directly from a raw stream of 1’s and 0’s that represent an image. Instead, we must first create an instance of theUIImage
data type. This is provided through the UIKit framework, which is another (older) framework that can be used to create user interfaces for iOS. UIKit is a bit harder to use than SwiftUI, but it is more customizable in terms of what kind of interfaces can be created. If an instance of theUIImage
data type cannot be created for some reason, anil
value is returned.If we got this far, we take the instance of the
UIImage
data type and create from it an instance of the SwiftUIImage
structure.An instance of
TodoItemImage
is returned.
Bottom line – if you need to import images into your app from a device’s photo library, you need to use code just like this. The only thing that might change is the name of the structure. In this app, we named the data type TodoItemImage
. In your app, you might choose a name for the structure that better matches what you are doing in your app. For example, if you are building an online marketplace app where people post listings of items for sale, you might name this structure ListingImage
instead.
This is a key step, even though we’ve just created a single file. Please commit and push your work with this message:
Added a new data structure to represent an image that was picked (selected) from the photo library of the device.
Add the photo picker
Our next step is to add the ability to select an image from the user’s photo library, and then show that image in the user interface where a new to-do item is added.
Switch to NewItemView
– recall that this is the view that appears in the slide-up sheet, when we are adding a new to-do item:
Import the framework
First, we must import a new framework – PhotosUI – please add this line of code:
import PhotosUI
…above the import SwiftUI
line of code:
Add the stored properties
Next we need to add two stored properties:
// The selection made in the PhotosPicker
@State var selectionResult: PhotosPickerItem?
// The actual image loaded from the selection that was made
@State var newItemImage: TodoItemImage?
… like this:
The first stored property, selectionResult
, holds a reference to whatever item was selected by the user in the picker. This could be a video, an image, or potentially something else. In our case, we will limit what can be selected to an image.
The second stored property, newItemImage
, will contain an actual instance of the data type we defined in the prior section of this tutorial.
Add the function
Next, use code-folding to fold up the body
property of the view, like this:
Now add a few blank lines below the body
property, taking care to ensure you are doing this inside the closing }
of the NewItemView
structure:
Take the following code:
// MARK: Functions
// Transfer the data from the PhotosPicker selection result into the stored property that
// will hold the actual image for the new to-do item
private func loadTransferable(from imageSelection: PhotosPickerItem) {
Task {
do {
// Attempt to set the stored property that holds the image data
newItemImage = try await imageSelection.loadTransferable(type: TodoItemImage.self)
} catch {
debugPrint(error)
}
}
}
… and copy it into your computer’s clipboard, then paste it into the space you just created in NewItemView
, like this:
The purpose of this function is described in the comments associated with it.
Essentially, it’s job is to take the picker selection the user makes, and transfer the image data into an instance of the TodoItemImage
data type. Via the Transferable
protocol, this function will invoke the transferRepresentation
computed property that we defined earlier in TodoItemImage
.
Detect photo selections
Next, we must detect when the user has made a selection in the photo picker.
Unfold the body
property in NewItemView
:
Then, fold up the VStack
:
Next add a few blank lines below the .toolbar
view modifier:
Then copy this code into your clipboard:
// This block of code is invoked whenever the selection from the picker changes
.onChange(of: selectionResult) {
// When the selection result is not nil...
if let imageSelection = selectionResult {
// ... transfer the data from the selection result into
// an actual instance of TodoItemImage
loadTransferable(from: imageSelection)
}
}
… and paste it after the .toolbar
view modifier, like this:
If needed, press Command-A
and then Control-I
to re-indent your code and keep it tidy.
That new code will be invoked whenever a new photo is selected in the picker.
Here is the overall sequence of what happens:
This is what happens, in general:
flowchart TB
id1["<b>Photo Picker</b>\nUser selects a photo"]
id2["<b>selectionResult</b>\nHolds a reference to selected photo"]
id3["<b>newItemImage</b>\nHolds an instance of TodoItemImage"]
id1 -- sends selection to --> id2
id2 -- data sent to --> id3
Present the picker interface
Finally, scroll up to the VStack
that was folded up earlier:
Unfold it:
Highlight the Spacer
that is inside the VStack
:
Now copy this code into your clipboard:
HStack {
PhotosPicker(selection: $selectionResult, matching: .images) {
// Has an image been loaded?
if let newItemImage = newItemImage {
// Yes, show it
newItemImage.image
.resizable()
.scaledToFit()
} else {
// No, show an icon instead
Image(systemName: "photo.badge.plus")
.symbolRenderingMode(.multicolor)
.font(.system(size: 30))
.foregroundStyle(.tint)
}
}
}
.frame(height: 100)
… and paste it into the file, replacing the Spacer
, like this:
Let’s examine that code more carefully:
In order:
DISCUSSION
The
PhotosPicker
structure is provided by thePhotosUI
framework and makes it possible to select photos from the device’s photo library.The picker is bound to the
selectionResult
stored property that we added and discussed earlier. When a selection is made in the picker, this stored property is updated to hold the selection that was made.We configure the
PhotosPicker
to allow selection of images only. However, many different types of items can be selected from a device’s photo library:Inside the block of code tied to the
PhotosPicker
we provide the user interface element that the user can click on to bring up the picker interface.If an image has been selected, this will be a thumbnail of that image.
If an image has not yet been selected, this will be an appropriate SF Symbol.
Finally, switch over to the LandingView
file in your project, and scroll down to the code that presents the sheet that will show NewItemView
:
The addition of the photos picker means that the sheet will need to be a little larger to show the expanded user interface. So, please change the fraction of the screen size that the sheet will take up – increasing it from 15%:
.presentationDetents([.fraction(0.15)])
… to 25% instead:
.presentationDetents([.fraction(0.25)])
We’ve done enough to be able to try out the photos picker at this point.
Run the app in the Simulator on your computer, or on a device attached to your computer, and try this out:
You may have noticed a couple of issues:
- After adding a new to-do item, the text field is cleared, but the selected photo remains.
- The to-do item is added, but not the image that goes along with it.
We can fix the first issue quickly. We will address the second issue in the next section of today’s tutorial.
To fix the first issue, switch to NewItemView
and scroll down to the code that defines the Add button:
Find the code that clears the text field:
// Clear the input field
newItemDescription = ""
… and add this code below it:
// Clear the photo picker selection result
selectionResult = nil
// Clear the loaded photo
newItemImage = nil
… like this:
Now run the app again, and try adding a to-do item that contains an image:
Note that the selected image is now cleared after the new to-do item is added.
This is all very important progress, so please commit and push your work now with this message:
Can now select and load a photo from the Photos library.
Configure Supabase to store photos
Now we need to complete a bit of setup in the cloud at Supabase to be able to save the photos that have been selected by users in the to-do list app.
Adjust the database table
Visit your Supabase dashboard:
Then select your Todo List App project:
Open the Table Editor:
Select the todos
table:
Click the three dots beside the todos
table and select Edit Table:
Select the Add Column button:
Create a new column named image_url
with a data type of text
and a default value of NULL
, then press the green Save button:
You should see some messages indicating the column was created, and then something like the following – notice the addition of the image_url
column at right:
This column will contain the filename of the image for a to-do item, when an image is uploaded.
Add a storage bucket
Databases are great at storing, searching, and sorting text – they perform less well when managing large binary objects – that is, a lot of 1’s and 0’s that represent things like images.
In most cases then, application developers do not store images directly in a database.
Instead, they store a pointer to the image, and the image is saved somewhere else.
That is what we will do. We store a pointer – the filename – for an image in the database – and we will set up a storage bucket – essentially a folder – to hold all of the uploaded images.
So, please select the Storage option at left in Supabase:
Then select the New bucket button:
In the dialog that appears, provide the name todos_images
, ensure that the bucket is not public, and then select the Save button:
You should then see the following:
Now under the Configuration section at left, select Policies:
Then in the section for the todos_images
storage bucket, select the New policy button:
In the dialog that appears, select the For full customization option:
Make the following inputs:
- In Policy name type
Authenticated users can select, insert, and delete
. - In Allowed operation, enable
SELECT
,INSERT
, andDELETE
. - In Target roles select
authenticated
.
Like this:
Then press the Review button:
You will see a dialog like this – go ahead and select the Save policy button:
Finally, you should see the newly created policies listed:
Adjust the model
Return to the Table Editor:
Then select the todos
table:
Notice that we named new column to hold image filenames image_url
.
This capitalization strategy is referred to as snake_case
, and it is the convention used by most developers when building a database.
In Swift, the convention (as you know) is to use the camelCase
capitalization strategy.
So, we need to make a minor edit to the TodoItem
structure in our project in Xcode to manage this.
Switch to Xcode and open the TodoItem
file:
Add some blank lines after the stored properties of the structure:
Now copy this code into your computer’s clipboard:
var imageURL: String?
// When decoding and encoding from JSON, translate snake_case
// column names into camelCase
enum CodingKeys: String, CodingKey {
case id
case title
case done
case imageURL = "image_url"
}
… and paste it into the TodoItem
structure, below the existing stored properties, like this:
Note that we have added a fourth stored property, named imageURL
. This matches up to the column [[To-do List App, Pt. 9#adjust-the-database-table|that we added a moment ago to the todos
database table]].
The enumeration that is added will be used by Swift when Supabase decodes the information from JSON and turns it into instances of the TodoItem
structure.
This line is key:
case imageURL = "image_url"
This tells Swift to look for information from the image_url
column in our table, but to make the data available inside our Swift app using the imageURL
property.
Please commit and push your work at this point with the following message:
Adjusted model for TodoItem to match new column in todos table for tracking image URL.
Upload selected photos to database
Next we’ll make a small series of edits to ensure that images are actually uploaded to Supabase when new to-do items are created.
Adjust the view model
Since we are dealing with data, that means we will be editing the view model.
Please open TodoListViewModel
now:
Since images will be written to the Supabase storage bucket we just created, we must add the Storage
framework to the view model.
Add this line of code:
import Storage
… after the existing import
statement, like this:
Now scroll down to the createToDo
function:
Currently the function accepts one parameter, whose external parameter name is withTitle
and whose internal parameter name is title
and whose data type is String
.
We are going to add a second parameter that will accept an image that has been selected by the user.
Please make the following edit, changing the function definition from:
func createToDo(withTitle title: String) {
… to:
func createToDo(withTitle title: String, andImage providedImage: TodoItemImage?) {
… like this:
Now there are two parameters. The second parameter has an external name of andImage
. The internal parameter name is providedImage
. The data type is an optional TodoItemImage
. When the user has selected an image to go along with a new to-do item, this parameter will be provided with an image, otherwise, it will receive nil
.
Now find the start of the asynchronous task inside the function:
Below that line of code, add a few blank lines:
Then copy this code into your computer’s clipboard:
// Upload an image.
// If one was not provided to this function, then this
// function call will return a nil value.
let imageURL = try await uploadImage(providedImage)
… and paste it into that area, like this:
The purpose of that code is to take the provided image (if one was given) and actually upload the image to Supabase. If the image was uploaded successfully, imageURL
will contain the filename of the image as stored within the storage bucket on Supabase. If no image was provided, imageURL
will be nil
.
In a moment, you see an error message:
Cannot find 'uploadImage' in scope.
That just means we are trying to use a function – uploadImage
– that does not yet exist within the view model.
We will fix that in a moment.
Scroll down a bit further and locate the section of code that creates a new TodoItem
instance:
Adjust the code so that we provide the imageURL
when creating the TodoItem
:
Now we will add the uploadImage
function.
Fold up the createToDo
function:
Now copy this code into your computer’s clipboard:
// We mark the function as "private" meaning it can only be invoked from inside
// the view model itself (it will not be accessible from the view layer)
private func uploadImage(_ image: TodoItemImage?) async throws -> String? {
// Only continue past this point if an image was provided.
// If an image was provided, obtain the raw image data.
guard let imageData = image?.data else {
return nil
}
// Generate a unique file path for the provided image
let filePath = "\(UUID().uuidString).jpeg"
// Attempt to upload the raw image data to the bucket at Supabase
try await supabase.storage
.from("todos_images")
.upload(
path: filePath,
file: imageData,
options: FileOptions(contentType: "image/jpeg")
)
return filePath
}
Paste the code below the createToDo
function but above the delete
function, like this:
You will notice the error message goes away.
Review the comments in that code to understand what it does to upload an image.
You can use this function verbatim in your culminating task. The only thing that would need to change in a different project is what storage bucket the image is uploaded to. In this project, we are using todos_images
for the storage bucket name. In your project, you might select a different name for your storage bucket, so you would need to adjust this line of code (line 112 in the screenshot):
Adjust the view
Switch to NewItemView
and locate the line of code that invokes the createToDo
function in the view model:
It may be marked with an error.
This is because we added a second parameter to pass in the image – but here in the view, we are not yet providing an argument for that second parameter.
So, please change this code:
viewModel.createToDo(withTitle: newItemDescription)
… to this instead:
viewModel.createToDo(withTitle: newItemDescription, andImage: newItemImage)
When creating a to-do item, we are now providing both the description of the new to-do item – the text – and potentially an image as well – if one was selected.
We can now try this new code out.
Please run your app in the Simulator, and create a new to-do item along with an image:
We don’t yet see the image in our to-do list (we’ll fix that in the next section of this tutorial) but if we go to Supabase, we can see the uploaded image.
Switch to Supabase where you likely still have the todos
table showing.
Notice that the new to-do item created in the animation above is now showing in Mr. Gordon’s database table:
In particular, a value in the image_url
column shows up.
Now switch to the Storage panel:
Then select the todos_images
bucket:
Then select the file that was uploaded from your app:
You should see the image shown in a preview at right.
This is great progress – images are actually persisted in the cloud!
Please commit and push your work with this message:
When a to-do item is added with an image, the image is now uploaded to Supabase.
Download photos from the database
Next we need to show each image when there is one attached to a given to-do item in our list.
As you have likely come to expect at this point, that will involve first editing the view model, the editing a view to show the data.
Please switch to TodoListViewModel
and fold up all of the functions:
We are going to add a new function named downloadTodoItemImage
after uploadImage
but before the delete
function.
Please copy this code into your computer’s clipboard:
func downloadTodoItemImage(fromPath path: String) async throws -> TodoItemImage? {
// Attempt to download an image from the provided path
do {
let data = try await supabase
.storage
.from("todos_images")
.download(path: path)
return TodoItemImage(rawImageData: data)
} catch {
debugPrint(error)
}
// If we landed here, something went wrong, so return nil
return nil
}
Then paste it into place, like this:
This function accepts a path – the image URL – and if it can find an image with the provided name – it returns an instance of TodoItemImage
.
We will now make use of this function from the view layer of our app.
Switch to ItemView
:
Recall that ItemView
is the helper view that displays a single to-do item within the list – it’s invoked from LandingView
inside a List
structure to show a list of to-do items.
First we must add a stored property that will hold the image that might be downloaded from Supabase, if an image exists for a given to-do item.
Replace this code:
@Binding var currentItem: TodoItem
… with this code instead:
// Holds a reference to the current to-do item
@Binding var currentItem: TodoItem
// Holds the image for this to-do item, if an image exists
@State var currentItemImage: TodoItemImage?
… like this:
We have added a stored property named currentItemImage
that holds a to-do item image (if one exists) or nil
if no image is associated with the current to-do item.
We also added a comment to explain the purpose of the currentItem
stored property. That’s our to-do item.
Next we need to do adjust the user interface. It currently shows a to-do item like what you see in the first item here:
… but we want to adjust it so that if there is an image, it is displayed in a thumbnail at right, like what you see in the second item above.
So, fold up the existing Label
:
Then copy this code into your computer’s clipboard:
HStack {
Label(
title: {
TextField("", text: $currentItem.title, axis: .vertical)
.onSubmit {
viewModel.update(todo: currentItem)
}
}, icon: {
Image(systemName: currentItem.done == true ? "checkmark.circle" : "circle")
// Tap to mark as done
.onTapGesture {
currentItem.done.toggle()
viewModel.update(todo: currentItem)
}
}
)
// When an image has been successfully downloaded for this to-do item,
// (it is not nil), then show a preview of the image (not too big since it is in a list)
if let currentItemImage = currentItemImage {
currentItemImage.image
.resizable()
.scaledToFill()
.frame(width: 30, height: 30, alignment: .center)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 5))
}
}
… and replace the Label
with this new code, like this:
That looks like a lot of changes, but all that we have done is:
- Replaced the single
Label
with anHStack
. - Inside the
HStack
is the same code as before for theLabel
. - Below the
Label
is a selection statement. When thecurrentItemImage
contains an actual image, we show the image in a thumbnail.
All that remains is to actually load an image for a to-do item, when one exists.
Please fold up the HStack
, like this:
Then copy this code into your computer’s clipboard:
// Adds an asynchronous task to perform before this view appears.
.task {
// If the image URL for this to-do item is not nil, and if it is not an empty string...
if let todoItemImageURL = currentItem.imageURL, todoItemImageURL.isEmpty == false {
// ... then attempt to download the image so it can be displayed in this view
do {
currentItemImage = try await viewModel.downloadTodoItemImage(fromPath: todoItemImageURL)
} catch {
debugPrint(error)
}
}
}
… and paste it into the view, attached to the HStack
, like this:
This is the code that downloads an image from Supabase.
When ItemView
is created for a given to-do item, before it is displayed, the task we just added will attempt to download the image from the storage bucket, using the imageURL
value that was read from the current row in the todos
table of the database.
Remember this?
The task code we just added obtains that imageURL
value (essentially the filename) and attempts to download the image from the storage bucket:
If you run your app in the Simulator, you should now see the image you previously selected when testing out the upload functionality earlier:
This is amazing progress, so please commit and push your work with this message:
Images are now downloaded and shown in the to-do item list.
Fully delete photos from Supabase
Now that an image is potentially associated with a to-do item, when a to-do item is deleted from the todos
table, we must be sure to delete the associated image from the storage bucket, if one exists.
This a small edit. Please switch to TodoListViewModel
and fold up all of the functions:
We are going to modify the delete
function, so please unfold that function:
Highlight the comment just above the start of the code that deletes the row from the todos
table, like this:
Now copy this code into your computer’s clipboard:
// If an image exists for this to-do item...
if let imageURL = todo.imageURL, imageURL.isEmpty == false {
// ... then delete the image from the storage bucket first.
do {
let _ = try await supabase
.storage
.from("todos_images")
.remove(paths: [imageURL])
} catch {
debugPrint(error)
}
}
// Run the delete command to remove to-do item from database table.
… then paste it into the view model, replacing the highlighted comment, like this:
This new code looks for an image attached to the to-do item being deleted.
When an image is attached to a to-do item, that image is first deleted from the storage bucket.
Then, the actual to-do item is deleted from the to-dos table.
Run your app in the Simulator and verify that this works:
If you visit the todos
table you should notice the item you deleted is no longer present:
Also, that the image attached to the to-do item was deleted from the storage bucket:
After verifying that your new code operates as intended, please commit and push your work with this message:
Ensure that items are deleted from the storage bucket as well as the to-do database table.
Add a detail view
Finally, it would be ideal if the app had a detail view, so that users could review the provided image without having to peer at a very tiny thumbnail, like this:
To get started, please create a new SwiftUI View named ItemDetailView
, like this:
Now copy this code into your computer’s clipboard:
import SwiftUI
struct ItemDetailView: View {
// Holds a reference to the current to-do item
@Binding var currentItem: TodoItem
// Holds the image for this to-do item
@State var currentItemImage: TodoItemImage?
// Access the view model through the environment
@Environment(TodoListViewModel.self) var viewModel
var body: some View {
ScrollViewReader { scrollView in
ScrollView {
// When an image has been downloaded, show it
if let currentItemImage = currentItemImage {
currentItemImage.image
.resizable()
.scaledToFill()
} else {
// While waiting for the image to download
// show a progress indicator
ProgressView()
}
Label(
title: {
TextField("", text: $currentItem.title, axis: .vertical)
.onSubmit {
viewModel.update(todo: currentItem)
}
.onTapGesture {
// If the user chooses to update
// this to-do item, and the image
// is tall, ensure the scroll
// view scrolls down to show
// this part of the user
// interface
withAnimation {
scrollView.scrollTo(1)
}
}
}, icon: {
Image(systemName: currentItem.done == true ? "checkmark.circle" : "circle")
// Tap to mark as done
.onTapGesture {
currentItem.done.toggle()
viewModel.update(todo: currentItem)
}
.font(.title2)
.foregroundStyle(.tint)
}
)
.padding()
// Anchor to draw the focus down to this part of the scroll view
Color.clear
.frame(height: 10)
.id(1)
}
}
// Don't leave space for a navigation title
.navigationBarTitleDisplayMode(.inline)
// Load the image for this to-do item, if one exists
.task {
if let todoItemImageURL = currentItem.imageURL, todoItemImageURL.isEmpty == false {
do {
currentItemImage = try await viewModel.downloadTodoItemImage(fromPath: todoItemImageURL)
} catch {
debugPrint(error)
}
}
}
// Add a button to allow for deletion of the to-do item
.toolbar {
ToolbarItem(placement: .automatic) {
Button("Delete", role: .destructive) {
viewModel.delete(currentItem)
}
.foregroundStyle(.red)
}
}
}
}
#Preview {
List {
ItemDetailView(currentItem: .constant(firstItem))
ItemDetailView(currentItem: .constant(secondItem))
}
}
Then paste it into the new file, like this:
Please briefly review the comments in this file to understand how it works.
The ScrollView
is necessary because some images will be tall, and require scrolling to move down and see the entire image and the to-do item text below the image.
Now, finally, we need to adjust LandingView
so that it shows a NavigationLink
to the detail page – but only when a to-do item has an image attached to it.
Switch to LandingView
and locate the section of code that produces the scrollable list:
Highlight the code that invokes the ItemView
helper to show an individual to-do item:
Then copy this code into your computer’s clipboard:
// Is there an image attached to the to-do item?
if todo.imageURL == nil {
// If no, just show the text of the to-do item
ItemView(currentItem: $todo)
// Delete item
.swipeActions {
Button(
"Delete",
role: .destructive,
action: {
viewModel.delete(todo)
}
)
}
} else {
// If yes, show a navigation
// link that leads to the detail view
NavigationLink(destination: {
ItemDetailView(currentItem: $todo)
}, label: {
ItemView(currentItem: $todo)
// Delete item
.swipeActions {
Button(
"Delete",
role: .destructive,
action: {
viewModel.delete(todo)
}
)
}
})
}
… and paste it into LandingView
, replacing the highlighted text, like this:
Review the newly added code.
It is a selection statement that looks to see whether an image URL exists for the given to-do item.
When there is not an image URL for the to-do item – when that property is nil
– then there is no image, so the regular ItemView
helper is shown.
When there is an image attached to the to-do item, we create a NavigationLink
that leads to the detail view. The label for the navigation link is ItemView
.
NOTE
There is a bit of repeated code here – the code that shows the
ItemView
. If we wanted to strictly adhere to D.R.Y. – to not repeat ourselves – we might add a small helper view here. To keep this tutorial from getting any longer, that step will be omitted at this time.
Try out the app. You should now be able to create a to-do item, attach an image, and then navigate down to the detail view:
This our final edit, and lots of great progress. Please commit and push your work with this message:
Added a detail view for to-do items that have an image.
Well done for getting to this point! 🎉
Please make a post in your portfolio to document your progress and highlight key ideas that you have learned.