Recreating Apple's beautiful visionOS search bar
March 24, 2024
Many of Apple’s own visionOS apps, like Music, Safari, and Apple TV, have a handy search bar front and center on the window so you can easily search through your content. Oddly, as of visionOS 1.1, replicating this visually as a developer using SwiftUI or UIKit is not particularly easy due to lack of a direct API, but it’s still totally possible, so let’s explore how.
First let’s get a few ideas out of the way to maybe save you some time.
On the SwiftUI side .searchable()
in is an obvious API to try, but even with the placement
API, there’s no way to put in the center (by default it’s to the far right, and you can either put it to the far left, or under the navigation bar, by passing different values). With toolbarRole
, similar deal, values like .browser
will put it to the left instead, but not middle. ToolbarItem(placement: .principal)
meets a similar fate, as in visionOS, the principal
position is to the left, not center.
In UIKit, the situation is similar, where navigationItem.titleView
is to the left, not center, on visionOS, and I was unable to find any other APIs that worked here.
You could technically recreate navigation bar UIView
/View
from scratch, but navigation bars on visionOS have a nice progressive blur background that wouldn’t be fun to recreate, not to mention all the other niceties they have.
All this to say, it’s totally possible there’s a clear API to do it, but I’ve dug around and poked a bunch of different people so it’s well hidden if it does exist! I’m assumning Apple’s using an internal-only API, or at least a custom UI here.
Step 1: Creating a search bar
SwiftUI doesn’t directly have the concept of a search bar view unfortunately, just the .searchable
modifier that only takes a few arguments, so… you know…
We’ll create a SwiftUI interface into UIKit’s UISearchBar
that allows us to store the typed text and respond when the user hits enter/return.
struct SearchBar: UIViewRepresentable {
@Binding var text: String
var onSearchButtonClicked: () -> Void
func makeUIView(context: Context) -> UISearchBar {
let searchBar = UISearchBar()
searchBar.delegate = context.coordinator
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: Context) {
uiView.text = text
}
func makeCoordinator() -> Coordinator { SearchBarCoordinator(self) }
}
class SearchBarCoordinator: NSObject, UISearchBarDelegate {
var parent: SearchBar
init(_ searchBar: SearchBar) {
self.parent = searchBar
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
parent.text = searchText
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
parent.onSearchButtonClicked()
searchBar.resignFirstResponder()
}
}
Now we can easily use it as so:
struct ContentView: View {
@State private var searchText = ""
var body: some View {
SearchBar(text: $searchText) {
print("User hit return")
}
}
}
Hmm, looks a little off.
Step 2: Positioning
Cool, we have a search bar, how do we position it? Again, tons of ways to do this. Perhaps the “most correct” way would be to completely wrap a UINavigationBar
or UIToolbar
, add a UISearchBar
as a subview and then move it around in layoutSubviews
relative to the other bar button items, titles, and whatnot. But that’s probably overkill, and we want a simple SwiftUI solution, so (as the great Drew Olbrick suggested) we can just overlay it on top of our NavigationStack
.
NavigationStack {
Text("Welcome to my cool view")
.navigationTitle("Search")
}
}
.overlay(alignment: .top) {
SearchBar(text: $searchText) {
print("User hit return")
}
}
This is actually great, as we get all the niceties of the normal SwiftUI APIs, and the system even appropriately spaces our search bar from the top of the window. Only issue is an obvious one, the width is all wrong. Studying how Apple does it, in the Music and Apple TV app the search bar just stays a stationary width as the window can’t get too narrow, but let’s modify ours slightly a bit so if it does get too narrow, our search bar never takes up more than half the window’s width (Apple’s probably does something similar, but more elegantly), by wrapping things in a GeometryReader
. The height is fine to stay as-is.
struct SearchBar: View {
@Binding var text: String
var onSearchButtonClicked: () -> Void
var body: some View {
GeometryReader { proxy in
InternalSearchBar(text: $text, onSearchButtonClicked: onSearchButtonClicked)
.frame(width: min(500.0, proxy.size.width / 2.0))
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
struct InternalSearchBar: UIViewRepresentable {
@Binding var text: String
var onSearchButtonClicked: () -> Void
func makeUIView(context: Context) -> UISearchBar {
let searchBar = UISearchBar()
searchBar.delegate = context.coordinator
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: Context) {
uiView.text = text
}
func makeCoordinator() -> SearchBarCoordinator { SearchBarCoordinator(self) }
}
class SearchBarCoordinator: NSObject, UISearchBarDelegate {
var parent: InternalSearchBar
init(_ searchBar: InternalSearchBar) {
self.parent = searchBar
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
parent.text = searchText
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
parent.onSearchButtonClicked()
searchBar.resignFirstResponder()
}
}
Which results in…
Bam.
Step 3: Corner radius
Our corner radius looks different than Apple’s at the top of the article!
One oddity I noticed is different Apple apps on visionOS use different corner radii despite being that same, front and center search bar. (Rounded rectangle: Apple TV, Photos, App Store; circular: Music, Safari) Presumably this is just an oversight, but after poking some Apple folks it seems like the rounded option is the correct one in this case, and I too prefer the look of that, so let’s go with that one.
One issue… The default is a rounded rectangle, not circular/capsule, and API to directly change this (as far as I can tell) is private API. But cornerRadius
is just a public API on CALayer
, so we just have to find the correct layer(s) and tweak them so they’re circular instead. We can do this by subclassing UISearchBar
and monitoring its subviews for any changes to their layer’s corner radius, and changing those layers to our own circular corner radius.
class CircularSearchBar: UISearchBar {
private var didObserveSubviews = false
private let desiredCornerRadius = 22.0
private var observedLayers = NSHashTable<CALayer>.weakObjects()
deinit {
// We need to manually track and remove CALayers we add observers for, the OS seemingly does not handle this properly for us, perhaps because we're adding observers for sublayers as well and there's timing issues with deinitialization?
// (Also don't store strong references to layers or we can introduce reference cycles)
for object in observedLayers.objectEnumerator() {
guard let layer = object as? CALayer else { continue }
layer.removeObserver(self, forKeyPath: "cornerRadius")
}
}
override func willMove(toWindow newWindow: UIWindow?) {
super.willMove(toWindow: newWindow)
// Adding to window
guard !didObserveSubviews else { return }
didObserveSubviews = true
observeSubviews(self)
}
func observeSubviews(_ view: UIView) {
if !observedLayers.contains(view.layer) {
view.layer.addObserver(self, forKeyPath: "cornerRadius", options: [.new], context: nil)
observedLayers.add(view.layer)
}
view.subviews.forEach { observeSubviews($0) }
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
guard keyPath == "cornerRadius" else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
return
}
guard let layer = object as? CALayer else { return }
guard layer.cornerRadius != desiredCornerRadius else { return }
layer.cornerRadius = desiredCornerRadius
}
}
Which gives us this beautiful, circular result once we replace UISearchBar
with CircularSearchBar
.
Step 4: Remove hairline
Just when you think you’re done, you notice there’s a little hairline border underneath the search bar that looks kinda off in our context. This is also not easily addressable with an API, but we can find it ourselves and hide it. You’d think you’d just find a thin UIView
and hide it, but Apple made this one nice and fun by making it a normal sized image view set to an image of a thin line.
Knowing that, we could find the image view and sets its image to nil
, or hide it, but through something done behind the scenes those operations seem to be overwritten, however just setting the alpha
to 0 also hides it perfectly.
private func hideImageViews(_ view: UIView) {
if let imageView = view as? UIImageView {
imageView.alpha = 0.0
}
view.subviews.forEach { hideImageViews($0) }
}
And add hideImageViews(self)
to our willMove(toWindow:)
method.
With that, we’re done and we should have nice solution for a search bar that more closely mimics how visionOS shows prominent search bars, at least until Apple hopefully adds a more straightforward way to do this! (FB13696963)