0

In SwiftUI, how can I insert a string at the current cursor position of the TextField? In below example, I want to change the string from 1234 to 12+34 in the toolbar button event.

@State private var inputText: String = "1234"

public var body: some View {
    VStack {
        TextField("Input text", text: $inputText)
        .toolbar {
            ToolbarItemGroup(placement: .keyboard) {
                HStack {
                    Button("+") {
                        //
                        // Here I want to insert "+" at the current cursor position.
                        //
                    }
                }
            }
        }
    }
}
winner.ktw
  • 431
  • 4
  • 12

1 Answers1

1

You already have access to the inputText, so this is a matter of determining the current cursor position. As seen in this StackOverflow post, this is currently not possible with pure SwiftUI. However, using a custom implementation, you can potentially achieve what you are trying to achieve, via String.Index and NSTextRange. However, I'm not currently aware of a way to pass this value between SwiftUI and AppKit directly, so my implementation below uses an ObservableObject singleton:

TextHolder

class TextHolder: ObservableObject {
    ///The shared instance of `TextHolder` for access across the frameworks.
    public static let shared = TextHolder()
    
    ///The currently user selected text range.
    @Published var selectedRange: NSRange? = nil
    
    //NOTE: You can comment the next variable out if you do not need to update cursor location
    ///Whether or not SwiftUI just changed the text
    @Published var justChanged = false
}

Some explanations:

  • TextHolder.shared is the singleton here, so that we can access it through SwiftUI and AppKit.
  • selectedRange is the actual NSRange of the user selected text. We will use the location attribute to add text, as this is where the user's cursor is.
  • justChanged is a property that reflects whether or not the plus button was just clicked, as we need to move the user's cursor forward one spot (to in front of the plus) if so.

TextFieldRepresentable

struct TextFieldRepresentable: NSViewRepresentable{
    
    
    ///This is an `NSTextField` for use in SwiftUI
    typealias NSViewType = NSTextField
    
    ///The placeholder to be displayed when `text` is empty
    var placeholder: String = ""
    
    ///This is the text that the `TextFieldRepresentable` will display and change.
    @Binding var text: String
    
   
    func makeNSView(context: Context) -> NSTextField {
        let textField = NSTextField()
        //Set the placeholder for when there is no text
        textField.placeholderString = placeholder
        //Set the TextField delegate
        textField.delegate = context.coordinator
        
        return textField
    }
    
    func updateNSView(_ nsView: NSTextField, context: Context) {
        //Update the actual TextField
        nsView.stringValue = text
        //NOTE: You can comment this out if you do not need to update the cursor location
        DispatchQueue.main.async {
            //Move the cursor forward one if SwiftUI just changed the value
            if TextHolder.shared.justChanged{
                nsView.currentEditor()?.selectedRange.location += 1
                TextHolder.shared.justChanged = false
            }
        }
        //END commentable area
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, NSTextFieldDelegate {
        var parent: TextFieldRepresentable
        
        init(_ parent: TextFieldRepresentable) {
            self.parent = parent
        }
        
        func controlTextDidChange(_ obj: Notification) {
            //To avoid the "NSHostingView is being laid out reentrantly while rendering its SwiftUI content." error
            DispatchQueue.main.async {
                
                //Ensure we can get the current editor
                //If not, handle the error appropriately
                if let textField = obj.object as? NSTextField, let editor = textField.currentEditor(){
                    //Update the parent's text, so SwiftUI knows the new value
                    self.parent.text = textField.stringValue
                    //Set the property
                    TextHolder.shared.selectedRange = editor.selectedRange
                } else {
                    //Handle errors - we could not get the editor
                    print("Could not get the current editor")
                }
            }
        }
    }
}

And finally, the example View usage:

struct ContentView: View {
    @State private var inputText: String = "1234"
    @ObservedObject var holder = TextHolder.shared
    public var body: some View {
        VStack {
            TextFieldRepresentable(placeholder: "Input text", text: $inputText)
            .toolbar {
                ToolbarItem(id: UUID().uuidString, placement: .automatic) {
                    HStack {
                        Button("+") {
                               insertPlus()
                        }
                    }
                }
            }
        }
    }
    
    ///Inserts the plus character at the selectedRange/
    func insertPlus(){
        //First, we will check if our range is not nil
         guard let selectedRange = holder.selectedRange else {
             //Handle errors, as we could not get the selected range
             print("The holder did not contain a selected range")
             return
         }
         let endPos = inputText.index(inputText.startIndex, offsetBy: selectedRange.location) // End of the selected range position
         //Insert the text
         inputText.insert(contentsOf: "+", at: endPos)
        //Necessary to move cursor to correct location
        TextHolder.shared.justChanged = true
    }
}

Here is an example of this in action:

Example Usage

This code has been tested with Xcode 14.2/macOS 13.1.

Source

marc_s
  • 732,580
  • 175
  • 1,330
  • 1,459