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 String
s 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! ✌️