WWDC22 SALE: Save 50% on all my Swift books and bundles! >>

SOLVED: Day 73 Project 14: Advice on third challenge EditView-ModelView?

Forums > 100 Days of SwiftUI

Hi everyone,

I'm currently on last day of project 14: https://www.hackingwithswift.com/books/ios-swiftui/bucket-list-wrap-up

With the latest challenge (for 2021-2022) being as follow:

Create another view model, this time for EditView. What you put in the view model is down to you, but I would recommend leaving dismiss and onSave in the view itself – the former uses the environment, which can only be read by the view, and the latter doesn’t really add anything when moved into the model. Tip: That last challenge will require you to make StateObject instance in your EditView initializer – remember to use an underscore with the property name!

Here is how I've modified my EditView:

struct EditView: View {
    @Environment(\.dismiss) var dismiss
    var onSave: (Location) -> Void

    @StateObject private var viewModel: ViewModel

    var body: some View {
        NavigationView {
            Form {
                Section {
                    TextField("Place name", text: $viewModel.name)
                    TextField("Description", text: $viewModel.description)
                }

                Section("Nearby...") {
                    switch viewModel.loadingState {
                    case .loading:
                        Text("Loading...")
                    case .loaded:
                        ForEach(viewModel.pages, id: \.pageid) { page in
                            Text(page.title)
                                .font(.headline)
                            + Text(": ") +
                            Text(page.description)
                                .italic()
                        }
                    case .failed:
                        Text("Please try again later.")
                    }
                }
            }
            .navigationTitle("Place details")
            .toolbar {
                Button("Save") {
                    var newLocation = viewModel.location
                    newLocation.id = UUID()
                    newLocation.name = viewModel.name
                    newLocation.description = viewModel.description

                    onSave(newLocation)
                    dismiss()
                }
            }
            .task {
                await viewModel.fetchNearbyPlaces()
            }
        }
    }

    init(location: Location, onSave: @escaping (Location) -> Void) {
        _viewModel = StateObject(wrappedValue: ViewModel(location: location))

        self.onSave = onSave
    }
}

And my added EditView-ViewModel:

extension EditView {
    @MainActor class ViewModel: ObservableObject {
        var location: Location

        @Published var loadingState: LoadingState = .loading
        @Published var pages: [Page] = []

        @Published var name: String
        @Published var description: String

        init(location: Location) {
            self.location = location

            _name = Published(initialValue: location.name)
            _description = Published(initialValue: location.description)
        }

        func fetchNearbyPlaces() async {
            let urlString = "https://en.wikipedia.org/w/api.php?ggscoord=\(location.latitude)%7C\(location.longitude)&action=query&prop=coordinates%7Cpageimages%7Cpageterms&colimit=50&piprop=thumbnail&pithumbsize=500&pilimit=50&wbptterms=description&generator=geosearch&ggsradius=10000&ggslimit=50&format=json"

            guard let url = URL(string: urlString) else {
                print("Bad URL: \(urlString)")
                return
            }

            do {
                let (data, _) = try await URLSession.shared.data(from: url)

                let items = try JSONDecoder().decode(Result.self, from: data)
                pages = items.query.pages.values.sorted()
                loadingState = .loaded
            } catch {
                loadingState = .failed
            }
        }

        // MARK: - Wikipedia

        struct Result: Codable {
            let query: Query
        }

        struct Query: Codable {
            let pages: [Int: Page]
        }

        struct Page: Codable, Comparable {
            let pageid: Int
            let title: String
            let terms: [String: [String]]?

            var description: String {
                terms?["description"]?.first ?? "No further information"
            }

            static func <(lhs: Page, rhs: Page) -> Bool {
                lhs.title < rhs.title
            }
        }

        enum LoadingState {
            case loading, loaded, failed
        }
    }
}

It works but... I'm not sure it's the best practice? Anyone has an advice on this? Particularly about those lines (I'm not sure if it's supposed to be done like that):

_viewModel = StateObject(wrappedValue: ViewModel(location: location))
_name = Published(initialValue: location.name)
_description = Published(initialValue: location.description)

Thanks in advance for any feedback!

   

Bump, if anyone has a proposition here :)

   

I did not look at this too closely. But here's a very high level comment.

I remember from view and viewmodel lessons that Views should be very streamlined and simple. They should not have to do much thinking. Views should not know about the internals of your business. Here's an example from your code:

// This button is in your EditView.
Button("Save") {
            var newLocation = viewModel.location
            newLocation.id = UUID()  // Ugh! I am a view. I should not know that you need a UUID()
            newLocation.name = viewModel.name  // I am a view. I send you the data, YOU put it where it belongs.
            newLocation.description = viewModel.description  // I don't need to know this.

            onSave(newLocation)
            dismiss()
}

Consider training yourself to think, I am a Save button. What are my intentions?

Answer: I intend to save a new location. I am a View, and this is not my job. I need to send the form data to the view's model. The ViewModel is the best place to validate a location, and save it to CoreData.

I think it's Paul Hegerty (CS193p) who says ViewModels should contain Intents. (minute 19:18)

See: CS 193p Lesson 4 What do you intend for your model to do? (19:18) Prof Hegerty is a fan of having models be completely private. He only exposes model data via the viewmodel's vars and funcs. (7:41)

These words stuck with me: "This is what the view code should look like. Ask the ViewModel to express the user's intent."

You intend for the view model to save this new location. You are changing your model. Consider only allowing the viewmodel to do this via a function.

Consider:

// The button describes your intentions. Place the logic in your view model.
Button("Save") {
     myViewModel.saveNewLocation( name: nameFromTextField, description: descriptionFromTextField ) 
     // your view should not need to know a location needs a UUID. The view should not have to do this work.
     // Create the UUID inside your view's model.
     dismiss()
}

Not sure if this is helpful observation? Interesting adding the ViewModel as an extension. So you've made great progress. But you asked for any feedback. So, voila!

1      

This initializer:

init(location: Location) {
  self.location = location

  _name = Published(initialValue: location.name)
  _description = Published(initialValue: location.description)
}

can be written as this:

init(location: Location) {
  name = location.name
  description = location.description
  self.location = location
} 

That's how Paul does it in his solution.

1      

Hacking with Swift is sponsored by Emerge

SPONSORED Optimize your app’s startup time, binary size, and overall performance using Emerge’s advanced app optimization and monitoring tools. Reliably measure app size, speed up your app's startup time with Emerge's Launch Booster, and much more. Emerge is actively used by many of the top mobile development teams in the world.

Find out more

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.