Making macOS SwiftUI text views editable on click

Sponsored
RevenueCat logo
Develop with RocketSim, Ship with Helm.

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:

  1. The view is not editable by default.
  2. When the user clicks on the view, it becomes editable and focused.
  3. When the user presses the escape key, the view reverts to its initial value and loses focus.
  4. 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:

EditableText.swift
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.

EditableText.swift
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.

EditableText.swift
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.

EditableText.swift
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 }
    }
}