Can ChatGPT create an iOS app in SwiftUI - part 2
The initial Task Manager app created with code from ChatGPT did not quite work correctly. In this article, I work with ChatGPT to resolve the issues and then add the ability to persist the tasks when the app is closed.
It is worth noting that this experiment was started with ChatGPT version 3.5, but by the time part-1 was published, a newer version was released. ChatGPT version 4.0 (GPT-4) was released on March 14th and is publicly available in a limited form to ChatGPT Plus subscribers. I suspect this may be the nature of working with ChatGPT with newer versions coming at an ever increasing rate. I have started playing around with GPT-4, but I'll leave that for another day.
Related articles on ChatGPT and SwiftUI:
- Can ChatGPT create an iOS app in SwiftUI - part 1
- Can ChatGPT create an iOS app in SwiftUI - part 2
- Create a circular slider in SwiftUI with ChatGPT
Issues with editing tasks in the Task Manager App
In part 1 ChatGPT had created the bones of a Task Manager app, albeit with one
small defect, in that the app did not save the edits to the tasks. The Edit
button
appeared to allow the user to edit any and all of the tasks, but when the done button
was selected, the edits were not displayed and the tasks seemed to revert to their
original content.
Digging into the code that was created shows a couple of errors:
- The code in EditableTaskView creates a local copy of the dueDate and does not set the selected value back to the Task dueDate.
- Changes made in EditableTaskView for Title and Status are saved to the TaskViewModel but these are not shown in the main list by TaskView.
There is a relatively easy fix for both of these issues using the relatively new element binding syntax introduced in 2021 with Xcode 13.
It is not clear how to get ChatGPT to create this code. Neither is it clear how I could progress with ChatGPT with further enhancements if I manually make these code changes. How should I inform ChatGPT that the code has been modified?
The following are a number of interactions with ChatGPT to try and get the app working. One great advantage with ChatGPT is that you can ask it to Regenerate a response over and over again.
First prompt to fix the problem
done button in ContentView does not display the edits to the tasks
Response from chatGPT
It's likely that the updateTask method in the TaskListViewModel is not properly updating the task with the edited values.
When the user taps the "Done" button in the EditTaskView, the updateTask method is called with the edited Task instance. This method should then find the corresponding task in the tasks array and update its properties with the new values...
The update code suggested by ChatGPT includes use of TaskListViewModel
, but there
is no TaskListViewModel in the code. The code seems to be referring to a variation of
TaskViewModel
, but would require further refactoring to get it to work.
Second prompt to fix the problem
EditableTaskView does not save task changes back to the ViewModel when done
button in ContentView is selected
Response from chatGPT
To make the EditableTaskView save task changes back to the ViewModel when the "Done" button in the ContentView is selected, you can add a completion handler to the EditableTaskView and call it when the user taps the "Done" button. Here's an updated version of the EditableTaskView:...
This solution of the code from ChatGPT also refers to taskListViewModel
in the
ContentView, which does not exist in the App code.
After some debugging into the code, I realise that my previous prompt is incorrect. The edits for title and status are saved to the ViewModel, but these changes are not displayed by the TaskView. I update the prompt to ChatGPT to the following.
Third prompt to fix the problem
EditableTaskView saves the task changes except for the date to the ViewModel when
done button in ContentView is selected. However the changes in the ViewModel are
not shown in the TaskView views
Response from chatGPT
If the changes to the Task instances in the TaskViewModel are not reflected in the TaskView views after editing a task, it's likely because the TaskView views are not observing the changes in the TaskViewModel. Here's how you can fix this issue:
Step 1: Add an id property to the TaskView struct.
TaskView.swift
1struct TaskView: View {
2 let task: Task
3 let id: UUID
4
5 var body: some View {
6 VStack(alignment: .leading) {
7 Text(task.title)
8 if let dueDate = task.dueDate {
9 Text("Due date: \(dueDate, formatter: ContentView.taskDateFormat)")
10 .font(.caption)
11 }
12 Text("Status: \(task.status.rawValue)")
13 }
14 .foregroundColor(task.completed ? .gray : .primary)
15 .opacity(task.completed ? 0.5 : 1)
16 .id(id) // Add id
17 }
18}
Step 2: Modify the ForEach loop in the ContentView to pass the id of each task to the TaskView.
ContentView.swift
1struct ContentView: View {
2 @ObservedObject var viewModel = TaskViewModel()
3
4 static let taskDateFormat: DateFormatter = {
5 let formatter = DateFormatter()
6 formatter.dateStyle = .medium
7 return formatter
8 }()
9
10 var body: some View {
11 NavigationView {
12 List {
13 ForEach(viewModel.tasks) { task in
14 if viewModel.isEditing {
15 EditableTaskView(task: $viewModel.tasks[viewModel.tasks.firstIndex(of: task)!])
16 } else {
17 TaskView(task: task, id: task.id) // Pass id to TaskView
18 }
19 }
20 .onDelete { indexSet in
21 self.viewModel.tasks.remove(atOffsets: indexSet)
22 }
23 }
24 .navigationTitle("Tasks")
25 .navigationBarItems(
26 trailing: HStack {
27 if viewModel.isEditing {
28 Button("Done") {
29 self.viewModel.isEditing.toggle()
30 }
31 } else {
32 Button(action: {
33 self.viewModel.tasks.append(Task(title: "New Task", dueDate: Date(), status: .notStarted))
34 }) {
35 Image(systemName: "plus")
36 }
37 Button("Edit") {
38 self.viewModel.isEditing.toggle()
39 }
40 }
41 }
42 )
43 }
44 }
45}
Step 3: Make sure that the TaskView is observing the Task instance using the @ObservedObject property wrapper.
TaskView.swift
1struct TaskView: View {
2 @ObservedObject var task: Task // Use @ObservedObject property wrapper instead of let
3 let id: UUID
4
5 var body: some View {
6 VStack(alignment: .leading) {
7 Text(task.title)
8 if let dueDate = task.dueDate {
9 Text("Due date: \(dueDate, formatter: ContentView.taskDateFormat)")
10 .font(.caption)
11 }
12 Text("Status: \(task.status.rawValue)")
13 }
14 .foregroundColor(task.completed ? .gray : .primary)
15 .opacity(task.completed ? 0.5 : 1)
16 .id(id)
17 }
18}
With these changes, the TaskView instances will observe changes to their associated Task instances, so any updates made to the Task instances in the TaskViewModel will be reflected in the UI.
Overall, this is not a good idea. There are two versions of TaskView
generated and
Task was defined as a Struct and not an ObservableObject class. This would
require more code changes to get the code to compile and even then, it may still not
work. Let's try and regenerate the response to see what other solutions ChatGPT can
provide.
This code does not compile.
Same prompt to fix the problem - second response
EditableTaskView saves the task changes except for the date to the ViewModel when
done button in ContentView is selected. However the changes in the ViewModel are
not shown in the TaskView views
Response from chatGPT
If I understand your question correctly, you're saying that the changes made to a Task in the EditableTaskView are not reflected in the TaskView instances that display the same task, even though the changes are correctly saved to the TaskViewModel. If that's the case, here's how you can fix it:
Step 1: Modify the TaskView to use a Binding for the Task instance, instead of a constant value.
TaskView.swift
1struct TaskView: View {
2 @Binding var task: Task // change to a binding
3
4 var body: some View {
5 VStack(alignment: .leading) {
6 Text(task.title)
7 if let dueDate = task.dueDate {
8 Text("Due date: \(dueDate, formatter: ContentView.taskDateFormat)")
9 .font(.caption)
10 }
11 Text("Status: \(task.status.rawValue)")
12 }
13 .foregroundColor(task.completed ? .gray : .primary)
14 .opacity(task.completed ? 0.5 : 1)
15 }
16}
Step 2: Update the ForEach loop in ContentView to use a Binding for each Task.
ContentView.swift
1 . . .
2
3 List {
4 ForEach($viewModel.tasks) { $task in // use a binding
5 if viewModel.isEditing {
6 EditableTaskView(task: $task)
7 } else {
8 TaskView(task: $task) // use a binding
9 }
10 }
11 .onDelete { indexSet in
12 self.viewModel.tasks.remove(atOffsets: indexSet)
13 }
14 }
15
16 . . .
Step 3: In EditableTaskView, use the Binding passed in as the task parameter directly when modifying the Task instance.
EditableTaskView.swift
1struct EditableTaskView: View {
2 @Binding var task: Task
3
4 var body: some View {
5 VStack(alignment: .leading) {
6 TextField("Title", text: $task.title)
7 if let dueDate = task.dueDate {
8 DatePicker("Due date", selection: Binding<Date>(
9 get: { dueDate },
10 set: { newValue in self.task.dueDate = newValue } // use the binding directly
11 ), displayedComponents: .date)
12 }
13 Picker("Task Status", selection: $task.status) {
14 ForEach(TaskStatus.allCases, id: \.self) { status in
15 Text(status.rawValue).tag(status)
16 }
17 }
18 .pickerStyle(SegmentedPickerStyle())
19 }
20 .foregroundColor(task.completed ? .gray : .primary)
21 .opacity(task.completed ? 0.5 : 1)
22 }
23}
With these changes, the TaskView instances will use Bindings to the Task instances in the TaskViewModel, so any changes made to a Task in the EditableTaskView will be reflected in the corresponding TaskView instances.
I have to admit - I was stunned. These are exactly the changes I had made manually to get the app to work.
Persist the Tasks for the app - 1
The tasks can be edited and new tasks can be added, but the app restores to the default three tasks whenever it is restarted. The tasks need to be persisted to make the app useful.
Prompt to persist the task list
Persist the list of tasks when the iOS app is closed using userdefaults
Response from chatGPT
Sure! Here's an example of how you could modify the TaskViewModel to persist the list of tasks in UserDefaults when the app is closed, and to load the saved tasks when the app is launched again.
Step 1: Add a UserDefaults property to TaskViewModel to hold the shared UserDefaults instance.
TaskViewModel.swift
1class TaskViewModel: ObservableObject {
2 private let userDefaults = UserDefaults.standard
3 // ...
4}
Step 2: Modify the init method of TaskViewModel to load the saved tasks from UserDefaults, if available.
TaskViewModel.swift
1class TaskViewModel: ObservableObject {
2 // ...
3
4 init() {
5 if let savedTasksData = userDefaults.data(forKey: "tasks"),
6 let savedTasks = try? JSONDecoder().decode([Task].self, from: savedTasksData) {
7 self.tasks = savedTasks
8 }
9 }
10
11 // ...
12}
In this modification, we use the data(forKey:) method of UserDefaults to retrieve the saved data for the "tasks" key, and then use JSONDecoder to decode the data as an array of Task instances. If the decoding succeeds, we set the tasks property to the decoded value.
Step 3: Modify the TaskViewModel method that saves the list of tasks to UserDefaults to be called whenever the list changes, as well as when the app is closed.
TaskViewModel.swift
1class TaskViewModel: ObservableObject {
2 // ...
3
4 func saveTasks() {
5 if let encodedTasks = try? JSONEncoder().encode(tasks) {
6 userDefaults.set(encodedTasks, forKey: "tasks")
7 }
8 }
9
10 // ...
11}
In this modification, we encode the current list of tasks using JSONEncoder, and then save the encoded data to UserDefaults using the "tasks" key.
Step 4: Add a ScenePhase property to ContentView to detect when the app is closing, and call the TaskViewModel method that saves the list of tasks.
TasksAppWithChatGptApp.swift
1@main
2struct TaskManagerApp: App {
3 @Environment(\.scenePhase) private var scenePhase
4
5 var body: some Scene {
6 WindowGroup {
7 ContentView()
8 .environmentObject(TaskViewModel())
9 }
10 .onChange(of: scenePhase) { newScenePhase in
11 if newScenePhase == .inactive || newScenePhase == .background {
12 let taskViewModel = TaskViewModel()
13 taskViewModel.saveTasks()
14 }
15 }
16 }
17}
In this modification, we use the onChange(of:) modifier to detect changes in the ScenePhase environment value, and call the TaskViewModel method that saves the list of tasks when the app is closing (i.e. when scenePhase is set to .inactive or .background).
With these modifications, the list of tasks will be saved to UserDefaults whenever the app is closed, and loaded from UserDefaults when the app is launched again.
This code does not compile with errors on encoding and decoding tasks.
Prompt to fix the encoding errors
Two errorsClass 'JSONDecoder' requires that 'Task' conform to 'Decodable'
Here, I mis-typed the prompt, but ChatGPT gave the correct response anyway.
Response from chatGPT
The error message indicates that the Task struct does not conform to the Decodable protocol, which is required in order to be able to decode it from JSON using JSONDecoder.
To fix this error, you need to make sure that Task conforms to Decodable. Here's an example of how you could modify the Task struct to conform to Decodable:
Task.swift
1struct Task: Identifiable, Equatable, Codable {
2 var id = UUID()
3 var title: String
4 var dueDate: Date?
5 var status: TaskStatus
6 var completed = false
7
8 enum CodingKeys: String, CodingKey {
9 case id, title, dueDate, status, completed
10 }
11
12 init(id: UUID = UUID(), title: String, dueDate: Date?, status: TaskStatus, completed: Bool = false) {
13 self.id = id
14 self.title = title
15 self.dueDate = dueDate
16 self.status = status
17 self.completed = completed
18 }
19
20 init(from decoder: Decoder) throws {
21 let container = try decoder.container(keyedBy: CodingKeys.self)
22 id = try container.decode(UUID.self, forKey: .id)
23 title = try container.decode(String.self, forKey: .title)
24 dueDate = try container.decodeIfPresent(Date.self, forKey: .dueDate)
25 status = try container.decode(TaskStatus.self, forKey: .status)
26 completed = try container.decode(Bool.self, forKey: .completed)
27 }
28
29 func encode(to encoder: Encoder) throws {
30 var container = encoder.container(keyedBy: CodingKeys.self)
31 try container.encode(id, forKey: .id)
32 try container.encode(title, forKey: .title)
33 try container.encodeIfPresent(dueDate, forKey: .dueDate)
34 try container.encode(status, forKey: .status)
35 try container.encode(completed, forKey: .completed)
36 }
37}
In this modified version of the Task struct, we've added conformance to Codable, which is a protocol that combines Encodable and Decodable conformance. We've also implemented the init(from:) and encode(to:) methods required by Decodable and Encodable, respectively.
With these modifications, you should be able to decode Task instances from JSON using JSONDecoder.
This appears to fix the Codable issues with Tasks, but reveals an initialisation error with TaskViewModel.
Prompt to fix the initialisation error
error in TaskViewModel - Return from initializer without initializing all stored
properties
Response from chatGPT
This error message indicates that there is a code path in the init method of TaskViewModel that doesn't initialize all the stored properties of the class.
To fix this error, you need to make sure that all stored properties of TaskViewModel are initialized in the init method. Here's an example of how you could modify the init method to ensure that all stored properties are initialized:
TaskViewModel.swift
1class TaskViewModel: ObservableObject {
2 private let userDefaults: UserDefaults
3 @Published var tasks: [Task]
4 @Published var isEditing = false
5
6 init() {
7 self.userDefaults = UserDefaults.standard
8 self.tasks = []
9
10 if let savedTasksData = userDefaults.data(forKey: "tasks"),
11 let savedTasks = try? JSONDecoder().decode([Task].self, from: savedTasksData) {
12 self.tasks = savedTasks
13 }
14 }
15
16 func saveTasks() {
17 if let encodedTasks = try? JSONEncoder().encode(tasks) {
18 userDefaults.set(encodedTasks, forKey: "tasks")
19 }
20 }
21
22 // ...
23}
In this modified version of TaskViewModel, we've added explicit initialization for both the userDefaults and tasks properties in the init method. We also removed the @State property wrapper from isEditing, since it's not used in a View.
With these modifications, the init method should initialize all stored properties of TaskViewModel, and the "Return from initializer without initializing all stored properties" error should be resolved.
Compiling the app now reveals 4 errors. Two of these errors are in
TaskView_Previews
, which ChatGPT does not know about - so I could comment out the
preview or just fix them. The errors in Task
should have really been addressed when
making Task codable. The property for Status is an enum and this should also have
been marked as codable.
So I pass this error back to ChatGPT.
Prompt to fix TaskStatus codable error
error in Task - Instance method 'decode(_:forKey:)' requires that 'TaskStatus'
conform to 'Decodable'
Response from chatGPT
The error message indicates that the TaskStatus enum does not conform to the Decodable protocol, which is required in order to be able to decode it from JSON using JSONDecoder.
To fix this error, you need to make sure that TaskStatus conforms to Decodable. Here's an example of how you could modify the TaskStatus enum to conform to Decodable:
Task.swift
1enum TaskStatus: String, Codable {
2 case notStarted = "Not Started"
3 case inProgress = "In Progress"
4 case complete = "Complete"
5}
In this modified version of TaskStatus, we've added conformance to Codable, which is a protocol that combines Encodable and Decodable conformance.
With this modification, you should be able to decode Task instances from JSON using JSONDecoder.
This code is technically wrong as it removes CaseIterable
, which is used to iterate
through the Status options. So I just add Codable.
1enum TaskStatus: String, CaseIterable, Codable {
2 case notStarted = "Not started"
3 case inProgress = "In progress"
4 case complete = "Complete"
5}
The code now compiles and runs. Tasks can be added and edited.
However, all the tasks disappear when the user switches App and the tasks are not persisted.
Persist the Tasks for the app - 2
A review of the code shows that there is a new instance of TaskViewModel
is created
in TaskManagerApp
in the onChange(of: scenePhase)
. A new instance TaskViewModel
loads the Tasks fron UserDefaults or uses an empty list. So the list id always empty.
Prompt to address the issue in TaskManagerApp
problem in TaskManagerApp - a new TaskViewModel is created in .onChange(of:
scenePhase)
Response from chatGPT
You are correct. In the onChange(of:scenePhase:) modifier in the TaskManagerApp struct, a new instance of TaskViewModel is created instead of using the existing instance.
To fix this problem, you can modify TaskManagerApp to pass the existing instance of TaskViewModel to the ContentView and use that instance throughout the app. Here's an example of how you can modify TaskManagerApp to achieve this:
View
1@main
2struct TaskManagerApp: App {
3 @Environment(\.scenePhase) private var scenePhase
4
5 let taskViewModel = TaskViewModel()
6
7 var body: some Scene {
8 WindowGroup {
9 ContentView()
10 .environmentObject(taskViewModel)
11 }
12 .onChange(of: scenePhase) { newScenePhase in
13 if newScenePhase == .inactive || newScenePhase == .background {
14 taskViewModel.saveTasks()
15 }
16 }
17 }
18}
In this modified version of TaskManagerApp, we create a single instance of TaskViewModel and pass it to ContentView using the environmentObject(_:) method. This ensures that the same instance of TaskViewModel is used throughout the app, including in the onChange(of:scenePhase:) modifier.
With this modification, you should be able to use the existing instance of TaskViewModel to save and load tasks from UserDefaults, without creating a new instance of TaskViewModel each time the app enters the background.
This does not fix the problem as it looks like ContentView is still creating a new instance of TaskViewModel rather than using the environmentObject one. So this is what we feedback to ChatGPT.
Prompt to fix ContentView to use environmentObject
it looks like ContentView is still creating a new instance of TaskViewModel
rather than using the environmentObject one
Response from chatGPT
You're right. In the ContentView struct, a new instance of TaskViewModel is created using the TaskViewModel() initializer, which creates a new instance of UserDefaults. This will prevent ContentView from accessing the same data as the rest of the app, which is stored in the shared instance of TaskViewModel.
To fix this, you should modify the ContentView struct to use the @EnvironmentObject property wrapper to access the existing instance of TaskViewModel that was passed down from TaskManagerApp. Here's an example of how you can modify the ContentView struct:
ContentView.swift
1struct ContentView: View {
2 @EnvironmentObject var viewModel: TaskViewModel
3
4 // ...
5}
In this modified version of ContentView, we've added the @EnvironmentObject property wrapper to viewModel, which tells SwiftUI to look up the existing instance of TaskViewModel that was passed down from TaskManagerApp. This ensures that ContentView uses the same instance of TaskViewModel as the rest of the app, rather than creating a new instance with its own UserDefaults.
With this modification, ContentView should be able to access the same data as the rest of the app, and any changes made to the task list should be reflected across all views.
It works. This took a bit of time and a bit of back and forth with ChatGPT that when it finally worked, I had almost forgotten how ugly it is.
There is also some rudimentary support for dark mode as well as swipe to delete.
Conclusion
As humans, we tend to personify many things and it is very easy to fall into this with ChatGPT. After all, I am asking questions and it is responding and it seems to be building on what has already been discussed in the chat. Although, there are responses that seem to imply it may have forgotten. I think it is better to think of ChatGPT as a type of autocomplete, although a very sophisticated version of autocomplete.
This took a bit more work than I thought, but ChatGPT arguably created the iOS app to manage tasks. I also had to debug and identify the root cause of issues and essentially solve them first and then use this knowledge to try and coax ChatGPT to give me the right solution. There are a number of things I would do differently next time. The first of which is to not use Tasks and instead perhaps call it a TODO list manager. I don’t think I would ever create a Struct or Class named Task because of the use of Tasks in Swift.
ChatGPT is great for a whole load of things, but it might be too early to get rid of all you software engineers just yet 😀. There are increasing quality levels that the code for any application needs to meet and I think ChatGPT is currently struggling with level 1 and 2. It is likely that tools like ChatGPT will get to levels 3 & 4, but I'm not sure on levels 5 & 6 - hang on to your designers 😀. It is also conceivable that in the future some Machine Learning functionality is baked into IDEs like Xcode freeing up the humans to focus on levels 5 and 6 of the applications.
Increasing Quality Levels
- Code that is readable and maintainable.
- Code that compiles without warnings or errors.
- App that provides the required functionality.
- Code that is performant.
- App that looks good while providing the required functionality.
- App that excites and delights the customer.
I think everyone should start using tools like ChatGPT, but don’t rely on it to get everything right or to produce the most performant code and certainly not the best-looking User Interface. Use ChatGPT to generate multiple solutions to a problem then read and study the sample code and learn from it to write your code to solve the problem. Paul Hudson has published a great video on Man VS Machine in Can ChatGPT write better SwiftUI code than you? that is well worth viewing.
To revisit the question - "Can ChatGPT create an iOS app in SwiftUI?".
My answer today is “Not Yet” and it would be interesting to revisit this again in a year.
Here is a list of all the commands in part-1 and part-2
- Part 1
- Create task management app in SwiftUI using MVVM design for iPhone
- add a due date to the task and a task status field
- Add unit tests
- change task status to be one of "not started" "in progress" or "complete”
- modify Task to conform to Equatable
- make a task editable
- Cannot convert value of type 'Binding<Date?>' to expected argument type 'Binding
' - Update unit tests
- Add unit tests for Task
- test testTaskEquality fails because task1 is not equal to task3
- test testTaskEquality still fails because the tasks have different id
- Part 2
- done button in ContentView does not display the edits to the tasks
- EditableTaskView does not save task changes back to the ViewModel when done button in ContentView is selected
- EditableTaskView saves the task changes except for the date to the ViewModel when done button in ContentView is selected. However the changes in the ViewModel are not shown in the TaskView views
- Regenerate Response
- Persist the list of tasks when the iOS app is closed using userdefaults
- Two errorsClass 'JSONDecoder' requires that 'Task' conform to 'Decodable’
- error in TaskViewModel - Return from initializer without initializing all stored properties
- error in Task - Instance method 'decode(_:forKey:)' requires that 'TaskStatus' conform to 'Decodable'
- problem in TaskManagerApp - a new TaskViewModel is created in .onChange(of: scenePhase)
- it looks like ContentView is still creating a new instance of TaskViewModel rather than using the environmentObject one
The source code for TasksAppWithChatGpt is available on GitHub.