Keeping track of UI state on iOS can be a tricky problem, at least when using UIKit. Apple is making huge strides with SwiftUI to create a more modern declarative UI framework similar to React or Flutter, but many iOS developers can't use it on their existing apps until more users upgrade their devices. Until then, we can implement a pattern using Enums to reduce bugs in views which have multiple different states they can be in.
Suppose we have a Search view controller that contains a UISearchBar
. In order for us to get notified when the user taps the cancel button, we can conform our view controller to UISearchBarDelegate
, which will call our method searchBarCancelButtonClicked(_ searchBar: UISearchBar)
when the cancel button is tapped.
Tapping the cancel button needs to trigger quite a few actions. First we clear the SearchBar text by setting it to nil, second we tell the SearchBar to hide the keyboard since the user is done searching, third we tell the contained SearchTableVC
to clear its search results by sending nil
as a new query, and finally we tell our delegate (super view / view controller) that it should hide its search UI.
extension SearchVC: UISearchBarDelegate {
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
searchBar.text = nil // 1
searchBar.resignFirstResponder() // 2
searchTableVC.search(query: nil) // 3
delegate.hideSearch() // 4
}
}
This is all understandable right here, in this one method, but there are many other places in the Search view controller where we need to update the UI state, and it's incredibly easy to forget to update some bit of the UI and get ourselves in an inconsistent and buggy state.
What if our next task is to add a UIActivityIndicatorView
spinner? Can we remember all of the places in our code to add commands to start and hide the spinner?
There is a better way. Let's define an enum
that will store each discrete state the app can be in:
enum SearchState {
case hidden
case emptyView
case startSearch(String?)
case showingResults
}
The search view can be in only one of these states at a time:
- Totally hidden
- Visible, but empty (no results) with either no query or a partial search query entered
- Loading search results (
startSearch(String?)
), passing search query along - Showing search results
Then we add a state
variable to hold the state this view controller is in. Every time state
changes we will update the needed views; everything is all in one place which makes it much easier to reason about and much harder to miss something.
private var state: SearchState = .emptyView {
didSet {
switch state {
case .hidden:
activitySpinner.stopAnimating()
searchBar.text = nil
searchBar.resignFirstResponder()
searchTableVC.search(query: nil)
delegate.hideSearch()
case .emptyView:
activitySpinner.stopAnimating()
searchBar.text = nil
searchBar.becomeFirstResponder()
searchTableVC.search(query: nil)
case .startSearch(let query):
activitySpinner.startAnimating()
searchBar.resignFirstResponder()
searchTableVC.search(query: query)
case .showingResults:
activitySpinner.stopAnimating()
}
}
}
Finally in our searchBarCancelButtonClicked(_ searchBar: UISearchBar)
method, instead of four lines that update various UI directly, we just set the state like this:
extension SearchVC: UISearchBarDelegate {
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
state = .hidden
}
}
And when the search button on the keyboard is clicked, we can set state
to .startSearch()
, passing the search bar text as the search query.
extension SearchVC: UISearchBarDelegate {
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
state = .startSearch(searchBar.searchTextField.text)
}
}
That's it! State management can be a complex topic, and there are many ways to approach it, including large libraries you can import to your project, but something fairly simple like this enum
method helps a lot to keep your view logic organized and working correctly.
Top comments (0)