1

I have a search bar inside a view where I can search and the search will be passed to a REST api and the result will be showed on a tableView. Below are my different classes

Model:

struct MovieResponse: Codable {
    
    var totalResults: Int
    var response: String
    var error: String
    var movies: [Movie]
    
    enum ConfigKeys: String, CodingKey {
        case totalResults
        case response = "Response"
        case error = "Error"
        case movies
    }
    
    init(totalResults: Int, response: String, error: String, movies: [Movie]) {
        self.totalResults = totalResults
        self.response = response
        self.error = error
        self.movies = movies
    }
    
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        self.totalResults = try values.decodeIfPresent(Int.self, forKey: .totalResults) ?? 0
        self.response = try values.decodeIfPresent(String.self, forKey: .response) ?? "False"
        self.error = try values.decodeIfPresent(String.self, forKey: .error) ?? ""
        self.movies = try values.decodeIfPresent([Movie].self, forKey: .movies) ?? []
    }
}

extension MovieResponse {
    struct Movie: Codable, Identifiable {
        var id = UUID()
        var title: String
        var year: Int8
        var imdbID: String
        var type: String
        var poster: URL
        
        enum EncodingKeys: String, CodingKey {
            case title = "Title"
            case year = "Year"
            case imdmID
            case type = "Type"
            case poster = "Poster"
        }
    }
}

ViewModel:

final class MovieListViewModel: ObservableObject {

    @Published var isLoading: Bool = false
    @Published var movieObj = MovieResponse(totalResults: 0, response: "False", error: "", movies: [])

    var searchTerm: String = ""

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

    init() {
        searchTappedSubject
        .flatMap {
            self.requestMovies(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: \.movieObj.movies, on: self)
        .store(in: &disposeBag)
    }

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

    private func requestMovies(searchTerm: String) -> AnyPublisher<[MovieResponse.Movie], Error> {
        guard let url = URL(string:"\(Constants.HostName)/?s=\(searchTerm)&apikey=\(Constants.APIKey)") else {
            fatalError("Something is wrong with URL")
        }
        return URLSession.shared.dataTaskPublisher(for: url)
            .tryMap() { element -> Data in
                    guard let httpResponse = element.response as? HTTPURLResponse,
                        httpResponse.statusCode == 200 else {
                            throw URLError(.badServerResponse)
                        }
                    return element.data
                    }
               .mapError { $0 as Error }
            .decode(type: [MovieResponse.Movie].self, decoder: JSONDecoder())
               .eraseToAnyPublisher()
    }
}

And finally, the view & search bar

struct SearchView: View {

    @ObservedObject var viewModel = MovieListViewModel()
    
    @State private var searchText = ""
    
    var body: some View {
        ZStack {
            VStack {
                HStack {
                    Text("Search OMDB")
                        .font(.system(size: 25, weight: .black, design: .rounded))
                    Spacer()
                }
                .padding()
                Spacer()
                SearchBar(text: $viewModel.searchTerm,
                            onSearchButtonClicked: viewModel.onSearchTapped)
                List(viewModel.movieObj.movies) { movie in
                    Text(verbatim: movie.title)
                }
                .onAppear() {
                    print("Got the new data")
                }
            }
        }
    }
}

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
    }
}

When I run the code, the REST api is returning the data, but I am not able to see the same in Movie array and List is not displaying anything.

EDIT: Adding the sample json returned by the REST API

{
     "Search": [
         {
             "Title": "What We Do in the Shadows",
             "Year": "2014",
             "imdbID": "tt3416742",
             "Type": "movie",
             "Poster": "https://m.media-amazon.com/images/M/MV5BMjAwNDA5NzEwM15BMl5BanBnXkFtZTgwMTA1MDUyNDE@._V1_SX300.jpg"
         },
         {
             "Title": "I Know What You Did Last Summer",
             "Year": "1997",
             "imdbID": "tt0119345",
             "Type": "movie",
             "Poster": "https://m.media-amazon.com/images/M/MV5BZDI4ODJlNGUtNjFiMy00ODgzLWIzYjgtMzgyZTljZDQ2NGZiXkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_SX300.jpg"
         }
     ],
     "totalResults": "4365",
     "Response": "True"
 }
aios
  • 405
  • 5
  • 14
  • You're using `.replaceError(with: [])`, so you may not actually see an error. Are you positive there's not an error happening somewhere before that in the chain? – jnpdx Jul 01 '21 at 20:03
  • No error happening anywhere while running. I suspect its a decode issue, but not able to find the exact problem. – aios Jul 01 '21 at 20:06
  • 1
    Right -- if it's a decode error, I suggest you actually catch that error and respond to it rather than just replacing the error with `[]` – jnpdx Jul 01 '21 at 20:33
  • I think the problem is that movieObj is @ Published, not movies (which should trigger list reload). @Published var movieObj = MovieResponse(totalResults: 0, response: "False", error: "", movies: []) If you instead have @ Published var movies = [], and set it after you get response, it should work. – Predrag Samardzic Jul 01 '21 at 21:43

1 Answers1

1

There are few problems with your code.

  1. Mainly, the type you are trying to decode is not ccorrect. The data is a dictionary which can be decoded to MovieResponse but you are trying to decode to [MovieResponse.Movie]. This will fail.

You can find decoding/network error easily by adding

.mapError({ (error) -> Error in
  print("error -- \(error)")
  return error
})

To fix this change

.decode(type: [MovieResponse.Movie].self, decoder: JSONDecoder())

to

.decode(type: MovieResponse.self, decoder: JSONDecoder())
.map(\.movies)

And again some mismatch with the coding keys, For MovieResponse

  1. Fix typo ConfigKeys to CodingKeys
  2. The movies array should be decoded from Search object
  3. totalResults should be a string.

For Movie

  1. Change EncodingKeys to CodingKeys
  2. Fix typo imdmID to imdbID
  3. Change type of year to String or use custom decoding These changes will fix the mapping issue.

Tip: At least check your typos before posting questions ;)

Johnykutty
  • 12,091
  • 13
  • 59
  • 100
  • 2
    While @Johnykutty is correct, I would also recommend that you cheat a little until you get the hang of writing decoders. It can be pretty daunting. First, I would run the JSON response through [JSON Formatter](https://jsonformatter.curiousconcept.com) to clean it up, and then run it through [Quicktype](https://app.quicktype.io) which will write a decoder for you. The decoder will be clunky and ugly, but it will work, and you can see how to structure decoders for yourself with the data you care about. You can then learn to write better, cleaner decoders. – Yrb Jul 02 '21 at 01:25
  • Quicktype is awesome. – aios Jul 02 '21 at 06:39
  • I did everything suggested but I am still not able to see the list. I think I drop the idea of `Combine` with `PassthroughSubject` – aios Jul 02 '21 at 06:41
  • I tried with your code, and what happen is I have to press enter to show the list. – Johnykutty Jul 02 '21 at 08:37
  • Quicktype is a start, not the end. Depending on the data returned that you use to create the decoder, you can get some weird and unnecessary variables. Also, @Johnykutty is right, the way you set up your searcher will not cause the list to automatically appear. It waits until you hit enter to start the search. Otherwise, every character typed will cause a new search of the database. There are ways to deal with that, but that is beyond what you want to be doing right now. – Yrb Jul 02 '21 at 15:21