26 min read
Recreating Apple Music's Search Chips UI
Learn how to build Apple Music's custom search chips UI.

One of my favourite things to do as an iOS developer is to find great user interfaces across Apple's platforms and try to recreate or reverse engineer them. I did this not so long ago with Apple Music's Now Playing UI, complete with a custom view controller transition.

However, this isn't the only standout design pattern in the Music app. Another feature that caught my eye was the "search chips" in the Search tab. The chips, or tags as I'll refer to them from here on out, appear underneath the search bar, where the scope bar normally sits, appearing after the user performs a search. When the search is performed, the scope bar fades out, while the search tags fade in. The animation reverses when the search bar is focused again. Let's take a look at what that looks like inside the Apple Music app...

You could achieve something similar to this with a cell at the top of your list containing your search tags, but this isn't a great user experience as, if the user has scrolled down the list, the search tags are no longer accessible. Plus, it ain't how Apple do it, so it's not how I'm gonna do it either!

Getting Started

First things first, we need to assess what Apple are probably doing here. Given that Music isn't available on the Xcode Simulator, we can't easily attach to the process and inspect the view hierarchy. If you have a jailbroken device, you could use a tool like FLEX to pick apart the view hierarchy on device but, alas, I do not - so I'm just going to take my best guess.

The good news is, most of this UI can be built with very public, very well documented APIs. For the basic search functionality, we can rely on UISearchController. However, in this article, we'll be getting our hands a little dirty modifying view hierarchies that we don't own - a practice that your favourite Apple engineer on Twitter would probably recommend against. Why are we doing that? Well, it turns out that there is no API, private or otherwise, to manipulate the navigation bar, where the search bar sits, in such a way so that we can replace its scope bar with a custom view.

So, to get started, let's create a new Xcode project and get shot of Main.storyboard. We're going to initialise our UIWindow programmatically, with a UINavigationController as our window's root view controller, and Xcode's auto-generated ViewController class as our navigation controller's root view.

SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else { return }
        let rootViewController = ViewController()
        let navigationController = UINavigationController(rootViewController: rootViewController)
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = navigationController
        window.tintColor = .systemPink
        window.makeKeyAndVisible()
		self.window = window
    }
}

From here, we need to set up our view controller's navigation item with a title and search controller.

ViewController.swift
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let searchController = UISearchController()
        searchController.scopeBarActivation = .onSearchActivation
        searchController.searchBar.scopeButtonTitles = ["Apple Music", "Library"]
        navigationItem.searchController = searchController
        navigationItem.title = "Search"
    }
}

If we build and run our project, things are starting to take shape.

Having done this, we can use Xcode's Debug View Hierarchy feature to take a look at where we could insert our custom UI. We'll do this with the search bar focused as, in Apple Music, the tags appear in place of the scope bar which is only visible while the search bar's underlying UISearchBarTextField is the first responder.

If we select the search bar, we can see that it has a single subview of type UIView, not very interesting in and of itself. However, this view has several subviews of its own that are of interest - those being UISearchBarBackground, _UISearchBarSearchContainerView and _UISearchBarScopeContainerView. The last of which, _UISearchBarScopeContainerView, is of particular interest to us as this is the view that houses the scope bar, a UISegmentedControl, which we want to conditionally show and hide.

I'm going to cut to the chase here as I wasted many hours mucking around with this so that you don't have to. UIKit does a lot of trickery under the hood to manage the state of this segmented control, and at funny times of the view lifecycle too. We could write a bunch of combative code to work around this, relying on our trusty friend DispatchQueue.main.async(execute:), but it's going to get very messy, very quickly. Instead, we're just going to hide the system's scope bar and substitute it with our own.

To do that, we're going to create a UISearchBar subclass that overrides the didMoveToSuperview() method. When this method is called, we'll hide the search bar's scope bar, and add our segmented control that we can manage ourselves. The question is, how do we retrieve the scope bar to hide it? Well, it's actually pretty simple! It turns out UISearchBar has a private _scopeBar method that returns just that! We can write a quick extension to retrieve the value that this method returns.

UISearchBar+ScopeBar.swift
extension UISearchBar {
    var scopeBar: UIView? {
        let selector = NSSelectorFromString("_scopeBar")
        guard responds(to: selector) else { return nil }
        return value(forKey: "_scopeBar") as? UIView
    }
}

So, what's going on here? First, we create a Selector from a string, a selector being a unique identifier that represents the name of an Objective-C method. After that, we check that UISearchBar actually responds to said selector. This is an important step as the method we're calling is undocumented, meaning it's liable to change or be removed entirely in the future. Finally, we call value(forKey:) on our instance of UISearchBar and try to cast the returned value, if any, to the type of UIView.

Having done that, we can create our UISearchBar subclass and override the didMoveToSuperview() method, setting the scope bar's isHidden property to true.

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {
    override func didMoveToSuperview() {
        super.didMoveToSuperview()
        scopeBar?.isHidden = true
    }
}

The reason we do this in didMoveToSuperview() and not in init(frame:) is that the scope bar is initialised lazily. It's only created after the search bar has been added to the view hierarchy.

If we build and run our project now, nothing changes. This is because UISearchController knows nothing about our TaggedSearchBar class. In order to use our custom search bar in place of the default search bar, Apple suggests we create a UISearchController subclass and override its searchBar property, returning our own instance of some UISearchBar subclass.

TaggedSearchController.swift
class TaggedSearchController: UISearchController {
    private lazy var _searchBar = TaggedSearchBar()
    override var searchBar: TaggedSearchBar {
        return _searchBar
    }
}

The last thing we need to do is update our code in ViewController.swift to assign an instance of our new UISearchController subclass to the navigation item's searchController property.

ViewController.swift
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let searchController = TaggedSearchController()
        searchController.scopeBarActivation = .onSearchActivation
        searchController.searchBar.scopeButtonTitles = ["Apple Music", "Library"]
        navigationItem.searchController = searchController
        navigationItem.title = "Search"
    }
}

If we build and run, and focus the search bar, the scope bar is indeed hidden. So, let's get to work adding our own scope bar.

Scoping Things Out

The first thing we want to do is add a new, private property to our TaggedSearchBar class called segmentedControl. We'll also add two new methods called setupSegmentedControl() and segmentedControlValueChanged(_:). The first method, setupSegmentedControl(), will initialise a new instance of UISegmentedControl, assign it to our segmentedControl property, and add an action to be performed when the selected segment index changes. The second method, segmentedControlValueChanged(_:), will call the search bar's delegate to tell it that the selected scope button index changed. Here's what that looks like...

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {
    private var segmentedControl: UISegmentedControl!
    private func setupSegmentedControl() {
        segmentedControl = UISegmentedControl()
        segmentedControl.addTarget(self, action: #selector(segmentedControlValueChanged), for: .valueChanged)
    }
    @objc private func segmentedControlValueChanged(_ sender: UISegmentedControl) {
        delegate?.searchBar?(self, selectedScopeButtonIndexDidChange: sender.selectedSegmentIndex)
    }
}

Having done this, we'll need to call our new setupSegmentedControl() method from a custom implementation of the init(frame:) initialiser. Overriding the aforementioned initialiser will also require us to implement init(coder:), but we're not going to use that so we can rely on Xcode's default implementation.

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {
    // ...
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupSegmentedControl()
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    // ...
}

We'll also need to add our segmented control to the view hierarchy. We want to add it as a subview of the original scope bar's superview as UIKit animates this view in and out during view controller transitions and we want to take advantage of this behaviour. Earlier, when we inspected the original view hierarchy using Xcode, we saw that the scope bar was a subview of a view of type _UISearchBarScopeContainerView. We can retrieve this view using a similar extension to the one we used to retrieve the scope bar...

UISearchBar+ScopeBarContainerView.swift
extension UISearchBar {
    var scopeBarContainerView: UIView? {
        let selector = NSSelectorFromString("_scopeBarContainerView")
        guard responds(to: selector) else { return nil }
        return value(forKey: "_scopeBarContainerView") as? UIView
    }
}

Much like the scope bar, the scope bar container view is initialised lazily, so we can't call its addSubview(_:) method at the time of initialising our segmented control as the scopeBarContainerView property will return nil. So, we'll update TaggedSearchBar's didMoveToSuperview method and add our segmented control to the scope bar container view there, when we know the container view has been initialised.

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {
    // ...
    override func didMoveToSuperview() {
        super.didMoveToSuperview()
        scopeBar?.isHidden = true
        scopeBarContainerView?.addSubview(segmentedControl)
    }
    // ...
}

This is a good start, but if we build and run our app now, we still won't see our custom scope bar. This is because we initialised our segmented control using NSObject's init() initialiser, rather than UIView's init(frame:). This is by design as, at this point, we don't know what size our scope bar should be. To solve this, we'll override the layoutSubviews() method and set the frame of segmentedControl to be equal to that of the original scopeBar's.

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {
    // ...
    override func layoutSubviews() {
        super.layoutSubviews()
        layoutSegmentedControl()
    }
    private func layoutSegmentedControl() {
        if let scopeBar {
            segmentedControl.frame = scopeBar.frame
        }
    }
}

Note

I've stuck the logic for setting the segmented control's frame in a separate method, layoutSegmentedControl(), because the logic for this will get a little more complex when we conditionally show or hide our search tags later.

There is one last thing to do before we'll see our scope bar appear. Earlier, in ViewController.swift, we initialised an instance of TaggedSearchController and set its searchBar's scopeButtonTitles property. This will have updated the original scopeBar but not our custom segmentedControl. All we need to do is override TaggedSearchBar's scopeButtonTitles property, and, when the property is set, update our segmented control accordingly.

To keep the implementation of TaggedSearchBar clean, we'll create an extension of UISegmentedControl with a method called setTitles(_:). This method will accept an array of strings, iterate over those strings, and call UISegmentedControl's insertSegment(withTitle:at:animated:) method. We'll also handle a nil value, calling UISegmentedControl's removeAllSegments() method if needed.

UISegmentedControl+SetTitles.swift
extension UISegmentedControl {
    func setTitles(_ titles: [String]?) {
        if let titles {
            for title in titles.reversed() {
                insertSegment(withTitle: title, at: 0, animated: false)
            }
            selectedSegmentIndex = 0
        } else {
            removeAllSegments()
        }
    }
}

Now, we'll update TaggedSearchBar, overriding its scopeButtonTitles property.

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {
    // ...
    override var scopeButtonTitles: [String]? {
        didSet {
            segmentedControl.setTitles(scopeButtonTitles)
        }
    }
    // ...
}

Now, if we build and run, we're essentially back to square one. Tapping on the search bar focusses it, and our custom segmented control takes the place of the original scope bar. From here, we're free to customise our new scope bar to our heart's content, without having to worry about UIKit interfering with our changes.

Onwards and Upwards

Our next job is to manipulate our segmented control so that we can show and hide it to make room for our search tags. To do this, we could just use its isHidden property that it inherits from UIView, but this won't achieve the elegant animation we see in Apple Music. Instead, we'll set its frame and alpha properties to change the y position and opacity, respectively. We'll start by adding a new Boolean property to TaggedSearchBar called showsTags using the didSet property observer to call layoutSegmentedControl() when the property's value changes.

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {
    // ...
    var showsTags: Bool = false {
        didSet {
            layoutSegmentedControl()
        }
    }
    // ...
}

Now, we'll update the layoutSegmentedControl() method to conditionally set the segmented control's frame and opacity, depending on the value of showsTags.

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {
    // ...
    private func layoutSegmentedControl() {
        if let scopeBar {
            segmentedControl.frame.size = scopeBar.frame.size
            segmentedControl.frame.origin.x = scopeBar.frame.origin.x
            segmentedControl.frame.origin.y = showsTags ? -scopeBar.frame.height : scopeBar.frame.origin.y
            segmentedControl.alpha = showsTags ? 0 : 1
        }
    }
}

Now, if we set the search bar's showsTags property to true inside a (temporary) UIView.animate(withDuration:animations:) block, we can see the segmented control fade out and slide upwards.

Tagging Along

We're well on our way to recreating the Apple Music search tags UI, but there's one not-insignificant job remaining...building the search tags. This is a relatively simple task, but there's an interesting challenge that we'll have to tackle later on...

The search tags view comprises of a horizontally scrolling list of buttons, one of which is highlighted with a tinted pill shape behind it to indicate that the respective tag has been selected. To get started, we'll create a new UIScrollView subclass called TagsView. We'll define a new method called setupScrollView(), which will configure a few properties of the scroll view, and call that method from a custom implementation of init(frame:).

TagsView.swift
class TagsView: UIScrollView {    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupScrollView()
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    private func setupScrollView() {
        alwaysBounceHorizontal = true
        showsHorizontalScrollIndicator = false
    }
}

As always, if we override the init(frame:) initialiser, we'll have to implement init(coder:). Again, we can just rely on Xcode's default implementation here which is to raise a fatal error.

The scroll view properties that we configure are quite self-explanatory but, for the sake of completeness, we'll briefly go over them...

  • Setting alwaysBounceHorizontal to true allows the scroll view to scroll even if its content size is smaller than the frame of the scroll view, although it will rubber-band back to a content offset of zero.
  • Setting showsHorizontalScrollIndicator to false will cause the system's horizontal scroll bar to always be hidden, regardless of whether or not the user is scrolling.

Next on the agenda is to set up a series of buttons that will act as the actual tags. We can do this by creating UIButtons and adding them as arranged subviews of a horizontal UIStackView. UIScrollView and UIStackView work really nicely together, so we don't need to write any code to layout our subviews or to configure the scroll view's content size.

There is one consideration we need to make, however, and that is selection. As per the Apple Music UI, our search tags need to be selectable; so we'll have to highlight one search tag at a time, based on user input. One way we could do this is by setting the selected button's configuration.baseBackgroundColor to some UIColor, simultaneously setting all the other buttons' background color to nil. This would work fine, but check out this subtle detail of the original Apple Music UI...

As the selected search tags changes, the text colour of the outgoing and incoming selected tag transitions, masked to coloured pill. We can achieve this effect using UIView's mask property. Here's a breakdown of the components we'll use to achieve this effect...

  1. A horizontal stack view of buttons with a text color of UIColor.label.
  2. A pill-shaped view with a background color of UIColor.tintColor.
  3. A second, horizontal stack view of buttons with a text color of UIColor.white.
  4. A pill-shaped view with a background color of UIColor.black to be used as the second stack view's mask.
  5. The result of layering the first stack view, the tinted pill-shaped view and the second stack view with its mask applied.

Let's get stuck in. We'll start by creating two new properties, bottomStackView and topStackView, and two new methods, setupBottomStackView() and setupTopStackView(). We'll update our init(frame:) initialiser to call the two new methods, after setupScrollView().

TagsView.swift
class TagsView: UIScrollView {
    private var bottomStackView: UIStackView!
    private var topStackView: UIStackView!
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupScrollView()
        setupBottomStackView()
        setupTopStackView()
    }
    // ...
    private func setupBottomStackView() {
        bottomStackView = UIStackView()
        bottomStackView.axis = .horizontal
        bottomStackView.distribution = .fillProportionally
        bottomStackView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(bottomStackView)
        NSLayoutConstraint.activate([
            bottomStackView.topAnchor.constraint(equalTo: topAnchor),
            bottomStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
            bottomStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
            bottomStackView.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
    }
    private func setupTopStackView() {
        topStackView = UIStackView()
        topStackView.axis = .horizontal
        topStackView.isUserInteractionEnabled = false
        topStackView.distribution = .fillProportionally
        topStackView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(topStackView)
        NSLayoutConstraint.activate([
            topStackView.topAnchor.constraint(equalTo: topAnchor),
            topStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
            topStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
            topStackView.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
    }
}

Important

The second stack view, topStackView, has its isUserInteractionEnabled property set to false. This is an important step as, for reasons that will become apparent shortly, the buttons arranged inside this stack view should be purely decorative. By disabling user interaction on this stack view, it ignores all touch events and therefore they're received by the first stack view underneath, bottomStackView.

The next thing we need to do is add buttons to the stack views, but these need to be dynamic. So, let's create a new property called titles with a didSet property observer that will call a new method called updateButtons(with:).

TagsView.swift
class TagsView: UIScrollView {
    // ...
    var titles: [String] = [] {
        didSet { updateButtons(with: titles) }
    }
    // ...
    private func updateButtons(with titles: [String]) {
    }
}

The updateButtons(with:) method needs to do a couple of things...

  1. Remove all existing buttons from both the top and bottom stack views.
  2. Add a new button for each title in the titles array to both the top and bottom stack views.

Here's what that looks like...

TagsView.swift
class TagsView: UIScrollView {    
    // ...
    private func updateButtons(with titles: [String]) {
        bottomStackView.arrangedSubviews.forEach { subview in
            subview.removeFromSuperview()
        }
        topStackView.arrangedSubviews.forEach { subview in
            subview.removeFromSuperview()
        }
        for title in titles {
            let bottomButton = button(with: title, foregroundColor: .label)
            bottomStackView.addArrangedSubview(bottomButton)
            let topButton = button(with: title, foregroundColor: .white)
            topStackView.addArrangedSubview(topButton)
        }
    }
}

Eagle-eyed readers will have noticed a call to a function that we've not yet defined, button(with:foregroundColor:). This is a function that creates and returns a new UIButton with the specified title. Here's the implementation...

TagsView.swift
class TagsView: UIScrollView {    
    // ...
    private func button(with title: String, foregroundColor: UIColor) -> UIButton {
        let titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { container in
            var container = container
            container.font = UIFont.systemFont(ofSize: 13, weight: .semibold)
            return container
        }
        let button = UIButton(type: .system)
        button.configuration = .plain()
        button.configuration?.title = title
        button.configuration?.cornerStyle = .capsule
        button.configuration?.baseForegroundColor = foregroundColor
        button.configuration?.titleTextAttributesTransformer = titleTextAttributesTransformer
        button.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 8.33, leading: 12, bottom: 8, trailing: 12.66)
        button.addTarget(self, action: #selector(tagButtonTapped), for: .touchUpInside)
        return button
    }
    @objc private func tagButtonTapped(_ sender: UIButton) {
    }
}

We also added a tagButtonTapped(_:) method here, which we added as a target of our button. We'll circle back to this however, as we still need to implement our pill-shaped background view and mask view. But, before we do that, let's check in on progress. We can use the new Preview(body:) macro, introduced in iOS 17, to see what we've built so far.

TagsView.swift
#Preview {
    let tagsView = TagsView()
    tagsView.tintColor = .systemPink
    tagsView.titles = ["Top Results", "Artists", "Albums", "Songs", "Playlists", "Stations", "Profiles"]
    return tagsView
}

This is...progress, but a far cry from Apple Music. We'll continue by building our pill-shaped background view. We need to create a new property called backgroundView and a new method called setupBackgroundView(). We'll also call setupBackgroundView() from our init(frame:) method.

TagsView.swift
class TagsView: UIScrollView {
    // ...
    private var backgroundView: UIView!
    // ...
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupScrollView()
        setupBottomStackView()
        setupTopStackView()
        setupBackgroundView()
    }
    // ...
    private func setupBackgroundView() {
        backgroundView = UIView()
        backgroundView.clipsToBounds = true
        backgroundView.backgroundColor = .tintColor
        backgroundView.layer.cornerCurve = .continuous
        insertSubview(backgroundView, aboveSubview: bottomStackView)
    }
    // ...
}

At the moment, our preview won't have updated, at least not visibly. This is because backgroundView's frame is zero. So, obviously, we need to set the background view's frame. However, the frame of this view should be dependant on which tag is currently selected, so we need to keep track of the selected tag's index.

Let's create a new property in TagsView called selectedTagIndex of type Int. Again, we'll add a didSet property observer which will call a new method called updateSelection(for:). This method will get the button in the bottom stack view's arrangedSubviews array at the given index, and set backgroundView's frame to be that of the retrieved button.

TagsView.swift
class TagsView: UIScrollView {
    // ...
    var selectedTagIndex: Int = 0 {
        didSet { updateSelection(for: selectedTagIndex) }
    }
    // ...
    private func updateSelection(for selectedTagIndex: Int) {
        if bottomStackView.arrangedSubviews.indices.contains(selectedTagIndex) {
            let button = bottomStackView.arrangedSubviews[selectedTagIndex]
            backgroundView.layer.cornerRadius = button.frame.height / 2
            backgroundView.frame = button.frame
        }
    }
}

Note that we also set the tag mask view's layer.cornerRadius property. We set it to be half of the height of the selected button, giving it that distinct pill shape.

Right now, the frame of the background view won't be set unless we specifically set TagView's selectedTagIndex property. To fix this, we can override TagView's layoutSubviews() method to call our updateSelection(for:) function.

TagsView.swift
class TagsView: UIScrollView {
    // ...
    override func layoutSubviews() {
        super.layoutSubviews()
        UIView.performWithoutAnimation { [unowned self] in
            updateSelection(for: selectedTagIndex)
        }
    }
}

I've wrapped the call to updateSelection(for:) in a UIView.performWithoutAnimation(_:) block to avoid any implicit animation funkiness we might be subject to when our tags view is laid out by UIKit's layout system. Let's take another look at our preview...

We're getting there! To complete the look, we need to implement the top stack view's mask. To do that, we'll once again create a new property of TagsView and a new method, tagMaskView and setupTagMaskView() respectively. And, you guessed it, we'll call the new setupTagMaskView() from our init(frame:) initialiser.

TagsView.swift
class TagsView: UIScrollView {
    // ...
    private var tagMaskView: UIView!
    // ...
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupScrollView()
        setupBottomStackView()
        setupTopStackView()
        setupBackgroundView()
        setupTagMaskView()
    }
    // ...
    private func setupTagMaskView() {
        tagMaskView = UIView()
        tagMaskView.clipsToBounds = true
        tagMaskView.backgroundColor = .black
        tagMaskView.layer.cornerCurve = .continuous
        topStackView.mask = tagMaskView
    }
    // ...
}

Like with our background view, we'll need to lay out the mask view manually. Fortunately, the code here is identical to that which we wrote for our background view. So, let's update our updateSelection(for:) method...

TagsView.swift
class TagsView: UIScrollView {
    // ...
    private func updateSelection(for selectedTagIndex: Int) {
        if bottomStackView.arrangedSubviews.indices.contains(selectedTagIndex) {
            let button = bottomStackView.arrangedSubviews[selectedTagIndex]
            backgroundView.layer.cornerRadius = button.frame.height / 2
            backgroundView.frame = button.frame
            tagMaskView.layer.cornerRadius = button.frame.height / 2
            tagMaskView.frame = button.frame
        }
    }
    // ...
}

Quick progress check...

Looking good!

OK, back to our empty tagButtonTapped(_:) function! Remember that...? All we need to do here is get the index of our tapped button in its stack view's arrangedSubviews array, and assign that value to our selectedTagIndex property inside a UIView.animate(springDuration:bounce:) method. Simple!

TagsView.swift
class TagsView: UIScrollView {
    // ...
    @objc private func tagButtonTapped(_ sender: UIButton) {
        UIView.animate(springDuration: 0.25, bounce: 0.25) {
            selectedTagIndex = bottomStackView.arrangedSubviews.firstIndex(of: sender)!
        }
    }
    // ...
}

This works because we have our didSet property observer on selectedTagIndex, which automatically calls our updateSelection(for:) method.

This is also why we set our topStackView's isUserInteractionEnabled property to false. When the tagButtonTapped(_:) method is called, we're guaranteed that the sender will be a subview of bottomStackView.

Let's check back in on that preview...

I'm pretty happy with this!

Back to Basics

With the complexity of our tags view out of the way, we just need to update TaggedSearchBar so that we add an instance of TagsView to our view hierarchy. Then, we need to lay the tags view out appropriately, and show and hide it depending on the showsTags property we defined earlier. This code will be almost identical to that relating to our custom scope bar, segmentedControl.

We'll start by defining a new property in TagsView called tagsView and a new method called setupTagsView(). We'll update the init(frame:) initialiser to call the new setupTagsView() method.

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {
    // ...
    private var tagsView: TagsView!
    // ...
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupSegmentedControl()
        setupTagsView()
    }
    // ...
    private func setupTagsView() {
        tagsView = TagsView()
        tagsView.alpha = 0
    }
    // ...
}

Next, we'll need to update our implementation of didMoveToSuperview() so that we also add our tags view as a subview of the scope bar container view.

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {
    // ...
    override func didMoveToSuperview() {
        super.didMoveToSuperview()
        scopeBar?.isHidden = true
        scopeBarContainerView?.addSubview(segmentedControl)
        scopeBarContainerView?.addSubview(tagsView)
    }
    // ...
}

Having done this, we'll need to layout out our tags view. We'll create a new method called layoutTagsView() and call it from our implementation of layoutSubviews().

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {
    // ...
    override func layoutSubviews() {
        super.layoutSubviews()
        layoutSegmentedControl()
        layoutTagsView()
    }
    // ...
    private func layoutTagsView() {
        if let scopeBarContainerView {
            tagsView.frame.size = scopeBarContainerView.frame.size
            tagsView.frame.origin.x = 0
            tagsView.frame.origin.y = showsTags ? 0 : scopeBarContainerView.frame.height
            tagsView.alpha = showsTags ? 1 : 0
        }
    }
}

Much like in our implementation of layoutSegmentedControl(), the y position of the view we're laying out is dependant on the showsTags property. However, this time, we're showing the view when showsTags is true, instead of false.

We also need to update the didSet property observer of the showsTags property.

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {
    // ...
    var showsTags: Bool = false {
        didSet {
            layoutSegmentedControl()
            layoutTagsView()
        }
    }
    // ...
}

While we're here, we need to add a quick passthrough property for tagsView's titles property as tagsView itself is private and we need a clean, public interface for setting tag titles.

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {    
    // ...
    var tagTitles: [String] {
        get {
            return tagsView.titles
        } set {
            tagsView.titles = newValue
        }
    }
    // ...
}

Wrapping Up

As a final, somewhat optional step, we need to write some logic that shows and hides the scope bar, in favour of our tags view. In Apple Music, the scope bar is visible while the search bar text field is focused, the tags only appearing once you perform a search and the search bar text field has resigned its first responder status.

To do this, we're going to go back to our root view controller and add two new properties, searchController and isCancelling. The first property will hold a strong reference to our instance of TaggedSearchController so we can access it outside of our viewDidLoad() method. The second property will store a Boolean value, indicating whether the user has tapped the search bar's cancel button.

ViewController.swift
class ViewController: UIViewController, UISearchBarDelegate {
    private var searchController: TaggedSearchController!
    private var isCancelling: Bool = false
    // ...
}

We'll also update our implementation of viewDidLoad() so that we assign the instance of TaggedSearchController to our new searchController property, instead of a local variable. While we're here, we'll also set the searchController.searchBar's delegate and tagTitles properties. Setting the search bar's delegate property to self will also require us to add UISearchBarDelegate protocol conformance to our ViewController class.

ViewController.swift
class ViewController: UIViewController, UISearchBarDelegate {
    // ...
    override func viewDidLoad() {
        super.viewDidLoad()
        searchController = TaggedSearchController()
        searchController.scopeBarActivation = .onSearchActivation
        searchController.searchBar.delegate = self
        searchController.searchBar.scopeButtonTitles = ["Apple Music", "Library"]
        searchController.searchBar.tagTitles = ["Top Results", "Artists", "Albums", "Songs", "Playlists", "Stations", "Profiles"]
        navigationItem.searchController = searchController
        navigationItem.title = "Search"
    }
    // ...
}

Now, we're going to implement three UISearchBarDelegate methods...

  1. searchBarTextDidBeginEditing(_:)
  2. searchBarTextDidEndEditing(_:)
  3. searchBarCancelButtonClicked(_:)

Let's start with 1, searchBarTextDidBeginEditing(_:). This method gets called whenever the search bar's underlying text field becomes focused. When this happens, we want to set the search bar's showsTags property to false, inside a UIView.animate(withDuration:animations:) block.

ViewController.swift
class ViewController: UIViewController, UISearchBarDelegate {
    // ...
    func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
        isCancelling = false
        UIView.animate(withDuration: 0.25) {
            self.searchController.searchBar.showsTags = false
        }
    }
    // ...
}

We also set our isCancelling property to false here, the reason being, at this point, we know the user hasn't tapped the search bar's cancel button as it's not possible to focus the search bar text field having done so.

Next, searchBarTextDidEndEditing(_:). This is when we want to show our tags view, again inside a UIView.animate(withDuration:animations:) block.

ViewController.swift
class ViewController: UIViewController, UISearchBarDelegate {
    // ...
    func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
        guard isCancelling == false else { return }
        UIView.animate(withDuration: 0.25) {
            self.searchController.searchBar.showsTags = true
        }
    }
    // ...
}

Here, we've got a guard check that ensures our isCancelling property is set to false. We do this because, after the cancel button is tapped, the searchBarTextDidEndEditing(_:) method is called, so we would start to show our tags view while the search bar is being dismissed, causing a glitchy-looking animation.

Finally, searchBarCancelButtonClicked(_:). This one is super simple, we just need to set our isCancelling property to true.

ViewController.swift
class ViewController: UIViewController, UISearchBarDelegate {
    // ...
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        isCancelling = true
    }
}

And the result...

Now I know I said that was the final step, but there is one tiny amendment we need to make to TaggedSearchBar... That is, we need to update the horizontal insets of our scroll view as, currently, ours tags hug the leading and trailing edges of the screen. Fortunately, this fix is pretty simple - it just takes a bit of mucking around with safe area insets. We just need to update our layoutTagsView() method in TaggedSearchBar.

TaggedSearchBar.swift
class TaggedSearchBar: UISearchBar {
    // ...
    private func layoutTagsView() {
        if let scopeBarContainerView {
            // ...
            let contentInset = searchTextField.frame.origin.x
            let safeAreaInset = safeAreaInsets.left
            tagsView.contentInset.left = contentInset - safeAreaInset
            tagsView.contentInset.right = contentInset - safeAreaInset
            tagsView.contentOffset.x = -contentInset
        }
    }
}

Conclusion

And there we have it! A pretty good recreation of the Apple Music search chips UI, even if I do say so myself. There may well be some edge cases that I haven't accounted for; if there are, feel free to shout at me on Twitter.

If you've skipped to the end and just want the complete source code, you're in luck! Check out this GitHub repository.

And finally, here's a dark mode Podcasts themed version of the UI we've built, for no reason other than I think it looks pretty sweet.