Presenting UISearchController programmatically

Viewed 201

I am presenting a UISearchController programmatically, without adding it to the navigationItem. The Calendar app does something similar.

Without a presentation context, the search bar appears correctly, but persists after pushing another view controller.

Without presentation context

This is expected, so we need to set definesPresentationContext on the list view controller... But that causes the search bar to render incorrectly.

With presentation context

Here's the code for context:

private lazy var searchController: UISearchController! = {
    let searchController = UISearchController(searchResultsController: nil)
    searchController.obscuresBackgroundDuringPresentation = false

    // If this is set to true, the search bar animates correctly, but that's
    // not the effect I'm after. See the next video.
    searchController.hidesNavigationBarDuringPresentation = false
    return searchController
}()

override func viewDidLoad() {
    super.viewDidLoad()
    definesPresentationContext = true
    searchButton.rx.tap.subscribe(onNext: { [unowned self] in
        present(searchController, animated: true)
    }).disposed(by: disposeBag)
}

Setting hidesNavigationBarDuringPresentation kind of fixes it, but we lose the tab bar, and the whole thing just looks bad.

with hidesNavigationBarDuringPresentation

I tried this solution (Unable to present a UISearchController), but it didn't help.

Any suggestions?

UPDATE: The issue is, more specifically, that the search bar appears behind the translucent navigation bar. Making the nav bar solid ( navigationController?.navigationBar.isTranslucent = false) makes the search bar appear under the nav bar.

3 Answers

I have the same problem not been able to solve this either. It seems like the problem is that either

a) the searchcontroller is presented at the very top of the viewcontroller stack, even above the navigation controller, so that it stays active into the next viewcontroller push. or,

b) the searchcontroller is presented underneath the navigationcontroller so that it remains covered by the navigation bar

One idea: don't embed the viewcontroller which is presenting the searchcontroller in a navigation controller. instead, just create a UIView which looks like a navigation bar a the top. would this be an inappropriate solution?

I haven't found a solution to the original problem, but I found a workaround: intercept navigation events, and manually dismiss the search controller.

override func viewDidLoad() {
    ...
    // This makes the search bar appear behind the nav bar
    // definesPresentationContext = true

    navigationController?.delegate = self
}

extension JobListViewController: UINavigationControllerDelegate {
    func navigationController(_ navigationController: UINavigationController,
                              willShow viewController: UIViewController, animated: Bool) {
        // `animated` is false because, for some reason, the dismissal animation doesn't start
        // until the transition has completed, when we've already arrived at the new controller
        searchController.dismiss(animated: false, completion: nil)
    }
}

I found that presenting the search controller over the navigation bar can be achieved by calling present(_:animated:completion:) on the navigation controller itself rather than the navigation controller's child.

So in your view controller you can do

navigationController?.present(searchController, animated: true)

And this will behave like the search button in the Apple's Calendar app.

enter image description here

Update

Regarding dismissing the search controller before pushing a new controller to the navigation stack, you can do this manually depending on how the push is done.

All the bellow will animate dismissing the search controller before the push happens. Note that I disable user interaction until the dismiss animation completes to prevent pushing the same view controller multiple times.

UIStoryboardSegue

Add this override to your view controller:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    if navigationController?.presentedViewController == searchController {
        view.isUserInteractionEnabled = false
        searchController.dismiss(animated: true) {
            self.view.isUserInteractionEnabled = true
        }
    }
}

IBAction (Programmatically)

Just dismiss the search controller before pushing view controllers to the navigation stack:

@IBAction func showSecondTapped(_ sender: UIButton) {
    // Dismiss the search controller first.
    view.isUserInteractionEnabled = false
    searchController.dismiss(animated: true) {
        self.view.isUserInteractionEnabled = true
    }

    // Build and push the detail view controller.
    if let secondViewController = storyboard?.instantiateViewController(withIdentifier: "SecondViewController") {
        navigationController?.pushViewController(secondViewController, animated: true)
    }
}

Handling pop gesture

If the view controller that is presenting the search controller is not the root of your navigation controller, the user might be able to use the interactive pop gesture, which will also keep the search controller presented after the pop. You can handle this by making your view controller the delegate for the search controller, conform to UISearchControllerDelegate and adding the following code:

extension ViewController: UISearchControllerDelegate {
    func willPresentSearchController(_ searchController: UISearchController) {
        navigationController?.interactivePopGestureRecognizer?.isEnabled = false
    }

    func willDismissSearchController(_ searchController: UISearchController) {
        navigationController?.interactivePopGestureRecognizer?.isEnabled = true
    }
}
Related