Implementing Quick Search with SwiftUI Searchable
In this post, we’ll take a look at how to search with the .searchable
API, by just typing on the keyboard without first having to tap/click on the text field.
Update: 2024-06-14
At WWDC24, Apple revealed that the .searchable
search field will get focus support. This will most likely make this code and the linked SDK in this post obsolete.
I will however keep this information here as a reference, for those of you who can’t target iOS 18 yet.
Background
The .searchable
view modifier is a convenient way to add a search text field to a screen. However, unlike regular text fields, it doesn’t let you use FocusState
to control its focus.
This means that users will have to tap/click on the search field in order to start searching, while many native macOS apps let you just type to search or filter items.
Implementing quick search in SwiftUI is tricky, but I think I made it work with two different approaches on macOS and iOS. Let’s take a look.
onKeyPress to the rescue
I got quick search to work with the .onKeyPress
modifier, which means that this approach will only work in iOS 17 and macOS 14.
Unfortunately, we can’t just add .onKeyPress
to a view and be done. macOS only detects key presses on focusable views, while .focusable()
has no effect on some views in iOS.
This forced me to come up with a solution where macOS will apply .focusable()
and iOS instead injects a hidden text field that handles the focus.
Creating a quick search view modifier
Let’s first create a QuickSearchViewModifier
view modifier that applies quick search to any view. It will integrate with a .searchable
text field, so it needs the same text binding:
struct QuickSearchViewModifier: ViewModifier {
init(text: Binding<String>) {
self._text = text
}
@Binding
private var text: String
@FocusState
private var isFocused
func body(content: Content) -> some View {
#if os(iOS)
content
.background(
extend {
TextField("", text: $text)
.opacity(0.01)
.offset(x: -10_000, y: -10_000)
}
)
#else
extend {
content
}
#endif
}
}
The iOS text field hack is ugly, but we need it gone from the view hierarchy. Any ideas for a better approach are more than welcome.
This extend
function will let both approaches share a common foundation:
private extension QuickSearchViewModifier {
func extend<Content: View>(
content: @escaping () -> Content
) -> some View {
content()
.focused($isFocused)
.focusEffectDisabled()
.onKeyPress(action: handleKeyPress)
.onChange(of: text) {
guard $1.isEmpty else { return }
isFocused = true
}
.onAppear { isFocused = true }
}
}
It applies a focused
modifier with an internal state, then disables the focus effect since it’s not meant to be an accessibility feature. Re-enable the effect where it’s needed!
The function then applies an .onKeyPress
modifier to handle key presses (more on it soon) and focuses on the view when it appears and when the text binding becomes empty.
The empty state handling is required since focus would otherwise be lost whenever a user taps or clicks the clear button in the search text field.
Handling key presses
We will handle key presses by appending characters to the text field, delete if backspace is pressed and clear the search field if escape is pressed.
To make this work, we need to be able to identify the backspace
, space
and tab
keys:
extension String {
static let backspace = String("\u{7f}")
static let space = String(" ")
static let tab = String("\t")
}
We can now implement handleKeyPress
. It requires special handling on macOS and iOS:
private extension QuickSearchViewModifier {
func handleKeyPress(
_ press: KeyPress
) -> KeyPress.Result {
guard press.modifiers.isEmpty else { return .ignored }
let chars = press.characters
switch press.key {
case .delete: return handleKeyPressWithBackspace()
case .escape: return handleKeyPressWithReset()
default: break
}
switch chars {
case .backspace: return handleKeyPressWithBackspace()
case .space: return handleKeyPressByAppending(.space)
case .tab: return .ignored
default: return handleKeyPressByAppending(chars)
}
}
func handleKeyPressByAppending(
_ char: String
) -> KeyPress.Result {
performAsyncToMakeRepeatPressWork {
text.append(char)
}
}
func handleKeyPressWithBackspace() -> KeyPress.Result {
if text.isEmpty { return .ignored }
return performAsyncToMakeRepeatPressWork {
text.removeLast()
}
}
func handleKeyPressWithReset() -> KeyPress.Result {
if text.isEmpty { return .ignored }
text = ""
return .handled
}
}
I was surprised to see that backspace behaves differently on macOS & iOS. iOS requires checking if key
is delete
, while macOS requires checking if char
is \u{7f}
(backspace).
In the code above, we handle backspace in two different ways, clear the text binding when pressing escape, ignore tab and append typed characters to the text binding.
Even though KeyPress
will trigger repeatedly if you press and hold the key, I had problems getting the repeat to work. For this to work, I had to wrap the operations in an async call:
func performAsyncToMakeRepeatPressWork(
action: @escaping () -> Void
) -> KeyPress.Result {
DispatchQueue.main.async(execute: action)
return .handled
}
That’s all the code needed to make quick search work. But before we wrap up, let’s add a view extension to make it easier to use this modifier:
public extension View {
func quickSearch(
text: Binding<String>
) -> some View {
self.modifier(
QuickSearchViewModifier(text: text)
)
}
}
We can now apply .quickSearch
next to .searchable
to make it possible to search by just start typing on the keyboard. It works on both macOS, iOS and iPadOS.
Disclaimer
Be aware that this is a highly experimental approach to make quick typing work in SwiftUI. Use it with caution, and only when it makes sense.
I hope that the native .searchable
will support quick typing in the future. Until it does, you can use my QuickSearch library to avoid having to add all this code to your project.
Discussions & More
If you found this interesting and would like to share your thoughts, please comment in the Disqus section below or reply to this tweet or this toot.
Follow on Twitter and Mastodon to be notified when new content & articles are published.