How to implement a Look-to-Dictate Text input in VisionOS Apps
Learn how to create a seamless dictation experience similar to Apple's first party apps
Table of contents
Prerequisites
Basic knowledge of Swift programming
Familiarity with SwiftUI
Introduction
In Apple's VisionOS apps, such as Safari, the address bar is incredibly user-friendly. You simply look at it and start speaking—no need to click a microphone icon. This seamless interaction is not available for custom apps, as Apple restricts access to gaze data to protect user privacy. However, this limitation can be a hurdle when you want users to input text.
This article will guide you through implementing a text input field that mimics this functionality, allowing users to dictate text by looking at the field.
By the end of this article, you'll have a functional component that you can integrate into your VisionOS apps.
Note: This approach can disrupt the Navigation Stack if you have one. I will cover alternative navigation methods in a separate article to prevent this issue.
Solution
Suppose you have an app where the user needs to enter some text. Typically, you would call it like this:
import SwiftUI
struct TestView: View {
let title: String
@Binding var text: String
let icon: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label(title, systemImage: icon)
.font(.subheadline)
TextField("", text: $text)
}
}
}
We have a title
as a label to the text field. We have a @Binding var text: String
as the variable that takes in the text.
The key to this implementation is that a Navigation Stack with a search bar will work automatically like Apple’s in-built APIs.
To achieve this, we want to implement a dummy NavigationStack
for each TextField
View and use the search bar of this stack as the input.
NavigationStack {
}
.searchable(
text: $text,
prompt: prompt
)
This is all that’s needed to get it working. However, it will look bare with just this. So, let’s clean it up. Let’s add a navigationTitle
to show the label for the text prompt.
We can do this by adding an empty NavigationView
and then adding the title to it. While we’re at it, we want to suppress the left pane of the view by selecting the .stack
style. Let’s also place the search bar immediately below the title ( so it appears like a label and a textfield ) and add a height to this to limit showing the empty space. The code now looks like this.
// Create an empty NavigationStack to apply the ".searchable" modifier
NavigationStack {
// Create an empty NavigationVew to apply the ".navigationBarTitle" modifier
NavigationView {}
// Supress the NavigationView's default left panel
.navigationViewStyle(.stack)
// Set the navigation title bar text
.navigationBarTitle(self.header)
}
// Restrict the navigation stack height so that just the title bar and
// search input field are shown
.frame(height: 170)
// This modifier places a search field inside the navigation stack
.searchable(
text: self.$text,
// Place the search bar below the title bar
placement: .navigationBarDrawer,
// Set the text that appears when the field is empty
prompt: self.prompt
)
This should already work if you use it as is. But if the user turns this setting off, it doesn’t work.
To make sure it works regardless of the setting, let’s add another modifier to this view.
// Always enable "look to dictate" for this field regardless of system settings
.searchDictationBehavior(.inline(activation: .onLook))
Conclusion
In total, let’s assemble our component in a struct called AdvancedTextField
import SwiftUI
struct AdvancedTextField: View {
var header: String
var prompt: String
@Binding var text: String
var action: () -> Void
var body: some View {
// Create an empty NavigationStack to apply the ".searchable" modifier
NavigationStack {
// Create an empty NavigationVew to apply the ".navigationBarTitle" modifier
NavigationView {}
// Suppress the NavigationView's default left panel
.navigationViewStyle(.stack)
// Set the navigation title bar text
.navigationBarTitle(self.header)
}
// Restrict the navigation stack height so that just the title bar and
// search input field are shown
.frame(height: 170)
// This modifier places a search field inside the navigation stack
.searchable(
text: self.$text,
// Place the search bar below the title bar
placement: .navigationBarDrawer,
// Set the text that appears when the field is empty
prompt: self.prompt
)
// Always enable "look to dictate" for this field regardless of system settings
.searchDictationBehavior(.inline(activation: .onLook))
// Change the on-screen keyboard's primary button label from "Search" to "Go"
.keyboardType(.webSearch)
// This modifier prevents the search bar from moving when search is
// activated or dismissed
.searchPresentationToolbarBehavior(.avoidHidingContent)
// Action to be taken when the "Go" button on the keyboard is pressed
.onSubmit(of: .search) {
self.action()
}
}
}
#Preview {
AdvancedTextField(
header: "This is an example header",
prompt: "This is the prompt",
text: .constant("")
)
}
As you can see, this is a pretty solid replacement for using the Input. We also added a few extra elements like the action — on complete.
This is how it looks and behaves.
You can use this struct in your app. You can also import my swift package here (https://github.com/sravankaruturi/SKLib.git) which has helpful extensions as well as this Component in it.
After importing, you just use it like this
//
// ContentView.swift
// LTD
//
// Created by Sravan Karuturi on 2/10/25.
//
import SwiftUI
import SKLib
struct ContentView: View {
@State var text: String
var body: some View {
VStack {
AdvancedTextField(header: "Hello", prompt: "Hi", text: $text, action: {} )
}
.padding()
}
}
#Preview(windowStyle: .automatic) {
ContentView(text: "Hello")
.environment(AppModel())
}
Please let me know if you face any issues with this component or if you have any other questions!