SwiftUI is a powerful framework for building UIs across the apple ecosystem, it's also very new and therefore missing some components we've become accustomed to in UIKit, one such component is UISearchBar. We could wrap UISearchBar using the UIViewRepresentable protocol (more here) but for a side project i'm working on I wanted to find a solution using SwiftUI. We're going to build a SwiftUI project which mimics the basic functionality of UISearchController, that is, a view with a search bar and a list we can search through; we'll be making light use of the Combine framework to achieve our goal.

Setup

Open Xcode and create a new project, call it whatever you like but make you sure you select SwiftUI under from 'User Interface' dropdown.

Let's start by defining our UI. This isn't a 'getting started' guide for SwiftUI so i'll assume you have a bit of knowledge. If you don't that's ok, but I would recommend running through some of the tutorials apple provide here to get started.

First we're going to define a view model, we'll use this to store our data for searching, the value to hold our users search term and later link this together using Combine.

Add a new file called ContentViewModel.swift and add the following code.

// 1
class ContentViewModel: ObservableObject {
    // 2
    @Published var searchText = ""
    
    // 3
    let allData: [String]
    var filteredData: [String] = [String]()
    
    init() {
	    // 4
        self.allData = (0..<200).map({ "\($0)" })
        self.filteredData = allData
    }
}

We've defined a few things here so let's step through them. Firstly we have the class declaration, note the ObservableObject protocol subscription we've added. By making our view model conform to ObservableObject we're allowing SwiftUI to monitor it for changes, and in turn allowing the ContentViewModel class to notify anyone watching that its data has changed.

The next thing to note is the @Published property wrapper when defining searchText. This allows searchText to make announcements to SwiftUI when the value changes.

Next we're simply defining two String arrays, allData which we need to initialise on init and filteredData that we can initialise as empty.

Lastly we create an integer array using the shorthand for Range; (0..<200) creates an array of ints from 0-199, then we use map to transform the integer values to Strings giving us our list of 200 numbers to search through.

Ok, now we have that out the way we can reference our new view model in our ContentView, add the following code below the ContentView declaration.

@ObservedObject var viewModel = ContentViewModel()

The @ObservedObject property wrapper is the other half of the ObservableObject equation, we're telling SwiftUI that it should monitor this object for changes.

Now let's dig into our UI; replace the body variable with the following

var body: some View {
    return VStack {
        HStack(spacing: 8) {
            TextField("Search...", text: $viewModel.searchText)
            Image(systemName: "magnifyingglass")
                .imageScale(.large)
        }
        .padding(.top, 10)
        .padding(.leading, 20)
        .padding(.trailing, 20)
        
        List {
            ForEach(
                viewModel.filteredData, 
                id: \.self) { str in
                    Text(str)
            }
        }        
    }
}

Here we defined a simple VStack containing an HStack (search bar) and a List.

Our HStack contains a TextField and an Image with a small amount of padding. The Image uses the magnifying glass icon from Apple's SFSymbols library, accessed using the Image(systemName: ) initialiser, passing in the name of the desired symbol. When instantiating a TextField we must define placeholder text and pass a variable binding, this is denoted using the $ and for the case of  TextField must be of type String, we use the searchText variable we setup in our viewModel object. Now whenever we type a value into our TextField, it gets written directly to our searchText variable in our viewModel.

Lastly we defined a list using the values from our viewModel.filteredData array. Note that we use filteredData and not allData here, that's because we want to filter the allData list by our search term and store the search results in filteredData, leaving allData untouched.

Your ContentView should now look like this...

struct ContentView: View {
            
    @ObservedObject var viewModel = ContentViewModel()
    
    var body: some View {
        return VStack {
            HStack(spacing: 8) {
                TextField("Search...", text: $viewModel.searchText)
                Image(systemName: "magnifyingglass")
                    .imageScale(.large)
            }
            .padding(.top, 10)
            .padding(.leading, 20)
            .padding(.trailing, 20)
                        
            List {
                ForEach(
                	viewModel.filteredData, 
	                id: \.self) { str in
                        Text(str)
                }
            }
        }
    }
}

If you build and run you should see the following.

Pretty cool! The search bar is looking nice and we have a list of String objects we can search through, but if you type something in the search bar right now nothing will happen, let's fix that.

We're going to bring this all together using Combine, open up ContentViewModel.swift and add the following variable below filteredData

var publisher: AnyCancellable?

and add the following code to the init method.

self.publisher = $searchText
    .receive(on: RunLoop.main)
    .sink(receiveValue: { (str) in
        if !self.searchText.isEmpty {
            self.filteredData = self.allData.
            	filter { $0.contains(str) }
        } else {
            self.filteredData = self.allData
        }
    })

There's a few things going on here so let's step through. Firstly we want to keep a reference to our publisher (more on publishers here) so we create the publisher variable at the top of our view model and make this of type AnyCancellable, we don't do this here but this allows us to call cancel() when we're done receiving updates. searchText is our publisher in this instance, we've defined it using the @Published property wrapper and can reference the Publisher object directly using the $ operator (official apple docs on this here).

receive(on:) specifies a scheduler on which to receive elements from the publisher, in this case we're using Runloop.main to receive updates on the main thread. Notw: when working with UI you should always perform updates on the main thread.

sink(receiveValue:) enables us to attach our subscriber and define a closure to run whenever our value changes. Inside the closure we simply filter the allData array by the value sink passes back to us in our closure, and assign the result to filteredData, this update triggers SwiftUI to refresh the interface as the ContentViewModel struct data changes, and SwiftUI is observing it for updates. if the search term is an empty string we simply assign the filteredData array to allData effectively resetting the list.

Build and run the project and you should be able to search for a number between 0-199 like so.

That's it! Just replace the data and/or type in allData with your search values and you'll have a fully functioning search feature in your SwiftUI app.

Thanks for reading! ✌️