Search code examples
iosuikitswiftuisearchbar

SwiftUI: How to trigger api call to retrieve data source when Search Bar's text is changed


I implement a search bar using UIKit to wrap it in MovieSearchView. When a user input some keywords then it will show search result in the following list view.

The data searchResults is coming from API call self.model.getMovieSearchResults(), but in my code, the api call is never triggered. So I'm wondering where should I put the condition checking onAppear() in my case to trigger api call, when the search text is changed in Search Bar?

/ / / Search View

import SwiftUI

struct MovieSearchView: View {
    @ObservedObject var model = MovieListViewModel()
    @State private var searchText: String = ""

    var body: some View {
        NavigationView {
            VStack {
                SearchBar(text: $searchText)
                .onAppear() {
                    // If searchText has a value, then call api to fetch movies data.
                    if self.searchText.isEmpty == false {
                        self.model.getMovieSearchResults(for: self.searchText)
                    }
                }
                List {
                    ForEach(model.searchResults.filter {
                        self.searchText.isEmpty ? true : $0.title.lowercased().contains(self.searchText.lowercased())
                    }) { movie in
                        Text(movie.title)
                    }
                }
            }
        }
    }
}

struct MovieSearchView_Previews: PreviewProvider {
    static var previews: some View {
        MovieSearchView()
    }
}

/ / / Search Bar

import SwiftUI

struct SearchBar: 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) {
            text = searchText
        }
    }

    func makeCoordinator() -> SearchBar.Coordinator {
        return Coordinator(text: $text)
    }

    func makeUIView(context: UIViewRepresentableContext<SearchBar>) -> UISearchBar {
        let searchBar = UISearchBar(frame: .zero)
        searchBar.delegate = context.coordinator
        searchBar.searchBarStyle = .minimal
        searchBar.autocapitalizationType = .none
        return searchBar
    }

    func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchBar>) {
        uiView.text = text
    }
}

Update:

Just realize that I don't need to do the filter again, since API call already return the filtered search result from server. Change the code to remove the filter{}. I also add tap gesture to triggering navigation to movie detail view and call page 2 when you hit the bottom.

More update:

  • Clear search result when search text is cleared by the user.

  • Show cancel button on Search Bar when the user begin editing, and
    dismiss keyboard when cancel button is pressed.

Search Bar

import SwiftUI

struct SearchBar: UIViewRepresentable {

    @Binding var text: String
    var onTextChanged: (String) -> Void

    class Coordinator: NSObject, UISearchBarDelegate {

        @Binding var text: String
        var onTextChanged: (String) -> Void

        init(text: Binding<String>, onTextChanged: @escaping (String) -> Void) {
            _text = text
            self.onTextChanged = onTextChanged
        }

        // Show cancel button when the user begins editing the search text
        func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
            searchBar.showsCancelButton = true
        }

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

        func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
            text = ""
            searchBar.showsCancelButton = false
            searchBar.endEditing(true)
            // Send back empty string text to search view, trigger self.model.searchResults.removeAll()
            onTextChanged(text)
        }
    }

    func makeCoordinator() -> SearchBar.Coordinator {
        return Coordinator(text: $text, onTextChanged: onTextChanged)
    }

    func makeUIView(context: UIViewRepresentableContext<SearchBar>) -> UISearchBar {
        let searchBar = UISearchBar(frame: .zero)
        searchBar.delegate = context.coordinator
        searchBar.placeholder = "Search TMDB"
        searchBar.searchBarStyle = .minimal
        searchBar.autocapitalizationType = .none
        searchBar.showsCancelButton = true
        return searchBar
    }

    func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchBar>) {
        uiView.text = text
    }
}

Search View

import SwiftUI

struct MovieSearchView: View {
    @ObservedObject var model = MovieListViewModel()
    @State private var searchText: String = ""
    @State private var selectedId = -1
    @State private var showSheet = false
    @State private var page = 1

    var body: some View {
        List {
            SearchBar(text: $searchText, onTextChanged: searchMovies)

            ForEach(0..<model.searchResults.count, id: \.self) { i in
                MovieListRow(movie: self.model.searchResults[i])
                    .onTapGesture {
                        self.selectedId = self.model.searchResults[i].id
                        self.showSheet.toggle()
                    }
                .onAppear() {
                    if i == self.model.searchResults.count - 1 {
                        self.page += 1
                        self.model.getMovieSearchResults(for: self.searchText, page: self.page)
                    }
                }
            }
        }
        .sheet(isPresented: $showSheet) {
            SingleMovieView(movieId: self.selectedId)
        }
    }

    func searchMovies(for searchText: String) {
        if !searchText.isEmpty {
            self.model.getMovieSearchResults(for: self.searchText, page: self.page)
        } else {
            // remove search result when a user clear keyword.
            self.model.searchResults.removeAll()
        }
    }
}

struct MovieSearchView_Previews: PreviewProvider {
    static var previews: some View {
        MovieSearchView()
    }
}

Now what is it looked like in my app. Just one thing left, to dismiss the keyboard when scroll down the list. enter image description here


Solution

  • You can use a closure. Here I added onTextChanged: (String) -> Void to the SearchBar.

    MovieSearchView

    import SwiftUI
    
    struct MovieSearchView: View {
        @ObservedObject var model = MovieListViewModel()
        @State private var searchText: String = ""
    
        var body: some View {
            NavigationView {
                VStack {
                    SearchBar(text: $searchText, onTextChanged: searchMovies)
                    
                    List {
                        ForEach(model.searchResults.filter {
                            self.searchText.isEmpty ? true : $0.title.lowercased().contains(self.searchText.lowercased())
                        }) { movie in
                            Text(movie.title)
                        }
                    }
                }
            }
        }
        
        func searchMovies(for searchText: String) {
            if !searchText.isEmpty {
                model.getMovieSearchResults(for: searchText)
            }
        }
    }
    
    struct MovieSearchView_Previews: PreviewProvider {
        static var previews: some View {
            MovieSearchView()
        }
    }
    

    SearchBar

    import SwiftUI
    
    struct SearchBar: UIViewRepresentable {
        @Binding var text: String
        var onTextChanged: (String) -> Void
        
        class Coordinator: NSObject, UISearchBarDelegate {
            var onTextChanged: (String) -> Void
            @Binding var text: String
            
            init(text: Binding<String>, onTextChanged: @escaping (String) -> Void) {
                _text = text
                self.onTextChanged = onTextChanged
            }
            
            func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
                text = searchText
                onTextChanged(text)
            }
        }
        
        func makeCoordinator() -> SearchBar.Coordinator {
            return Coordinator(text: $text, onTextChanged: onTextChanged)
        }
        
        func makeUIView(context: UIViewRepresentableContext<SearchBar>) -> UISearchBar {
            let searchBar = UISearchBar(frame: .zero)
            searchBar.delegate = context.coordinator
            searchBar.searchBarStyle = .minimal
            searchBar.autocapitalizationType = .none
            return searchBar
        }
        
        func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchBar>) {
            uiView.text = text
        }
    }