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
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()
private var disposeBag = Set()
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:
flatMap
)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) -> UISearchBar {
let searchBar = UISearchBar(frame: .zero)
searchBar.delegate = context.coordinator
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext) {
uiView.text = text
}
}