Recreating Apple's beautiful visionOS search bar

March 24, 2024

visionOS Music app in the Joshua Tree environment with the window showing a search bar at the top with rounded corners

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.

Basic SwiftUI window with search bar to the left and text in the middle that simply says 'Perhaps the coolest View ever'
Default SwiftUI searchable() position

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.

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…

That Simpsons meme where they say 'Say the line, Bart!' but he responds 'Let's use UIKit' with much sadness

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")
        }
    }
}
Search bar at the very top but taking up full width so it overlaps the title in an ugly way

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…

Search bar at the top of the window, centered horizontally and not taking up the full width

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.

Search bar at the top of the window with a fully circular corner radius

Step 4: Remove hairline

A hairline border underneath the search bar in the center
Nooo, what IS that?

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.

Search bar at the top of the window, without any border underneath, shown in an app called Penguin Finder with a penguin as the window's background image with a progressive blur at the top under the search bar
That's it! 🎉

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)