Tutorial: Chatter SwiftUI

Cover Page

This tutorial can be completed on the iOS simulator.

Preparing your GitHub repo

:point_right: Go to the GitHub website to confirm that your folder structure follows this outline:

  reactive
    |-- chatter
        |-- swiftUIChatter
            |-- swiftUIChatter.xcodeproj
            |-- swiftUIChatter
    |-- chatterd
    |-- chatterd.crt

YOUR*TUTORIALS folder on your laptop should contain the llmprompt.zip file in addition.

If the folders in your GitHub repo does not have the above structure, we will not be able to grade your assignment and you will get a ZERO.

Chatter app

Chatt

When our tutorial app is used to interact with Ollama, the name field of a chatt is mostly used to hold the LLM model name. But when the app is used for human-to-human chat, the name field carries user names and the message field carries user’s messages. When used for human-to-human chatting, a chatt only needs to be observable within SwiftUI and we can define chatt as a struct to take advantage of compiler-synthesized intializers. Replace the @Observable final class definition for Chatt in Chatt.swift with:

struct Chatt: Identifiable {
    var name: String?
    var message: String?
    var id: UUID?
    var timestamp: String?
    
    // so that we don't need to compare every property for equality
    static func ==(lhs: Chatt, rhs: Chatt) -> Bool {
        lhs.id == rhs.id
    }
}

As before, we continue to declare the Chatt struct as conforming to the Identifiable protocol: it has an id property that SwiftUI can use to uniquely identify each instance in a list. We similarly use a randomly generated UUID to identify each chatt and continue to provide a == operator to equate two instances as long as they have the same id.

ChattStore

We now tackle networking for the app, but first add:

import Synchronization

to your ChattStore.swift; then add the following properties near the top of your ChattStore class, right below the chatts array declaration:

    private let nFields = Mirror(reflecting: Chatt()).children.count
    private let mutex = Mutex(false)
    private var isRetrieving = false

Mirror(reflecting: Chatt()).children.count uses introspection to obtain the number of properties in the Chatt type. We store the result in the variable nFields for later validation use.

Remove the llmPrompt(_:errMsg:) function. We don’t need it in this tutorial and it is incompatible with our redefinition of the Chatt type.

Now add the postChatt(_:errMsg:) method inside the ChattStore singleton:

    func postChatt(_ chatt: Chatt, errMsg: Binding<String>) async {
        
        guard let apiUrl = URL(string: "\(serverUrl)/postchatt") else {
            errMsg.wrappedValue = "postChatt: Bad URL"
            return
        }        
        let chattObj = ["name": chatt.name, "message": chatt.message]
        guard let requestBody = try? JSONSerialization.data(withJSONObject: chattObj) else {
            errMsg.wrappedValue = "postChatt: JSONSerialization error"
            return
        }
        
        var request = URLRequest(url: apiUrl)
        request.httpMethod = "POST"
        request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
        request.httpBody = requestBody
        
        do {
            let (_, response) = try await URLSession.shared.data(for: request)
            
            if let http = response as? HTTPURLResponse, http.statusCode != 200 {
                errMsg.wrappedValue = "postChatt: \(http.statusCode)\n\(apiUrl)\n\(HTTPURLResponse.localizedString(forStatusCode: http.statusCode))"
            }
        } catch {
            errMsg.wrappedValue = "postChatt: POSTing failed \(error)"
        }
    }
  

We post a chatt to chatterd similar to how we posted an Ollama prompt in the previous tutorial. Unlike the llmprompt API, however, the postchatt API is not expected to return anything on successful post.

Next add the getChatts(errMsg:) method to the class:

    func getChatts(errMsg: Binding<String>) async {
        // only one outstanding retrieval
        let inProgress = mutex.withLock { _ in
            guard !self.isRetrieving else {
                return true
            }
            self.isRetrieving = true
            return false
        }
        if inProgress { return }
        defer {
            mutex.withLock { _ in
                self.isRetrieving = false
            }
        }

        // do the get
    }
  

To prevent multiple outstanding getChatts(errMsg:), we let getChatts(errMsg:) proceed only if there were no outstanding getChatts(errMsg:) presently retrieving from the back end. To ensure thread safety, we allow only mutually exclusive access to the isRetrieving variable. If there were no other outstanding getChatts(errMsg:) running, we proceed and then, before leaving the function, we signal to other future getChatts(errMsg:) that we’re done with this retrieval so that they can perform their own retrieval. Replace // do the get comment with:

        guard let apiUrl = URL(string: "\(serverUrl)/getchatts") else {
            errMsg.wrappedValue = "getChatts: Bad URL"
            return
        }
        var request = URLRequest(url: apiUrl)
        request.httpMethod = "GET"
        request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Accept")
        
        do {
            let (data, response) = try await URLSession.shared.data(for: request)
            
            if let http = response as? HTTPURLResponse, http.statusCode != 200 {
                errMsg.wrappedValue = "getChatts: \(http.statusCode) \(HTTPURLResponse.localizedString(forStatusCode: http.statusCode))\n\(apiUrl)"
                return
            }

            // unpack the data

        } catch {
            errMsg.wrappedValue = "getChatts: Failed GET request \(error)"
        }            

The above is again similar to how we received a response from the back end in the llmprompt(_:errMsg:), though now we unpack the received data differently. Replace // unpack the data line with the following:

            guard let chattsReceived = try? JSONSerialization.jsonObject(with: data) as? [[String?]] else {
                errMsg.wrappedValue = "getChatts: failed JSON deserialization"
                return
            }
                
            chatts = [Chatt]()
            for chattEntry in chattsReceived {
                if chattEntry.count == self.nFields {
                    chatts.append(Chatt(name: chattEntry[0],
                                         message: chattEntry[1],
                                         id: UUID(uuidString: chattEntry[2] ?? ""),
                                         timestamp: chattEntry[3]))
                } else {
                    errMsg.wrappedValue = "getChatts: Received unexpected number of fields: \(chattEntry.count) instead of \(self.nFields)."
                }
            }

The server will return the chatts as a JSON Array. JSON (de)coding libraries such as Swift Codable, which we used in the previous tutorial, work well with JSON objects ("key": "value" pair), but requires manually programming a recursive descent parser to deserialize an array of unkeyed values, as used in the present tutorial, which turns out to be more complicated than the manual decoding we have done here.

The UI

Now we update the app’s UI.

ChattViewModel

Edit the ChatViewModel in file swiftUIChatterApp.swift and update the onTrailingEnd property to your uniqname (replace DUMMY_UNIQNAME with your uniqname):

    let onTrailingEnd = "DUMMY_UNIQNAME"

SubmitButton

In ContentView.swift, we update our SubmitButton: search for the call to llmPrompt(_:errMsg:) and replace it with a call to postChatt(_:errMsg):

                await ChattStore.shared.postChatt(Chatt(name: vm.onTrailingEnd, message: vm.message), errMsg: Bindable(vm).errMsg)
                if vm.errMsg.isEmpty { await ChattStore.shared.getChatts(errMsg: Bindable(vm).errMsg) }

After the user successfully posted a chatt, we automatically call getChatts(errMsg:) to refresh our chatts array (and have it displayed, with the display positioned at the last posted chatt).

ContentView

To support human-to-human chat, we add two features to ContentView():

  1. when the app starts, we automatically retrieve all existing chatts and display them on the timeline, scrolled to the last posted chatt, and

  2. since our back end doesn’t push chatts posted by others, we allow user to perform pull-to-refresh to run another getChatts().

First add the following modifier after the call to ChattScrollView(), below its .onAppear { } modifier:

                    .refreshable {
                        await ChattStore.shared.getChatts(errMsg: Bindable(vm).errMsg)
                        Task (priority: .userInitiated) {
                            withAnimation {
                                scrollProxy?.scrollTo(ChattStore.shared.chatts.last?.id, anchor: .bottom)
                            }
                        }
                    }
                  

Note that refreshable { } in this case doesn’t actually refresh the view, it calls getChatts(_:errMsg:) which refreshes the chatts array. SwiftUI then re-renders the view given the updated chatts array, then move the display to show the last posted chatt. In this way, refreshable { } refreshes our chatts array with postings from other users.

To automatically retrieve chatts from the back end and scroll to the last posted chatt when ContentView() appears, add the following modifier to the VStack in ContentView(), for example right after the .navigationBarTitleDisplayMode(.inline) modifier:

        .task (priority: .background) {
            await ChattStore.shared.getChatts(errMsg: Bindable(vm).errMsg)
            vm.showError = !vm.errMsg.isEmpty
            Task (priority: .userInitiated) {
                withAnimation {
                    scrollProxy?.scrollTo(ChattStore.shared.chatts.last?.id, anchor: .bottom)
                }
            }
        }

Update the navigationTitle to show "Chatter" as the title and replace "LLM Error" in .alert() with "Error".

Congratulations! You’re done with the front end! (Don’t forget to work on the back end!)

Run and test to verify and debug

You should now be able to run your front end against the provided back end on mada.eecs.umich.edu, by changing the serverUrl property in your ChattStore to mada.eecs.umich.edu. Once you have your back end setup, change serverUrl back to YOUR_SERVER_IP. You will not get full credit if your front end is not set up to work with your back end!

Front-end submission guidelines

We will only grade files committed to the main branch. If you use multiple branches, please merge them all to the main branch for submission.

Push your front-end code to the same GitHub repo you’ve submitted your back-end code:

:point_right: Go to the GitHub website to confirm that your front-end files have been uploaded to your GitHub repo under the folder chatter. Confirm that your repo has a folder structure outline similar to the following. If your folder structure is not as outlined, our script will not pick up your submission and, further, you may have problems getting started on latter tutorials. There could be other files or folders in your local folder not listed below, don’t delete them. As long as you have installed the course .gitignore as per the instructions in Preparing GitHub for Reactive Tutorials, only files needed for grading will be pushed to GitHub.

  reactive
    |-- chatter
        |-- swiftUIChatter
            |-- swiftUIChatter.xcodeproj
            |-- swiftUIChatter
    |-- chatterd
    |-- chatterd.crt

YOUR*TUTORIALS folder on your laptop should contain the llmprompt.zip file in addition.

Verify that your Git repo is set up correctly: on your laptop, grab a new clone of your repo and build and run your submission to make sure that it works. You will get ZERO point if your tutorial doesn’t build, run, or open.

IMPORTANT: If you work in a team, put your team mate’s name and uniqname in your repo’s README.md (click the pencil icon at the upper right corner of the README.md box on your git repo) so that we’d know. Otherwise, we could mistakenly think that you were cheating and accidentally report you to the Honor Council, which would be a hassle to undo. You don’t need a README.md if you work by yourself.

Review your information on the Tutorial and Project Links sheet. If you’ve changed your teaming arrangement from previous tutorial’s, please update your entry. If you’re using a different GitHub repo from previous tutorial’s, invite eecsreactive@umich.edu to your new GitHub repo and update your entry.

References

General iOS and Swift

Getting Started with SwiftUI

SwiftUI at WWDC

SwiftUI Programming

State Management

Networking

Working with JSON


Prepared by Ollie Elmgren, Tiberiu Vilcu, Nowrin Mohamed, and Sugih Jamin Last updated: January 10th, 2026