Coding Workshop 7 - Bucket List App Part 3

homepage

We have our app functioning now and we can add, update and delete our bucket list items, but when we stop and start the application again, we are back to the same sample array.

What we want to accomplish now is to save our list somewhere so that when we start up again, it can reload and display them. This is called Data Persistence. In the iOS world, you can store your data into databases like SQLLite that are managed by Core Data or some other database system like Realm, However, if your needs are light, then you can store your data in a file in a structure format that you can load and populate your models from.

Before we do that though, we are going to move some properties and functionality of our views into a separate class that in the future can be used in other view should I choose to add to this application

DataStore Class

We can create a class and inject it into the environments that it can be observed by any view that chooses to access the class, its properties and functions.

  1. Create a new file and call it DataStore.

  2. Inside the file, create a new class with the same name but since the views will need to observe changes on the properties, we must make sure that the class conforms to the ObservableObject protocol.

  3. It is here where we will be maintaining our array of BucketItem and be able to update, delete etc from the array so we can create a variable called bucketList as we did in our BucketListView, but instead of it being a @State property, we make it @Publishedand initialize it as an empty array,

  4. When this class gets initialized, we will want to load the stored items and assign them to our bucketList property. Whenever a new instance of a class gets created an initializer function is run. There is no requirement to create an initializer for a class of all of the properties have values like ours does, but we can create one and then perform an action within the body of the initializer function.

    In our case we want to call a loadItems() function which we will create next.

  5. Create a function called loadItems and in the body, for now, simply assign to bucketList, the static BucketList.samples array.

EnvironmentObject and Bucket_ListApp

Now that we have that class created, when our application gets initialized (in the file that is decorated with the @Main property wrapper), we can create an instance of this new class.

  1. In Bucket_ListApp create an instance of the DataStore class as a @StateObject property. Think of @StateObjectas being equivalent for a class object to what @State is to a view property.

  2. In the WindowGroup, where we present BucketListView( we can use the .environmentObject method to inject that object into the environment so all views that want to can refer to it.

Update Views

BucketListView

BucketListView is one of those views that we want to access that environmentObject

  1. Replace bucketList state property with an EnvironmentObject. Remember that it has already been initialized and the sample items have been loaded, so in our dataStore, our bucketList is access via dataStore.bucketList. When you create an EnvironmentObject in a view struct and specify the type, which in our case is DataStore it checks to see if there is an instance of that type of object in the environment and assigns it to the variable that you specify. If it can't find it, your app will crash.

  1. The preview will complain because it cannot present our list unless it too has access to the environmentFix the preview so we can fix that by injecting a new instance right here.

  2. There are 4 other errors being displayed. Fix first 3 error references to bucketList and $bucketList to now be dataStore.bucketList or $dataStore.bucketList

  3. Remove the bucketList argument in the DetailView for the navigationDestination because we will access it from the environment. (You will still see an error here, but that is because our DetailView is still expecting us to inject the bucketList from here.)

DetailView

Open Detail view.

  1. Remove the @Binding variable for bucketList.

  2. Add EnvironmentObject for our dataStore just like we did in the BucketListView.

  3. Remove as well from the preview, the bucketList argument, and add in the environmentObject modifier.

  4. Cut out the action in the update button (except for the dismiss().

    1. replace it with a yet to be designed function in our DataStore called update().

    2. This function will have 3 arguments, bucketItem, a BucketItem type, note, a String and completionDate a Date so we will be passing in to that function the corresponding values from our view. The bucketItem that was passed in, and our new note and completedDate values.

DataStore

Update function

  1. Return now to the DataStore class and create a function in DataStore called update with the 3 parameters we created as the arguments in our call above; bucketItem, a BucketItem type, note, a String and completionDate a Date.

  2. Paste in the cut code that use to be in the button action.

Loading and storing data in DataStore

Now we can create our save and load function to save and restore our data when the app launches.

Storing information in an iOS app can be done by storing in a database like SQLite with a CoreData wrapper, or in a Realm database using Realm, or even online using CloudKit or Firebase.

However, for simple data structures like this without many demands or records, you can save your list as structured data in a file.

First we need to have a place to store the data, and a name for the file.

The best and most secure place to store this is in the application's own, protected Documents Directory Your users cannot access that file directly on their phone. Only our application can through code.

The documents directory can be accessed through the static URL property URL.documentsDirectory.

To that url, we can append a path.

  1. Create a fileURL property for the storage location and name of the file to append will be bucketList.json.

JSON

The most common format for encoding and storing data is a format called JSON. It is beyond the scope of this tutorial to go though it in a lot of detail but the process for encoding and decoding (restoring) data to and from JSON is quite easy. To enable this, we must first make sure that our object type BucketItem conforms to the Codable protocol.

  1. Conform BucketList model to the Codable protocol

  2. Now we can create a function and call it saveList()

    1. Inside this function we are going to try to encode and write the encoded data to our file. This may fail if something goes wrong (it shouldn't if we code it properly) so we need to enclose our attempt in a do..catch block.

    2. Within the do block we can create a constant property called bucketListsData that we try to encode using the JSONEncoderClass's encode method and pass to it our entire array which is bucketList.

    3. Once it has been encoded, we can covert it to a String using the String decoding our bucketListData as UTF8.self.

    4. Now that we have it as a string, we can take that string and call the strings's write method to write it to the fileURL, setting atomically to true using .utf8 encoding.

    5. Should any of that fail we might as well give up so we catch the error and execute a fatalError indicating that we could not encode and save our list.

    See my series on JSON: https://youtube.com/playlist?list=PLBn01m5Vbs4DKrm1gwIr_a-0B7yvlTZP6

Update Code

Now that we have our save function, every time you make changes to an item (add, update and delete) we can call this function.

BucketListView

  1. In BucketListView we need to do that in our button action.

  1. We also need to do this when we delete.

  1. Similarly, when we change a bucketList name value in the list view, we need to save the list once more. We can do this with an onChange method on the ForEach loop that watches for changes on the bound item. When it notices a change, we can call the saveList function once more.

    This can be called before the listRowSeparator modifier. Notice the use of the _ in place of creating a variable for the new value. Since we never use it, we can simply use an _

DetailView

We could call the function in the UpdateButton action after it calls the dataStore's update function, but why not add it to the update function itself in DataStore.

Refactoring Code

Rethinking this, we may wish to add the creation of new items and the deletion of items to other views in the future, so why don't we refactor the update and delete code that is currently in our BucketListView and move the actions in to the DataStore.

Create Function

  1. Cut out the first two lines of the action in the add button in BucketListView and replace it with a yet to be created function in our dataStore called create which will have one parameter which will be a newItem, a String. Delete the saveList function here too as we will add that to our new function in the DataStore class. You should be left with only two lines in your action now.

  2. In DataStore create the function

    By placing an _ in front of the parameter name will mean that the call site does not have to provide it. So instead of dataStore.created(newItem: "some value") it can be dataStore.create("some value")

  3. Paste in and edit code that was cut out by removing any references to dataStore since we are already in the DataStore class and make sure you add in the saveList() function.

Delete Function

  1. In BucketListView, delete both lines in the .onDelete action and replace it with a call to a delete function that we will create in our DataStore class. This will have one argument which will be an indexSet which is a type IndexSet and we can pass in the indexSet that we get when we swipe.

    Note this time I will not be using an _ in front of my function parameter

  1. In the DataStore class, create a new function called delete and provide the indexSet parameter

    1. Paste in the code cut from the delete function in BucketListView

    2. Remove both references to the dataStore.

Documents Directory

To determine and find the saved document, add a line to an onAppear function in our app entry file that will print out the path to that directory to the console when the app loads.

Test

You can test out the app now by running in the simulator.

  1. Run the app and add, update and delete a few bucket list items and make sure you perform updates to force the saveList function to be called.

  2. In the console you will find the path to the Documents directory. It will look something like this:

  3. Select the text, the right click and choose Services -> Open and when the dialog appears, click on Run Service.

    image-20230117093601070

  4. This reveals the document in the documents directory and it will be called bucketList.json.

  5. Right click on this file and open it with the TextEdit app and you will see the JSON string.

JSONImage

  1. Copy the string and open a web browser and go to . https://jsonformatter.org

  2. Paste the copied text into the left pane and then click on Format/Beautify and you will get a better representation of the JSON object.

    1. You may notice that it is an array of objects just like our array of BucketList items and each item consists of Key-Value pairs with each key representing the property name of our BucketItem struct and the values correspond to the values of our array objects. This is the beauty of JSON Codable.

  3. Run again and we are back to the original, we still need a Load Json function. This means we still have to load that saved file when we launch our app.

Loading JSON

Currently our loadItems function is loading our sample data. What we need to do is check to see if a file called bucketList.json already exists and decode it and populate our empty array that we start with.

  1. First check to see if the file exists at that path using the the FileManager().fileExists method at the path specified by fileURL.path.

  2. If it does, we try to get data from the contents of that fileURL.

    1. This might fail, so like the saveList function, we create a do...catch block.

      1. In the do block we can let data be the result when we try to get the data from the contents of that fileURL.

      2. If it does not fail, we can assign to our bucketList published property the result when with try to use the JSONDecoder()'s decode(method for the array of BucketItem type from that data.

      3. If either of these two tries failed, we can simply call the saveList() function which will create a new file at the url with our empty array and thus overwrite any corrupted file that may be there.

  1. Run once more now and your updated items are loaded,

AppIcon and empty list

  1. Delete all items in the list and see that the start view (contentView is pretty boring).

We can spruce this up quite a bit now by adding some assets.

Adding Image Assets

Right now, our app has no icon, and our totals view does not have the image we saw in the preview image.

You should have two assets available to you.

appstore1024.png

This is an image that is 1024 X 1024 and I want to use it as the App Icon

Open the assets folder in Xcode

Select the AppIcon item and drag and drop the image on to the placeholder in the center

bucketList

This is a a smaller version with rounded corners and is 256 X 256

Drag and drop it into the asset folder

2023-01-15_12-22-02

Update BucketListView

  1. Enclose the List and the .listStyle modifier in an if -else clause that check to see if the dataSore. bucketList is not empty.

    1. If it isn't we present our list.

    2. If it is, we can create a new Text view, presenting the String "Add your First BucketList item".

      1. Follow this with a Image using the asset "bucketList".

      2. Add a Spacer() to push it all up to the top of the screen.

2023-01-15_12-21-23

Back to Project Index