-2

I'm playing around with MVVM and have trouble wrapping my head around how to work with selecting a single element.

Following online examples I've written something basic that fetches a list of data and displays it.

struct NoteListView: View {
    @State private var model = NoteModel()
    
    var body: some View {
        NavigationStack {
            List(model.notes) { note in
                NavigationLink {
                    NoteDetailView(note: note)
                } label: {
                    Text(note.title)
                }
            }
            .task {
                model.fetchNotes()
            }
        }
    }
}

struct NoteDetailView: View {
    let note: Note
    
    var body: some View {
        Text(note.title)
            .font(.title)
        Text(note.content)
    }
}

@Observable
class NoteModel {
    var notes: [Note] = []
    
    func fetchNotes() {
        self.notes = [
            Note(title: "First note", content: "Test note"),
            Note(title: "Reminder", content: "Don't forget to water the plants!"),
            Note(title: "Shopping list", content: "Eggs, milk, hat, bread")
        ]
    }
}

struct Note: Identifiable, Hashable {
    let id = UUID()
    var title: String
    var content: String
}

Now I want to update a note on the detail view. Updating involves a PUT request to the server with the server calculating data for the update, so after the request is successful the new version of the note needs to be fetched. I can't seem to figure out how to write this. I think the model would look something like this.

@Observable
class NoteModel {
    var notes: [Note] = []
    var selectedNote: Note?
    
    func fetchNotes() {
        self.notes = [
            Note(title: "First note", content: "Test note"),
            Note(title: "Reminder", content: "Don't forget to water the plants!"),
            Note(title: "Shopping list", content: "Eggs, milk, hat, bread")
        ]
    }

    func fetchNote(title: String) {
        // fetch a single note, probably used after updating a note to display the updated note on the details view
        self.selectedNote = APIClient.fetchNote()
    }
    
    func updateNote(title: String, content: String) {
        self.selectedNote?.title = title
        self.selectedNote?.content = content
        // makes a PUT request to update the note, after which the note needs to be fetched
    }
}

But I don't know how to pass the selected note to the details view and how to update the note displayed on the details view and on the list view. I imagine that this is a fairly basic scenario for MVVM, but I couldn't find any examples illustrating the basic conventions on how to do this.

Update
Based on the answer from @jiseong-lim I've changed my code a bit and gotten a working version. In the below code the Update button changes the title and text of the note on the detail view. Navigating back resets the notes list because the .task {} modifier triggers again, 'fetching' a hardcoded list.
This works, but it feels clunky to have to pass a specific note and the model.

struct NoteListView: View {
    @State private var model = NoteModel()
    
    var body: some View {
        NavigationStack {
            List($model.notes) { $note in
                NavigationLink {
                    NoteDetailView(note: $note, model: model)
                } label: {
                    Text(note.title)
                }
            }
            .task {
                model.fetchNotes()
            }
        }
    }
}

struct NoteDetailView: View {
    @Binding var note: Note
    var model: NoteModel
    
    var body: some View {
        Text(note.title)
            .font(.title)
        Text(note.content)
        
        Button("Update") {
            let updatedNote = Note(id: note.id, title: "\(note.title) (updated)", content: "Updated text!")
            model.updateNote(note: updatedNote)
        }
    }
}

@Observable
class NoteModel {
    var notes: [Note] = []
    var selectedNote: Note?
    
    func fetchNotes() {
        print("Fetching notes...")
        self.notes = [
            Note(title: "First note", content: "Test note"),
            Note(title: "Reminder", content: "Don't forget to water the plants!"),
            Note(title: "Shopping list", content: "Eggs, milk, hat, bread")
        ]
    }
    
    func updateNote(note: Note) {
        if let index = notes.firstIndex(where: { $0.id == note.id }) {
            notes[index] = note
        }
    }
}

struct Note: Identifiable, Hashable {
    let id: UUID
    var title: String
    var content: String
    
    init(id: UUID = UUID(), title: String, content: String) {
        self.id = id
        self.title = title
        self.content = content
    }
}
5
  • Is your question about how to do a PUT request to the server..., or how to pass the model to the NoteDetailView? In the latter case use @Environment(NoteModel.self) var model, from this you get access to all its functions and the selectedNote in your NoteDetailView. See also Apple official documentation Managing model data in your app for how to use and pass @Observable class models. Commented Aug 16 at 23:50
  • 1
    You really don’t need mvvm with SwiftUI, see for instance: developer.apple.com/videos/play/wwdc2019/226 Commented Aug 17 at 7:10
  • @workingdogsupportUkraine my question is how I can set the selectedNote of the model when a user taps a NavigationLink and how I can update the selectedNote and notes fields of the model when a user updates a note through the details view. Commented Aug 18 at 6:58
  • doesn't the link I gave you Managing model data in your app show you how to do that? Have you tried that approach with your code? Commented Aug 18 at 7:17
  • There they use @Bindable var book = book in the List for a selected item. I've tried just assigning it to the variable of the model, model.selectedNote = note, as that is an observable field but that doesn't work. Commented Aug 18 at 8:08

2 Answers 2

1
struct NoteListView: View {
  @State private var noteModel: NoteModel

    var body: some View {
        NavigationStack {
            List {
                ForEach($model.notes) { $note in 
                    NavigationLink(note.title) {
                        NoteDetailView(note: $note, model: model)
                    }
                }
            }
            .navigationTitle("Notes")
            .onAppear {
                viewModel.fetchNotes()
            }
        }
    }
}

@Observable
class NoteModel {
    var notes: [Note] = []
    
    func fetchNotes() {
        self.notes = [
            Note(title: "First note", content: "Test note"),
            Note(title: "Reminder", content: "Don't forget to water the plants!"),
            Note(title: "Shopping list", content: "Eggs, milk, hat, bread")
        ]
    }

    func update(_ note: Note) async throws {
        // notes = try await API.update(note)
    }
}
struct NoteDetailView: View {
    @Binding var note: Note
    
    var body: some View {
        Text(note.title)
            .font(.title)
        Text(note.content)
    }
}
Sign up to request clarification or add additional context in comments.

1 Comment

As it’s currently written, your answer is unclear. Please edit to add additional details that will help others understand how this addresses the question asked. You can find more information on how to write good answers in the help center.
0

One way and probably the cleanest way is to delegate the entirety of the task to the model as an intent.

struct NoteListView: View {
    @State private var model = NoteModel()

    var body: some View {
        NavigationStack {
            List(model.notes, selection: $model.selectedNote) { note in
                Text(note.title)
                    .onTapGesture {
                        //Delegate responsibility to the model.
                        //The model can now alter the selection causing navigation destination to destroy and create a view.
                        //It can also handle updating the data etc.
                        //Note: The model should make sure another selection wasn't made during transmission round trip.
                        model.select(note: note)
                    }
            }
            .navigationDestination(item: $model.selectedNote) { note in
                NoteDetailView(note: note)
            }
            .task { model.fetchNotes() }
        }
    }
}

struct NoteDetailView: View {
    let note: Note //Here is the underlying issue. Note is not changing because it is a let. So regardless of what model.selectedNote is NoteDetailView has nothing to watch as Note is a struct. so the one on model is destroyed, but struct is copied unlike a class so only the model copy is destroyed.

    var body: some View {
        VStack {
            Text(note.title)
                .font(.title)
            Text(note.content)
        }
    }
}

@Observable
class NoteModel {
    var notes: [Note] = []
    var selectedNote: Note?

    func fetchNotes() {
        self.notes = [
            Note(title: "First note", content: "Test note"),
            Note(title: "Reminder", content: "Don't forget to water the plants!"),
            Note(title: "Shopping list", content: "Eggs, milk, hat, bread")
        ]
    }
    
    //Pretend we are calling a REST endpoint or something.
    func fetch(note: Note) async -> Note {
        return await Task {
            sleep(1)
            return Note(title: "Some Title", content: "Some Content") //awaited task (simulated)
        }.value
    }

    func select(note: Note) {

        selectedNote = note

        Task {
            //NOTE: Here might be a good time to evaluate whether something has changed.
            //Did the user make a new selection while we were making a trip to the internet?
            let updatedNote = await fetch(note: note)
            let index = self.notes.firstIndex(where: {$0.id == note.id })!

            self.notes[index] = updatedNote
            self.selectedNote = updatedNote
        }
    }
}


struct Note: Identifiable, Hashable {
    let id = UUID()
    var title: String
    var content: String
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.