Swiftui Use Native Search Bar Widget For Ios Tvos
Context
In my Developer journey using SwiftUI in developing SlidesOnTV and KeyChainX I’ve dealt with adding search bar on top of list, grid, etc…
In these cases I’ve always ask to myself a question:
have I to use native widget or I’ve to implement a completely new widget from scratch using SwiftUi ?
But since I’m a (very) lazy developer I decided to reuse the native widget thinking that it would have been easier solution, unfortunately It would haven’t been so.
For this reason I’ve decided to write this article for sharing my experience hoping that this could help other lazy developers like me.
Solution Design
My solution design consists in implementing a search bar as swiftui container (ie. using a @viewbuilder
) so we’ll define the search bar and also the view where the search result will be applied.
SearchBar(text: $searchText ) {
List {
ForEach( (1...50)
.map( { "Item\($0)" } )
.filter({ $0.starts(with: searchText)}), id: \.self) { item in
Text("\(item)")
}
}
}
iOS implementation
UIKit Search Bar behaviour
Let’s start, beginning with iOS where the native widget is UISearchBox
.
UIKit
offers a pretty convenient way to implement a native search bar embedded into the navigation bar. In a typical UINavigationController
a navigation stack, each UIViewController
has a corresponding UINavigationItem
that has a property called searchController
.
The SwiftUI challenges
Looking for a UINavigationController instance
The challenge here in SwiftUI is to hook up a UISearchController
instance to a UINavigationController
, so you can get all the iOS native search bar features with a single line of code.
But, how could I achieve this ? It was enough for me follow this amazing article where I’ve understood that “behind the SwiftUI NavigationView there is the good old UINavigationController from UIKit”.
Hooking up UISearchController to a NavigationView
Since, as said, behind the SwiftUI NavigationView
there is a UINavigationController
instance we can start development following steps below:
1. create a new UIViewController that hold an instance of UISearchController
class SearchBarViewController : UIViewController {
let searchController: UISearchController
init( searchController:UISearchController ) {
self.searchController = searchController
super.init(nibName: nil, bundle: nil)
}
// Continue ...
}
2. Hook up the UISearchController to the UINavigationController
To do this we’ve to find a way to handle when SearchBarViewController
will be attached to UINavigationController
(behind NavigationView
) and hook up the UISearchController to the UINavigationController through the navigationItem.searchController
property.
An easy way is overridden the UIViewController lifecycle method named didMove:
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
guard let parent = parent,
parent.navigationItem.searchController == nil else {
return
}
parent.navigationItem.searchController = searchController
}
3. Add an UIViewControllerRepresentable to manage SearchBarViewController
We have to add an UIViewControllerRepresentable
named SearchBar
that create a SearchBarViewController
instance and hold a @Binding
string variable that will be used to handle the search text update events:
struct SearchBar: UIViewControllerRepresentable {
typealias UIViewControllerType = SearchBarViewController
@Binding var text: String
class Coordinator: NSObject, UISearchResultsUpdating {
@Binding var text: String
init(text: Binding<String>) {
_text = text
}
func updateSearchResults(for searchController: UISearchController) {
if( self.text != searchController.searchBar.text ) {
self.text = searchController.searchBar.text ?? ""
}
}
}
func makeCoordinator() -> SearchBar.Coordinator {
return Coordinator(text: $text)
}
func makeUIViewController(context: UIViewControllerRepresentableContext<SearchBar>) -> UIViewControllerType {
let searchController = UISearchController(searchResultsController: nil)
searchController.searchResultsUpdater = context.coordinator
return SearchBarViewController( searchController:searchController )
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: UIViewControllerRepresentableContext<SearchBar>) {
}
}
Enable SearchBar to be a SwiftUI container
Cool.. we have a first draft of implementation but we miss an important part the new created Widget isn’t a SwiftUI container so it doesn’t manage Sub Views Content.
To do this we can use the magic @ViewBuilder
the custom parameter attribute that constructs views from closures, however we proceed with order following the steps below:
1. Update SearchBarViewController implementation
First we’ve to update the SearchBarViewController
enabling it to manage SwiftUI View as a new attribute.
class SearchBarViewController<Content:View> : UIViewController {
let searchController: UISearchController
let contentViewController: UIHostingController<Content>
init( searchController:UISearchController, withContent content:Content ) {
self.contentViewController = UIHostingController( rootView: content )
self.searchController = searchController
super.init(nibName: nil, bundle: nil)
}
// Continue
}
2. Got a UIView from SwiftUI View
Since it is not possible maps a SwiftUI View to an UIView
we have to use UIHostingController
that is able to create a UIViewController
from a SwiftUI View then we can got a UIView and adding it as sub view to the SearchBarViewController
views hierarchy.
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(contentViewController.view)
contentViewController.view.frame = view.bounds
}
3. Update SearchBar Implementation
Lets add to SearchBar
, our UIViewControllerRepresentable
, a new attribute qualified as @ViewBuilder
that hold the closure producing the contained SwiftUI View and evaluate such closure to initialize SearchBarViewController
struct SearchBar<Content: View>: UIViewControllerRepresentable {
typealias UIViewControllerType = SearchBarViewController<Content>
@Binding var text: String
@ViewBuilder var content: () -> Content // closure that produce SwiftUI content
class Coordinator: NSObject, UISearchResultsUpdating { ... }
func makeCoordinator() -> SearchBar.Coordinator { ... }
func makeUIViewController(context: UIViewControllerRepresentableContext<SearchBar>) -> UIViewControllerType {
let searchController = UISearchController(searchResultsController: nil)
searchController.searchResultsUpdater = context.coordinator
return SearchBarViewController( searchController:searchController, withContent: content() )
}
// Continue
}
4. update Content
While in the first implementation of SearchBar
we have ignored implementation of updateUIViewController
now, since we are managing the SwiftUI content, it is not possible anymore. The updateUIViewController
is automatically called by SwiftUI when an update is required and as consequence we have to re-evaluate content closure passing it to the SearchBarViewController
func updateUIViewController(_ uiViewController: UIViewControllerType, context: UIViewControllerRepresentableContext<SearchBar>) {
let contentViewController = uiViewController.contentViewController // get reference to UIHostingController
contentViewController.view.removeFromSuperview() // remove previous content
contentViewController.rootView = content() // assign fresh content to UIHostingController
uiViewController.view.addSubview(contentViewController.view) // add produced UIView
contentViewController.view.frame = uiViewController.view.bounds // update view geometry
}
Put all together
Finally we have finished and we can put all together as reported below
struct ContentView: View {
@State var searchText = ""
var body: some View {
NavigationView {
SearchBar(text: $searchText ) {
List {
ForEach( (1...50)
.map( { "Item\($0)" } )
.filter({ $0.starts(with: searchText)}), id: \.self) { item in
Text("\(item)")
}
}
}.navigationTitle("Search Bar Test")
}
}
}
Conclusion
I’ve also implemented a search bar for tvOS but I’ll explain how in another article, however the complete code is on github.
Hope this could help someone, in the meanwhile Happy coding 👋