MVVM架构下,如何实现基于API数据的TableView TextField搜索功能?
Got it, let's walk through implementing the search feature in MVVM for your restaurant list—since you already have the basics down (Alamofire fetch, TableView display), this should be a smooth extension. Here's how to structure it properly:
1. Build the ViewModel (MVVM's Logic Hub)
The ViewModel will handle all data operations: fetching from the API, storing raw restaurant data, and filtering based on search input. We'll use Combine for reactive updates (but I'll note a non-Combine alternative too).
import Foundation import Alamofire import Combine class RestaurantViewModel { // Store all restaurants from the API (never modified after fetch) private var originalRestaurants: [SearchModel] = [] // Filtered list that feeds the TableView (updated on search) @Published var filteredRestaurants: [SearchModel] = [] private var cancellables = Set<AnyCancellable>() init() { // Auto-fetch data on init, or trigger this from your ViewController later fetchRestaurants() } // Move your Alamofire request here (ViewController shouldn't handle network calls!) func fetchRestaurants() { let apiEndpoint = "YOUR_API_URL_HERE" AF.request(apiEndpoint) .responseDecodable(of: [SearchModel].self) { [weak self] response in guard let self = self else { return } switch response.result { case .success(let restaurants): self.originalRestaurants = restaurants self.filteredRestaurants = restaurants // Show all initially case .failure(let error): print("Fetch failed: \(error.localizedDescription)") // Add error handling here (e.g., publish an error state for the ViewController) } } } // Core search logic: filter original data based on user input func searchRestaurants(with query: String) { guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { filteredRestaurants = originalRestaurants return } // Case-insensitive search for restaurant names filteredRestaurants = originalRestaurants.filter { restaurant in restaurant.restaurantname?.lowercased().contains(query.lowercased()) ?? false } } }
Non-Combine Alternative
If you aren't using Combine, replace the @Published property with a closure callback:
class RestaurantViewModel { // ... other properties ... var onDataUpdated: (() -> Void)? // When filteredRestaurants changes, call: self.onDataUpdated?() }
Then in your ViewController, set viewModel.onDataUpdated = { self.restaurantTableView.reloadData() } during setup.
2. Polish Your SearchModel
Let's make the model safe and conform to Decodable (since you're using Alamofire's responseDecodable):
class SearchModel: NSObject, Decodable { // Use optional String to avoid force-unwrap crashes var restaurantname: String? // Map API keys to your property names (skip if they match exactly) enum CodingKeys: String, CodingKey { case restaurantname = "restaurant_name" // Adjust to your API's actual key } // Keep your dictionary init if you need it, alongside Decodable init?(dict: [String: Any]) { self.restaurantname = dict["restaurantname"] as? String super.init() } }
3. Update the ViewController (UI Only!)
Your ViewController's job is only to manage UI and user interactions—no logic allowed. Here's how to wire it up:
import UIKit import Combine class RestaurantViewController: UIViewController { @IBOutlet weak var searchTextField: UITextField! @IBOutlet weak var restaurantTableView: UITableView! private let viewModel = RestaurantViewModel() private var cancellables = Set<AnyCancellable>() override func viewDidLoad() { super.viewDidLoad() setupTableView() setupBindings() setupSearchListener() } private func setupTableView() { restaurantTableView.dataSource = self restaurantTableView.register(UITableViewCell.self, forCellReuseIdentifier: "RestaurantCell") } // Listen for changes to the ViewModel's filtered list and refresh the TableView private func setupBindings() { viewModel.$filteredRestaurants .receive(on: DispatchQueue.main) // Ensure UI updates happen on main thread .sink { [weak self] _ in self?.restaurantTableView.reloadData() } .store(in: &cancellables) } // Listen for text changes in the search TextField private func setupSearchListener() { searchTextField.addTarget(self, action: #selector(searchQueryChanged(_:)), for: .editingChanged) } @objc private func searchQueryChanged(_ textField: UITextField) { let query = textField.text ?? "" viewModel.searchRestaurants(with: query) } } // TableView DataSource Implementation extension RestaurantViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return viewModel.filteredRestaurants.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "RestaurantCell", for: indexPath) let restaurant = viewModel.filteredRestaurants[indexPath.row] cell.textLabel?.text = restaurant.restaurantname ?? "Unknown Restaurant" return cell } }
Bonus Tips for Better UX & Performance
- Debounce Search: If you have a large dataset, add a delay to search calls to avoid filtering on every single keystroke. With Combine, modify the TextField listener to use
debounce:// Replace setupSearchListener with this Combine-based version private func setupSearchListener() { searchTextField.publisher(for: \.text) .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .removeDuplicates() .sink { [weak self] query in self?.viewModel.searchRestaurants(with: query ?? "") } .store(in: &cancellables) } - Error Handling: Add an
@Published var errorMessage: String?to the ViewModel, then bind it in the ViewController to show an alert when something goes wrong. - Empty State: Add a check in the TableView data source to show an empty state cell when
filteredRestaurantsis empty (either no results or no data fetched).
内容的提问来源于stack exchange,提问作者emmamichael

