iOS SwiftUI Searchbar and REST-API

你说的曾经没有我的故事 提交于 2019-12-04 18:27:45

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
    }

}

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: { self.performSearch() })
                .textFieldStyle(RoundedBorderTextFieldStyle())
            Button(action: { self.performSearch() }) {
                Image(systemName: "magnifyingglass")
            }
        }   .padding()
    }

    func performSearch() {

    }
}

and then place the search logic inside performSearch().

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!