LastPass clone - part 13: CoreData Integration in Views

lastpass redesign Jun 08, 2020

Hey guys! Previously, we finished creating the CoreData Manager which is the class that we used to make CoreData operations. In this one, we will finally integrate core data in our views. We will also do a bit of refactoring.

Preparations

Like always, you should have the source code link in your email inbox if you are subscribed, otherwise click here to subscribe and get the source code.

List

The list is the main piece of our app, so it does make sense to start with it. In the HomeView, most of the work will be done in the createPasswordsSection and createNotesSection, we will wrap the sections inside a component that will fetch data from the database and feed them to the section. Let’s create that component then:

Fetch result wrapper

Inside the Views folder, add a file named FetchResultWrapper.swift containing the following code:

import SwiftUI
import CoreData

struct FetchResultWrapper<Object, Content>: View where Object: NSManagedObject, Content: View {
    
    let content: ([Object]) -> Content
    var request: FetchRequest<Object>
    
    init(
         predicate: NSPredicate = NSPredicate(value: true),
         sortDescriptors: [NSSortDescriptor] = [],
         @ViewBuilder content: @escaping ([Object]) -> Content
     ) {
         self.content = content
         self.request = FetchRequest(
             entity: Object.entity(),
             sortDescriptors: sortDescriptors,
             predicate: predicate
         )
     }
    var body: some View {
        self.content(request.wrappedValue.map({$0}))
    }
}

That’s one complicated code block, let’s break it down:

  1. This wrapper is a generic view container where the Object is aNSManagedObject and the Content is a View.
  2. The content is a closure that will take in an array of managed object, build a view with them, (in this case it will be a Section view) and spills that View.
  3. The request will be used to collects the criteria needed to select and optionally to sort a group of managed objects held in the database.
  4. We then create an initialiser which we will use to pass in a sort descriptor and a predicate. As I mentioned earlier, the fetch request need a predicate and a sort descriptor.
  5. In the body block, we map over the request’s results, which is of type FetchedResults<Result>, to retrieve the object and convert the fetch results to an array of Object (NSManagedObject) that we then pass in the content view builder.

The above component will allow us to just wrap a list or a section and feed whatever comes from the request to the its children.

Update the home view list

We need to get an instance of the CoreDataManager class in the home. Likely for us, we’ve created a shared instance that we can just pass around

@ObservedObject var coredataManager = CoreDataManager.shared

Next, in the createPasswordsSection() and createNotesSection() methods, replace their existing content with the following:

    private func createPasswordsSection() -> some View {
        
        FetchResultWrapper(predicate: self.coredataManager.passwordPredicate, sortDescriptors: [self.coredataManager.sortDescriptor]) { (passwords: [PasswordItem]) in
            Section(header:
                SectionTitle(title: "Passwords")
            ) {
                ForEach(1..<5) { i in
                    RowItem().listRowBackground(Color.background)
                }
            }
        }        
    }

And this is in createNotesSection():

private func createNotesSection() -> some View {
        
        FetchResultWrapper(predicate: self.coredataManager.notePredicate, sortDescriptors: [self.coredataManager.sortDescriptor]) { (notes: [NoteItem]) in
            Section(header:
                SectionTitle(title: "Notes")
            ) {
                ForEach(1..<5) { i in
                    		RowItem().listRowBackground(Color.background)
                }
            }
        }
    }

What we’ve done here is just wrapping the existing section with the FetchResultWrapper, passing in the predicate and sort descriptor created in the CoreData manager. Right now, If you build and run the project, you will still see the old hard-coded values displayed on the list, that’s normal as we haven’t added anything in the database nor have we linked the NSManaged Objects from the fetch request to our views.

Now, replace the range (1..<5) inside the ForEach() with the following in the createPasswordsSection method:

passwords.map { PasswordViewModel(passwordItem: $0) } 

And this in the createNotesSection:

notes.map { NoteViewModel(noteItem: $0) } 

Rename the i in the closure block to password and note respectively.

What we’ve done above is converting the passwords and notes to an array of their view model counterparts. We still have two pass the note or password to the RowItem which we will do later, but let’s first take care of the home view.

Next, in the createList function replace the content of the list, meaning the 2 function calls with the following condition:


if self.coredataManager.showPasswords{
   createPasswordsSection()
}
            
if self .coredataManager.showNotes {
   createNotesSection()
}

Here we use the published boolean values from CoreDataManager to check whether we need to show both passwords and notes or one of them.

If you take a look at our Filter enum, you will see that we have 5 filters, and we’ve just covered the remaining 2. With that done, let’s now implement the RowItem. Open its file, and add the following to the top of the struct:

    var passwordModel: PasswordViewModel?
    var noteModel: NoteViewModel?

In order to avoid moving back and forth, let’s just replace the whole body content with the following:

 let image = passwordModel?.site.getImageName()
        return HStack{
            Image(image ?? "note")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 50, height: 50)
            
            VStack(alignment: .leading) {
                Text(passwordModel?.site ?? noteModel?.name ?? "No name")
                    .font(.system(size: 20))
                    .padding(.bottom, 5)
                Text(passwordModel?.username ?? noteModel?.createdAt.format() ?? "N/A")
                    .foregroundColor(Color.gray)
                    .font(.callout)
            }
        }.padding(.horizontal)
        .frame(maxWidth: .infinity, minHeight: 80, alignment: .leading)
            .background(Color.background)
             .cornerRadius(20)
             .neumorphic()
             .padding(.vertical)

You’ll get an error caused by the missing .format() extension method on the Date type. In the Extensions folder, add a file named DateExt.swift containing the following code inside:

import Foundation

extension Date{
    func format(_ format: String = "E, d MMM yyyy HH:mm") -> String {
           let formatter = DateFormatter()
           formatter.dateFormat = format
            formatter.locale = Locale.autoupdatingCurrent
           let component = formatter.string(from: self)
           return component
    }
}

This code just extends the Date with an helper method that you saw us use in the RowItem view. Now build the project and the error should go away.

With that in place, go back in the HomeView.swift, and replace the RowItem call in both methods with the following:

RowItem(passwordModel: password).listRowBackground(Color.background)

For the password, and this:

 RowItem(noteModel: note).listRowBackground(Color.background)

If you run the project now, you will find that all of your hard-coded data have disappeared, but you are still seeing the password and note section header texts, let’s get rid of those if passwords or notes are empty.

Inside the functions that creates those sections, only replace the sections with the following for the passwords:

 if !passwords.isEmpty {
                Section(header:
                    SectionTitle(title: "Passwords")
                ) {
                    ForEach(passwords.map { PasswordViewModel(passwordItem: $0) } ) { password in
                        RowItem(passwordModel: password).listRowBackground(Color.background)
                    }
                }
            }

And this for the notes:

            if !notes.isEmpty {
                Section(header:
                    SectionTitle(title: "Notes")
                ) {
                    ForEach(notes.map { NoteViewModel(noteItem: $0) } ) { note in
                        RowItem(noteModel: note).listRowBackground(Color.background)
                    }
                }
            }

Now run the app again, and you should see a clean screen. However, it’s would be a much better user experience to show the user that he should click the button to add new passwords or notes when there’s nothing right? I’ll leave that to you, be creative and liquid.

Only thing that’s left now is saving new passwords and notes to the database.

Persisting data to core data

In the EditFormView, add the following at the top:

    var passwordModel: PasswordViewModel?
    var noteModel: NoteViewModel?
    @EnvironmentObject var coredataManager: CoreDataManager

Next, add the following function below everything:

  
    fileprivate func saveNewPassword() {
        if self.passwordModel == nil{
            if !username.isEmpty
               && !password.isEmpty
            && !website.isEmpty {
                let password = PasswordItem(context: coredataManager.context)
                password.createdAt = Date()
                password.id = UUID()
                password.isFavorite = false
                password.lastUsed = Date()
                password.note = self.passwordNote
                password.site = self.website.lowercased()
                password.username = self.username
                password.password = self.password
                _ = coredataManager.save()
                
                withAnimation {
                    self.showDetails = false
                }
            }
        }
    }

Above we first check if inputs are valid, then proceed to creating a new password item using the context, we then set each property and save those changes using the context to persist them to the database.

Do the same thing for the note by adding this below:

    fileprivate func saveNewNote() {
        if self.noteModel == nil {
            
            if !self.noteName.isEmpty && !self.noteContent.isEmpty {
                let note = NoteItem(context: coredataManager.context)
                note.id = UUID()
                note.isFavorite = false
                note.createdAt = Date()
                note.lastUsed = Date()
                note.name = noteName
                note.content = noteContent
                _ = coredataManager.save()
                withAnimation {
                    self.showDetails = false
                }
            }
        }
    }

Now find the function that creates the save button and replace the break in each case with corresponding saveNew__ functions so that the createSaveButton becomes like this:

    fileprivate func createSaveButton() -> LCButton {
           return LCButton(text: "Save", backgroundColor: Color.accent) {
               switch self.formType{
               case .Password:
                    self.saveNewPassword()
               case .Note:
                    self.saveNewNote()
               }
           }
       }

Now run the app, add a new note or password and click save, but you will need to dismiss the sheet explicitly. The sheet should be dismissed automatically upon clicking the save button, but it doesn’t, this is my fault as I forgot to bind the showDetails binding property in the EditFormView to the showEditFormView in the HomeView, let’s do that now.

Find the createEditFormView function, and add the following in the EditFormView call:

showDetails: self.$showEditFormView

Test the app again, and now everything should work as expected.

That’s it folks! The next post will be the last for this series in which we will create a navigation drawer. Stay tuned, share this article and subscribe if you haven't done so already.

John K

I am a software developer and code enthusiast. Do you want to work with me, have a suggestion or a request? Feel free to contact me at [email protected] or https://twitter.com/liquidcoder