EdenPolis



FriendFace 2.0

Hacking with Swift

Day 61 / 100

Watch demo of the app on my Mastodon account.

Mastodon Video



The warning at the beginning of the challenge got me a bit scared.

As usual, I started by writing a plan, what steps I need to take to achieve it all. Once I have the path laid out, I can figure out details as I go.

This is part two of the project, if you want to see part one please click the button below! If you already know, where we left off keep reading.

FriendFace

Today we will add:
  • SwiftData - get the data from the server, save it locally so user can access it offline
  • Add Filtering and Sorting - I mentioned in previous post I’m planning on adding it
  • Converting Friends to Users - we can now navigate to user profile from somebody’s else’s profile, if they are fiends
  • Creating FriendsView - modified UsersView, to allow a different style of presenting the same information
  • Adding Home button - navigating back to the main screen no matter how deep you ventured into the profiles


This involved a lot of refactoring, which is always scary. You already have something that works, now you need to break it, take it apart, hoping you are able to rebuild it better.



If you want to see my process please keep reading!



Upgrading FriendFace

Show User.swift and Friend.swift



 // User.swift                       
import Foundation
import SwiftData

@Model
class User: Codable, Identifiable, Hashable {

    var id: String
    var isActive: Bool
    var name: String
    var age: Int
    var company: String
    var email: String
    var address: String
    var about: String
    var registered: Date = Date()
    var joinedDate: String {
        registered.formatted(date: .numeric, time: .omitted)
    }
    var tags: [String]
    var friends: [Friend]
    
    
    enum CodingKeys: String, CodingKey {
        case id = "id"
        case isActive = "isActive"
        case name = "name"
        case age = "age"
        case company = "company"
        case email = "email"
        case address = "address"
        case about = "about"
        case friends = "friends"
        case tags = "tags"
        case registered = "registered"
        
        
    }
    
    required init(from decoder: any Decoder) throws {
            
            
            let container = try decoder.container(keyedBy: CodingKeys.self)
            self.id = try container.decode(String.self, forKey: .id)
            self.isActive = try container.decode(Bool.self, forKey: .isActive)
            self.name = try container.decode(String.self, forKey: .name)
            self.age = try container.decode(Int.self, forKey: .age)
            self.company = try container.decode(String.self, forKey: .company)
            self.email = try container.decode(String.self, forKey: .email)
            self.address = try container.decode(String.self, forKey: .address)
            self.about = try container.decode(String.self, forKey: .about)
            self.registered = try container.decode(Date.self, forKey: .registered)
            self.tags = try container.decode([String].self, forKey: .tags)
            self.friends = try container.decode([Friend].self, forKey: .friends)
            
            
        
    }
        
        func encode(to encoder: any Encoder) throws {
            var container = encoder.container(keyedBy: CodingKeys.self)
            try container.encode(self.id, forKey: .id)
            try container.encode(self.isActive, forKey: .isActive)
            try container.encode(self.name, forKey: .name)
            try container.encode(self.age, forKey: .age)
            try container.encode(self.company, forKey: .company)
            try container.encode(self.email, forKey: .email)
            try container.encode(self.address, forKey: .address)
            try container.encode(self.about, forKey: .about)
            try container.encode(self.registered, forKey: .registered)
            try container.encode(self.tags, forKey: .tags)
            try container.encode(self.friends, forKey: .friends)
        }

}


// Friend.swift
import Foundation
import SwiftData

@Model
class Friend: Codable, Identifiable, Hashable {
    var id: String
    var name: String
    var owner: User?
    
    enum CodingKeys: String, CodingKey {
        case id
        case name
    }
    
    required init(from decoder:Decoder) throws {
       let container = try decoder.container(keyedBy: CodingKeys.self)
       self.id = try container.decode(String.self, forKey: .id)
       self.name = try container.decode(String.self, forKey: .name)
   }
   
   func encode(to encoder: any Encoder) throws {
       var container = encoder.container(keyedBy: CodingKeys.self)
       try container.encode(self.id, forKey: .id)
       try container.encode(self.name, forKey: .name)
   }
    
}


                    

From basic structs to class models

I explained last time why I thought Friend should be nested within a User structure. Models however offer another way of establishing relationships, using the property owner, here var owner: User? . If there is a User model, we want it to be an owner of Friend.

This resulted in a decision to create two files, one for the User, another for the Friend model. Each one needed to have Coding Keys, to allow for a custom encoding and decoding strategy.

Since we are dealing with classes now, the init method must be marked with the required keyword.

Once this was done, I added the .modelContainer(for: User.self) to the FriendFaceApp file along the import SwiftData. When run on first launch of the app it creates the database using the models listed. On next launches it loads the database and information in it.

You might wonder, why wasn't Friend.self added to the modelContainer. Since User is the owner of Friend, Friend is automatically added.

loadData() now saves data!

Okay, we have our models ready, the database was created, what's the next step? Saving the information from the server to permament memory on user's device.

I added a condition to check if our users property is empty, this ensures we get the data at the first opening of the app (if user has internet connection), after that they can be offline and still access all of the data.

We specify url, establish URLSession, add decoder and date decoding strategy, storing the results in a property called readUsers - all as before. Here is what needed to be added.

@Query var users: [User] we are loading all the users from the SwiftData to this screen (ContentView). @Environment(\.modelContext) var modelContext allows us to now manipulate the SwiftData, write to it.

In the loadData() function we loop over the readUsers and add each one of them to our database.

Once they are added, the array will not be empty, the users saved will be loaded every time we open the app

loadData()



// at the top of ContentView I added
    @State private var readUsers: [User] = [User]()
    @Query var users: [User]
    @Environment(\.modelContext) var modelContext

// load function that now saves!
func loadData() async {
        
        if users.isEmpty {
            // get the URL
            guard let url = URL(string: "https://www.hackingwithswift.com/samples/friendface.json") else {
                print("Invalid URL")
                return
            }
            
            // fetch the data
            do {
                let (data, _) = try await URLSession.shared.data(from: url)
                // decode data if fetched correctly
                let decoder = JSONDecoder()
                decoder.dateDecodingStrategy = .iso8601
                if let decodedUsers = try? decoder.decode([User].self, from: data) {
                    readUsers = decodedUsers
                    
                    // loading the users to memory 
                    for user in readUsers {
                        modelContext.insert(user)
                    }
                }
            } catch {
                print("Invalid data")
            }
        }
    }
                    

Filter, Sort and Init



// in ContentView
.toolbar {
                Menu("Filter", systemImage: "arrow.up.arrow.down") {
                    Text("Filter by activity:")
                    Picker("Filter", selection: $userActive) {
                        Text("All")
                            .tag("All")
                        Text("Active")
                            .tag("true")
                        Text("Inactive")
                            .tag("false")
                    }
                    Text("Order by:")
                    
                    Picker("Sort order", selection: $sortOrder) {
                        Text("Join Date")
                            .tag([SortDescriptor(\User.registered)])
                        Text("Name")
                            .tag([SortDescriptor(\User.name)])
                        Text("Age")
                            .tag([SortDescriptor(\User.age)])
                    }
                }
            }

//in UsersView 

init(userActive: String, sortOrder: [SortDescriptor]) {
        _users = Query(filter: #Predicate {user in
            if userActive == "All" {
                return true
            } else if userActive == "true" && user.isActive == true {
                return true
            } else if userActive == "false" && user.isActive == false {
                return true
            }
            else {
                return false
            }
        }, sort: sortOrder
        )}



                    

Filtering and Sorting

Since I made this app I wanted to add this functionality. I'm not sure what it is, that makes me excited about filtering, but I couldn't wait to finally implement it.

There are three parts to this process.

First, loading all of the data we want to filter/sort.
This was already taken care of with @Query var users: [User] earlier.

Second, creating properties that determine sorting and filtering (optional: allowing users to determine those values).
Filtering in my case was a string and sorting relied on an array of SortDesctiptors.
I nested Pickers inside the Menu view and used .tag to update the values based on users choice. It’s a very simple and elegant way of adding this functionality, I think it’s beautiful.
I will add my code for the toolbar, but you could change it to other values, that seemed intuitive to me.

Third, creating custom init method for this view and passing those values to it.
This part is a bit tricky, the syntax for Predicate still seems a bit weird to me and you need to trust the process.
We use _users because we are changing the SwiftData query that produces this array, not the actual users array.
Then we loop over each user and check if it matches the conditions. I think it could be done with switch statement, but I find if statements in this scenario to be clearer.
Predicate is confusing but happily, sortOrder is straight forward, we just pass the argument of SortDescriptor.

From Friend to User

As mentioned in my previous post, each Friend has an id, this id matches an instance of the User.

Currently, my app has a UserView, which displayed detailed information about a particular user. Section with Friends was simply listing them and did not have any interactivity.

With SwiftData in place, it’s easy to slightly modify UsersView copy (FriendsView), which consist of a simple grid displaying name, age and active status with an icon.

I do not need the init, we will not be adding filtering or sorting, it’s to complex for this purpose and would clutter the view with pointless buttons.
I adjusted the width and height of each element, I wanted it to fill a bit smaller than the actual full view designed to display the Users, in this context they are just friends.

In UserView I added a function that loops over each friend of the user, and each user of all users in our database. If the id matches, the user is added to an array. This array is then passed to the FriendsView to be displayed. I run this function .onAppear{} As soon as the UserView loads, we can see their friends.

checkFriends()


func checkFriends() {
        for friend in user.friends {
            for user in users {
                if friend.id == user.id {
                    userFriends.append(user)
                }
            }
        }
    }
                    
Before
After

Returning Home

Now we can go from one user, to another, and another and so on. We are deep in the down the path with no easy way to return back to main screen. That’s where we will programmatically change the path to an empty array. First, we need to access this path from UserView. Here’s how it’s defined in ContentView @State private var path = [User]().

We are binding it the path parameter in NavigationStack as well as use it in .navigationDestination when moving to UserView.

.navigationDestination(for: User.self) { user in UserView(user: user) }

As seen above, we only passed user as argument, now we need to create @Binding var path: [User] in UserView and pass it. I updated the code to the below one.

.navigationDestination(for: User.self) { user in UserView(user: user, path: $path) }

The rest is straightforward. Just add a home button that sets the path to an empty array and voilà.

.toolbar { Button("Home", systemImage: "house.fill") { path.removeAll() } }