Remember that a view should be dumb. It doesn't care what it displays. The notes view doesn't match that description. It can access the array of notes through its view model. That is a code smell and something we need to change. You learn how to do that in this episode.

A Protocol-Oriented Approach

We start with a protocol to hide the Note object behind. Create a group with name Protocols and add a Swift file with name NotePresentable.swift. Declare a protocol with name NotePresentable.

import Foundation

protocol NotePresentable {

}

The protocol defines two properties of type String, title and contents.

import Foundation

protocol NotePresentable {

    // MARK: - Properties

    var title: String { get }
    var contents: String { get }

}

Open Note.swift and conform the Note struct to the NotePresentable protocol. We don't need to make any changes to the Note struct because it automatically conforms to the NotePresentable protocol.

import Foundation

struct Note: Decodable, Identifiable, NotePresentable {

    // MARK: - Properties

    let id: Int
    let title: String
    let contents: String
    
}

Open NotesViewModel.swift. We change the type of the notes property to array of NotePresentable objects, [NotePresentable].

@Published private(set) var notes: [NotePresentable] = []

Build and run the application. The compiler doesn't seem to agree with the changes we made. The elements a List view displays need to conform to the Identifiable protocol. The Note struct conforms to the Identifiable protocol, but a NotePresentable object doesn't meet that requirement.

Hiding the Model Layer

I won't explain why the compiler throws an error. That would result in a lengthy discussion about protocols and associated types. That's a topic for another episode. We can resolve the compiler error by wrapping the Note object in a view model.

Creating Another View Model

Revisit the Note struct and remove the NotePresentable protocol from the Note declaration. That's no longer necessary.

import Foundation

struct Note: Decodable, Identifiable {

    // MARK: - Properties

    let id: Int
    let title: String
    let contents: String
    
}

Add a Swift file with name NoteViewModel.swift to the Views group. Declare a struct with name NoteViewModel that conforms to the Identifiable protocol.

import Foundation

struct NoteViewModel: Identifiable {

}

The NoteViewModel struct defines a private, constant property note of type Note. Because we declare the property privately, we don't get a memberwise initializer for free. We declare an initializer that takes a Note object as its only argument, assigning it to the note property.

import Foundation

struct NoteViewModel: Identifiable {

    // MARK: - Properties

    private let note: Note

    // MARK: - Initialization

    init(note: Note) {
        self.note = note
    }

}

To conform to the Identifiable protocol, we need to expose a property with name id. The property returns the identifier of the Note object.

import Foundation

struct NoteViewModel: Identifiable {

    // MARK: - Properties

    private let note: Note

    // MARK: -

    var id: Int {
        note.id
    }

    // MARK: - Initialization

    init(note: Note) {
        self.note = note
    }

}

Before we put the NoteViewModel struct to use, we conform NoteViewModel to the NotePresentable protocol in an extension. Conforming to NotePresentable is straightforward. The title property returns the note's title and the contents property returns the note's contents.

extension NoteViewModel: NotePresentable {

    var title: String {
        note.title
    }

    var contents: String {
        note.contents
    }

}

Revisit NotesViewModel.swift and change the type of the notes property to an array of NoteViewModel objects, [NoteViewModel].

@Published private(set) var notes: [NoteViewModel] = []

This also means we need to map the result of the fetchNotes() method to an array of NoteViewModel objects. We invoke the map(_:) method on the array of Note objects, passing in a reference to the initializer of the NoteViewModel struct.

func start() {
    Task {
        do {
            notes = try await apiService.fetchNotes()
                .map(NoteViewModel.init)
        } catch {
            print("Unable to Fetch Notes \(error)")
        }
    }
}

Finishing Touches

Before we end this episode, I would like to apply a few finishing touches to the implementation. We start by renaming the notes property of the NotesViewModel class to noteViewModels. That's a more accurate description and avoids confusion.

@Published private(set) var noteViewModels: [NoteViewModel] = []

We also update the property's name in the start() method and in the computed body property of the NotesView struct.

func start() {
    Task {
        do {
            noteViewModels = try await apiService.fetchNotes()
                .map(NoteViewModel.init)
        } catch {
            print("Unable to Fetch Notes \(error)")
        }
    }
}
var body: some View {
    NavigationView {
        List(viewModel.noteViewModels) { note in
            NoteView(
                title: note.title,
                contents: note.contents
            )
        }
        .navigationTitle("Notes")
    }
    .onAppear {
        viewModel.start()
    }
}

Open NoteView.swift and replace the title and contents properties with a property with name presentable of type NotePresentable. We also need to update the body of the computed body property.

struct NoteView: View {

    // MARK: - Properties

    let presentable: NotePresentable

    // MARK: - View

    var body: some View {
        VStack(alignment: .leading) {
            Text(presentable.title)
                .font(.title)
            Text(presentable.contents)
                .font(.body)
        }
    }

}

These changes break the preview, but the fix is easy. We declare a private, nested struct with name PreviewPresentable that conforms to NotePresentable. The PreviewPresentable struct defines a computed title property and a computed contents property to satisfy the requirements of the NotePresentable protocol. We update the initializer of the NoteView struct in the static previews property, passing in a PreviewPresentable object.

struct NoteView_Previews: PreviewProvider {

    private struct PreviewPresentable: NotePresentable {

        // MARK: - Properties

        var title: String {
            "My Note"
        }

        var contents: String {
            "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
        }

    }

    static var previews: some View {
        NoteView(presentable: PreviewPresentable())
    }

}

Revisit NotesView.swift one more time and update the initializer of the NoteView struct in the computed body property of NotesView.

var body: some View {
    NavigationView {
        List(viewModel.noteViewModels) { presentable in
            NoteView(presentable: presentable)
        }
        .navigationTitle("Notes")
    }
    .onAppear {
        viewModel.start()
    }
}

What's Next?

None of the views have direct access to the model layer and that is a welcome change. Because the view model is injected into the notes view, we can carefully control what data the preview displays and where that data comes from. The NotePresentable protocol isn't strictly necessary, but I typically choose for a protocol if a view does nothing but display data. The note view is a fine example of that.

The Notes application summarizes what the Model-View-ViewModel pattern is about and how we can extract business logic from the view and move it to the view model. We continue to apply this pattern in the remainder of this series.