3

I'm experimenting with SwiftUI and would like to fetch an update from my REST API with a search string.

However, I'm not sure how to bring the two components together now.

I hope you have an idea.

Here my Code:

struct ContentView: View {
    @State private var searchTerm: String = ""
    @ObservedObject var gameData: GameListViewModel = GameListViewModel(searchString: ### SEARCH STRING ???? ###)

    var body: some View {
        NavigationView{
            Group{
                // Games werden geladen...
                if(self.gameData.isLoading) {
                    LoadingView()
                }
                // Games sind geladen:
                else{
                    VStack{
                        // Suche:
                        searchBarView(text: self.$searchTerm)

                        // Ergebnisse:
                        List(self.gameData.games){ game in
                            NavigationLink(destination: GameDetailView(gameName: game.name ?? "0", gameId: 0)){
                                HStack {
                                    VStack(alignment: .leading, spacing: 2) {
                                        Text(game.name ?? "Kein Name gefunden")
                                            .font(.headline)
                                        Text("Cover: \(game.cover?.toString() ?? "0")")
                                            .font(.subheadline)
                                            .foregroundColor(.gray)
                                    }
                                }
                            }
                        }
                    }
                }
           }
       .navigationBarTitle(Text("Games"))
       }
   }
}

And the search bar implementation:

import Foundation
import SwiftUI

struct searchBarView: UIViewRepresentable {

    @Binding var text:String

    class Coordinator: NSObject, UISearchBarDelegate {

        @Binding var text: String

        init(text: Binding<String>){
            _text = text
        }

        func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
            print(searchText)
            text = searchText
        }
    }
    func makeCoordinator() -> searchBarView.Coordinator {
        return Coordinator(text: $text)
    }
    func makeUIView(context: UIViewRepresentableContext<searchBarView>) -> UISearchBar {
        let searchBar = UISearchBar(frame: .zero)
        searchBar.delegate = context.coordinator
        return searchBar
    }
    func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<searchBarView>) {
        uiView.text = text
    }
}

2 Answers 2

13

The search text should be inside the view model.

final class GameListViewModel: ObservableObject {

    @Published var isLoading: Bool = false
    @Published var games: [Game] = []

    var searchTerm: String = ""

    private let searchTappedSubject = PassthroughSubject<Void, Error>()
    private var disposeBag = Set<AnyCancellable>()

    init() {
        searchTappedSubject
        .flatMap {
            self.requestGames(searchTerm: self.searchTerm)
                .handleEvents(receiveSubscription: { _ in
                    DispatchQueue.main.async {
                        self.isLoading = true
                    }
                },
                receiveCompletion: { comp in
                    DispatchQueue.main.async {
                        self.isLoading = false
                    }
                })
                .eraseToAnyPublisher()
        }
        .replaceError(with: [])
        .receive(on: DispatchQueue.main)
        .assign(to: \.games, on: self)
        .store(in: &disposeBag)
    }

    func onSearchTapped() {
        searchTappedSubject.send(())
    }

    private func requestGames(searchTerm: String) -> AnyPublisher<[Game], Error> {
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
            return Fail(error: URLError(.badURL))
                .mapError { $0 as Error }
                .eraseToAnyPublisher()
        }
        return URLSession.shared.dataTaskPublisher(for: url)
               .map { $0.data }
               .mapError { $0 as Error }
               .decode(type: [Game].self, decoder: JSONDecoder())
            .map { searchTerm.isEmpty ? $0 : $0.filter { $0.title.contains(searchTerm) } }
               .eraseToAnyPublisher()
    }

}

Each time onSearchTapped is called, it fires a request for new games.

There's plenty of things going on here - let's start from requestGames.

I'm using JSONPlaceholder free API to fetch some data and show it in the List.

requestGames performs the network request, decodes [Game] from the received Data. In addition to that, the returned array is filtered using the search string (because of the free API limitation - in a real world scenario you'd use a query parameter in the request URL).

Now let's have a look at the view model constructor.

The order of the events is:

  • Get the "search tapped" subject.
  • Perform a network request (flatMap)
  • Inside the flatMap, loading logic is handled (dispatched on the main queue as isLoading uses a Publisher underneath, and there will be a warning if a value is published on a background thread).
  • replaceError changes the error type of the publisher to Never, which is a requirement for the assign operator.
  • receiveOn is necessary as we're probably still in a background queue, thanks to the network request - we want to publish the results on the main queue.
  • assign updates the array games on the view model.
  • store saves the Cancellable in the disposeBag

Here's the view code (without the loading, for the sake of the demo):

struct ContentView: View {

    @ObservedObject var viewModel = GameListViewModel()

    var body: some View {
        NavigationView {
            Group {
               VStack {
                    SearchBar(text: $viewModel.searchTerm,
                              onSearchButtonClicked: viewModel.onSearchTapped)
                    List(viewModel.games, id: \.title) { game in
                        Text(verbatim: game.title)
                    }
                }
            }
            .navigationBarTitle(Text("Games"))
        }
    }

}

Search bar implementation:

struct SearchBar: UIViewRepresentable {

    @Binding var text: String
    var onSearchButtonClicked: (() -> Void)? = nil

    class Coordinator: NSObject, UISearchBarDelegate {

        let control: SearchBar

        init(_ control: SearchBar) {
            self.control = control
        }

        func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
            control.text = searchText
        }

        func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
            control.onSearchButtonClicked?()
        }
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }

    func makeUIView(context: UIViewRepresentableContext<SearchBar>) -> UISearchBar {
        let searchBar = UISearchBar(frame: .zero)
        searchBar.delegate = context.coordinator
        return searchBar
    }
    func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchBar>) {
        uiView.text = text
    }

}
Sign up to request clarification or add additional context in comments.

4 Comments

Thank you. This works and I adjusted it so that the parameter is appended to the request. But now the view reloads permanently while typing, so that I have to click again and again in the search field. How do I have to rewrite it to reload it (only) when I click the "Send" button?
@Flolle updated my answer, along with the search bar implementation (find it at the bottom of the answer).
You're awesome - thank you so much! But I still have a lot to learn and have to study your code carefully. The new Swift mechanics are unusual for me.
Glad I can help. Best way to start is to have a look at WWDC videos - especially those regarding SwiftUI and the Combine framework.
9

There is no need to get UIKit involved, you can declare a simple search bar like this:

struct SearchBar: View {
    
    @State var searchString: String = ""
    
    var body: some View {
        
        HStack {
            TextField(
                "Start typing",
                text: $searchString,
                onCommit: performSearch)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            Button(action: performSearch) {
                Image(systemName: "magnifyingglass")
            }
        }   .padding()
    }
    
    func performSearch() {
        
    }
}

and then place the search logic inside performSearch().

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.