Making macOS SwiftUI text views editable on click
Helm Pro yearly subscribers now get a 30% discount on RocketSim thanks to contingent pricing on the App Store.
Apple uses lists with editable text items in a number of apps such as the macOS finder or SF Symbols. These views are so widely used across the ecosystem that being able to edit text items on a list has become somewhat of an expected behaviour for most apps.
Despite this pattern being so widely used, there is no out-of-the-box solution in the standard SwiftUI library to create views like this.
The view we’ll be building should behave in the following way:
- The view is not editable by default.
- When the user clicks on the view, it becomes editable and focused.
- When the user presses the escape key, the view reverts to its initial value and loses focus.
- When the user presses the return key, the binding passed in by the parent view gets updated.
I want to give credit to this Stack Overflow answer for giving me the idea and inspiration for making an editable text view in SwiftUI.
The editable text component
The EditableText
view wraps a standard SwiftUI TextField
to provide extra capabilities while keeping the call site as similar as possible.
TL;DR
If you want to skip the explanation and just see the code, you can find the full source code for the EditableText
view here:
struct EditableText: View {
@Binding var text: String
@State private var temporaryText: String
@FocusState private var isFocused: Bool
init(text: Binding<String>) {
self._text = text
self.temporaryText = text.wrappedValue
}
var body: some View {
TextField("", text: $temporaryText, onCommit: { text = temporaryText })
.focused($isFocused, equals: true)
.onTapGesture { isFocused = true }
.onExitCommand { temporaryText = text; isFocused = false }
}
}
Keeping an internal state
The component takes in a text binding and immediately creates a temporaryText
internal state variable initialised with the binding’s current value.
This internal state is necessary to keep hold of the user’s edits to the initial text and not update the binding’s value until the text’s onCommit
closure gets called.
This closure is not called until the user has finished editing by either pressing the return key or tapping outside of the view.
struct EditableText: View {
@Binding var text: String
@State private var temporaryText: String
init(text: Binding<String>) {
self._text = text
self.temporaryText = text.wrappedValue
}
var body: some View {
TextField("", text: $temporaryText, onCommit: { text = temporaryText })
}
}
Making the text editable
At the moment, the view you created in the previous section is always editable and focused.
You want to change its behaviour so that it becomes focused and editable whenever the user clicks on it. Furthermore, the component should be in a non-editable state by default.
You can achieve this by creating a @FocusState
property and then passing it as a binding to the TextField
’s focused
view modifier.
You can then use the onTapGesture
view modifier to set the isFocused
property to true when a user clicks on the view, which in turn sets the focus on the TextField
.
struct EditableText: View {
@Binding var text: String
@State private var temporaryText: String
@FocusState private var isFocused: Bool
init(text: Binding<String>) {
self._text = text
self.temporaryText = text.wrappedValue
}
var body: some View {
TextField("", text: $temporaryText, onCommit: { text = temporaryText })
.focused($isFocused, equals: true)
.onTapGesture { isFocused = true }
}
}
Roll changes back when the user presses the escape key
The great thing about keeping an internal state and not automatically updating the text binding on every change is that you can roll back the changes when the user presses the escape key and cancels the editing action.
To achieve this, add an onExitCommand
view modifier to the TextField
and pass it a closure which changes the value of temporaryText
to the text binding’s current value.
struct EditableText: View {
@Binding var text: String
@State private var temporaryText: String
@FocusState private var isFocused: Bool
init(text: Binding<String>) {
self._text = text
self.temporaryText = text.wrappedValue
}
var body: some View {
TextField("", text: $temporaryText, onCommit: { text = temporaryText })
.focused($isFocused, equals: true)
.onTapGesture { isFocused = true }
.onExitCommand { temporaryText = text; isFocused = false }
}
}