0

I'm trying to apply some MVVM principles to my SwiftUI app. I'm having trouble though with creating a search screen for finding locations on a map within the MVVM model. I created a Model, SearchState, which is a struct that has properties for currentLocation (my own child struct), currentText (String), and for currently suggested location ( [MKLocalSearchCompletion] ) and some other functions as well. The Model is then instantiated and published to the relevant Views by a ViewModel class.

The issues I'm having is with providing the suggestedLocations : [MKLocalSearchCompletion] to the View. The thing is the MKLocalSearchCompleter doesn't really work with SwiftUI, it works with UIKit - which I don't fully understand but I still managed to create a class that works:

import MapKit

class AutocompleteLocations: NSObject, MKLocalSearchCompleterDelegate {

    var completer: MKLocalSearchCompleter
    var suggestions: [MKLocalSearchCompletion]
    
    init(suggestions: inout [MKLocalSearchCompletion]) {
        self.completer = MKLocalSearchCompleter()
        self.suggestions = suggestions
        super.init()
        self.completer.delegate = self
    }
    
    
    
    // MKLocalSearchCompleterDelegate method
    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        suggestions = completer.results
    }
    
    func updateQuery(query: String)
    {
        self.completer.queryFragment = query
    }
    
    func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
        // Handle error
    }
}

Now the question I have is how to ensure changes in AutocompleteLocations.suggestions are published to the View by the ViewModel?

Here are some things I tried:

  1. Instantiate AutocompleteLocations in my SearchState struct as locationSuggestions and then use locationSuggestions.suggestions in my Views?

This doesn't work obviously because changes within a nested object don't trigger the objectWillChange.send() in SwiftUI.

  1. Trigger objectWillChange.send() myself in the ViewModel when relevant.

This doesn't work well because the locations could be returned at any point from the internet and so you don't know when to trigger objectWillChange.send(), from within the ViewModel.

  1. Modify AutocompleteLocations to take an array from SearchState, so it can append results into that array that instead of its own array.

It seems because of the fact that arrays are value types in Swift, you can't do this. i played around with it but it doesn't seem to work

  1. Decouple AutocompleteLocations from SearchState, make AutocompleteLocations an ObservableObject, published the results of AutocompleteLocations.suggestions to the ViewModel who will subscribe and then publish these results from its own field.

This works, but I just feel like this goes against MVVM and means I can't have suggestions in my SearchState Model which is where I want them.

I feel like I might be missing something fundamental here, so looking forward to input.

  • 1
    You should watch Demystify SwiftUI – lorem ipsum Aug 26 '23 at 21:48
  • `....AutocompleteLocations.suggestions are published to the View by the ViewModel...`, where is the code for that. Show how you declare your `ViewModel`, how you pass it to the views, how you update it etc... Show a minimal reproducible code that produces your issue, see: [minimal code](https://stackoverflow.com/help/minimal-reproducible-example). See also this link, it gives you the official way of how to manage data in your app: [monitoring data](https://developer.apple.com/documentation/swiftui/monitoring-model-data-changes-in-your-app) – workingdog support Ukraine Aug 26 '23 at 22:51
  • You might be able to get some ideas from this answer: https://stackoverflow.com/a/67131376/560942 – jnpdx Aug 27 '23 at 04:16
  • In SwiftUI the View structs are already the view model you don't need other objects for that. Use let for read only view data or @State/@Binding for read/write view data, use mutating func in your custom structs. – malhal Aug 27 '23 at 12:25

1 Answers1

0

You have quite a couple of good questions and I want you to give a head start with a working minimal example which you can extend and improve, added with comments explaining the how and why.

Obviously, you need to import MapKit. Note however, you don't need UIKit! The "weird" enum is just there to define a namespace which comes in handy to group views, models and accompanying structs into some "container" and thus keep names of symbols short and tidy.

import SwiftUI
import MapKit

enum LocalSearchCompleter {}

We (me) chose to want to show MKLocalSearchCompletion directly in some list view in SwiftUI. Those items should to be identifiable, i.e. conform to protocol Identifiable and we need to find a reasonable way to define this. Identifiable items in a list will SwiftUI help to detect if they have been mutated, or if its order changed, or if new ones get inserted or deleted. SwiftUI can then deduce appropriate animation by itself. Very nice.

I came up with the identifier being the title property of the MKLocalSearchCompletion.

Note, that MKLocalSearchCompletion is a class which has reference semantic! In order to improve UX you should provide value types!

Let's try out if this works well. Your mileage may vary.

extension MKLocalSearchCompletion: Identifiable {
    public var id: String {
        return title
    }
}

Next, the "Model". You may call it "ViewModel" or whatever. The name is not important, what it "does" is. Choose one, and stick with it. I use "Model".

Basically, the "Model" is an ObservableObject which produces and publishes values. The published values can be observed by a SwiftUI view which determines what the view should render. The Model also "receives" events (aka "intents"), i.e. some user actions or any form of "input" which comes from elsewhere (the result of a service for example). Based on the current "state" and the received event, the Model computes new published values, using some "logic". This is basically what a Model does.

So technically, the Model is a class, and I chose to make it also a MKLocalSearchCompleterDelegate. Since it is a UIKit Delegate, it needs to inherit from NSObject. This might sound "legacy", and honestly, it is. But this design is "opinionated" and focused on simplicity, you can improve it later if you want.

So, what the model does under the hood, is to create a MKLocalSearchCompletion object, and receive the messages from it in the delegates. The MKLocalSearchCompletion object also needs "input" to start a request. The input comes from the user, via a SwiftUI view, which you will see below in detail. The logic then updates the published properties. By the way, the "logic" is simple as it can be.

A few notes:

  • What @Published means should now be obvious. For details please read the documentation.

  • The @Published var query property has its didSet function overridden. This is one way how one can accomplish to send user intents (typing the query) to the model. There are other ways, too.

  • Else, the rest it pretty obvious, isn't it?

// MARK: - Model

extension LocalSearchCompleter {
    
    final class Model: NSObject, MKLocalSearchCompleterDelegate, ObservableObject {
        
        @Published var query: String = "" {
            didSet {
                updateQuery(query: query)
            }
        }
        @Published private(set) var suggestions: [MKLocalSearchCompletion] = []
        @Published private(set) var error: Error? = nil

        private let searchCompleter = MKLocalSearchCompleter()
        
        
        override init() {
            super.init()
            self.searchCompleter.delegate = self
        }
        
        
        // MARK: - MKLocalSearchCompleterDelegate
        
        func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
            suggestions = completer.results
        }
        
        func updateQuery(query: String) {
            self.error = nil
            self.searchCompleter.queryFragment = query
        }
        
        func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
            self.error = error
        }
    }
    
    
}

Now, the view.

The below is a very basic implementation of a SwiftUI view. What you should take from this, is just how the binding is accomplished, i.e. how the view gets the published properties from the model. How the view renders nicely list items and renders errors wasn't really the focus in my example.

extension LocalSearchCompleter {
    
    struct ContentView: View {

        // We only use `@StateObject` when the view creates the model!
        @StateObject var model = Model()
        
        var body: some View {
            LocalSearchCompleter.SearchView(
                query: $model.query,
                searchResults: model.suggestions,
                error: model.error
            )
        }
    }
    
    struct SearchView: View {
        @Binding var query: String
        let searchResults: [MKLocalSearchCompletion]
        let error: Error?

        var body: some View {
            Form {
                Section("Search") {
                    TextField("Query", text: $query)
                }
                Section("Results") {
                    ForEach(searchResults, id: \.self) { item in
                        Text(item.title)
                    }
                }
                Text(verbatim: error != nil ? error!.localizedDescription : "")
            }
        }
    }
    
}



// Note: Xcode beta!

#Preview {
    LocalSearchCompleter.ContentView()
}


Caveat: the Preview build may have issues, depending on your version of Xcode. The preview build may emit compiler errors, when there are local classes or structs (for example, a view within an enum or a Model within an enum) which it can't handele correctly yet. If you have Preview issues, use the current release version and possible also remove the "namespace" enum and use long symbols names.

CouchDeveloper
  • 18,174
  • 3
  • 45
  • 67