6

So in my text editor, I'd like to know the position of the cursor geometrically. I'm also planning to append some text after that position.

So how do I do this?

Adrian Mole
  • 49,934
  • 160
  • 51
  • 83

2 Answers2

1

Okay... So I figured out a way to do this.

First, I created a struct to store the cursor positions

import foundation

struct CursorPosition {
    start: Int
    end: Int
}

Then I initialize it to be static

class Global {
    public static var cursorPosition = CursorPosition(start: 0, end: 0)
}

Then finally, I created a custom view to match the SwiftUI TextEditor and listen for selection change and update the CursorPosition

import UIKit
import SwiftUI

fileprivate struct UITextViewWrapper: UIViewRepresentable {
    typealias UIViewType = UITextView

    @Binding var text: String
    var onDone: (() -> Void)?

    func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
        let textField = UITextView()
        textField.delegate = context.coordinator

        textField.isEditable = true
        textField.font = UIFont.preferredFont(forTextStyle: .body)
        textField.isSelectable = true
        textField.isUserInteractionEnabled = true
        textField.isScrollEnabled = true
        textField.backgroundColor = UIColor.clear
        if nil != onDone {
            textField.returnKeyType = .done
        }

        textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        return textField
    }

    func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
        if uiView.text != self.text {
            uiView.text = self.text
        }
        if uiView.window != nil, !uiView.isFirstResponder {
            uiView.becomeFirstResponder()
        }
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator(text: $text, onDone: onDone)
    }

    final class Coordinator: NSObject, UITextViewDelegate {
        var text: Binding<String>
        var onDone: (() -> Void)?

        init(text: Binding<String>, onDone: (() -> Void)? = nil) {
            self.text = text
            self.onDone = onDone
        }

        func textViewDidChange(_ uiView: UITextView) {
            text.wrappedValue = uiView.text
        }

        func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
            if let onDone = self.onDone, text == "\n" {
                textView.resignFirstResponder()
                onDone()
                return false
            }
            return true
        }
        
        func textViewDidChangeSelection(_ textView: UITextView) {
            if let range = textView.selectedTextRange {
                Global.cursorPosition.start = textView.offset(from: textView.beginningOfDocument, to: range.start)
                Global.cursorPosition.end = textView.offset(from: textView.beginningOfDocument, to: range.end)
            }
        }
        
    }

}

struct EditText: View {

    private var placeholder: String
    private var onCommit: (() -> Void)?

    @Binding private var text: String
    private var internalText: Binding<String> {
        Binding<String>(get: { self.text } ) {
            self.text = $0
            self.showingPlaceholder = $0.isEmpty
        }
    }

    @State private var showingPlaceholder = false

    init (_ placeholder: String = "", text: Binding<String>, onCommit: (() -> Void)? = nil) {
        self.placeholder = placeholder
        self.onCommit = onCommit
        self._text = text
        self._showingPlaceholder = State<Bool>(initialValue: self.text.isEmpty)
    }

    var body: some View {
        UITextViewWrapper(text: self.internalText, onDone: onCommit)
            .background(placeholderView, alignment: .topLeading)
    }

    var placeholderView: some View {
        Group {
            if showingPlaceholder {
                Text(placeholder).foregroundColor(.gray)
                    .padding(.leading, 4)
                    .padding(.top, 8)
            }
        }
    }
}

And using it:

EditText("", text: $text)
    .onChange(of: text){ _ in
        let cursorStart = Global.cursorPosition.start
    }
KANAYO AUGUSTIN UG
  • 2,078
  • 3
  • 17
  • 31
0

There is a wonderful answer describing how to do it without wrappers (but with a cost of adding SwiftUIIntrospect to your dependencies). It uses TextEditor, but it works on TextField too.

Note: I copied it, for convenience. I take no credits for it.


Getting the Cursor Position

You have two options here. Firstly, you can use the Introspect package. It allows you to access the underlying UIKit elements used to implement some SwiftUI Views. This would look like this:

import SwiftUI
import Introspect


struct ContentView: View {

    @State var items: [String] = ["Apples", "Oranges", "Bananas", "Pears", "Mangos", "Grapefruit","Apples", "Oranges", "Bananas", "Pears", "Mangos", "Grapefruit"]

    @State private var newItemText : String = ""

    @State private var uiTextView: UITextView?

    @State private var cursorPosition: Int = 0

    var body: some View {
        ScrollViewReader { (proxy: ScrollViewProxy) in
                List{
                     ForEach(items, id: \.self) {
                         Text("\($0)")
                     }
                     TextEditor(text: $newItemText)
                        .introspectTextView { uiTextView in
                            self.uiTextView = uiTextView
                        }
                        .onChange(of: newItemText) { newValue in
                            if let textView = uiTextView {
                                if let range = textView.selectedTextRange {
                                    let cursorPosition = textView.offset(from: textView.beginningOfDocument, to: range.start)

                                    self.cursorPosition = cursorPosition
                                }

                            }
                        }.id("New Item TextField")
                }
        }
    }
}

Note, however, that this approach might break in future versions of SwiftUI, as the Introspect package depends on internal APIs and might no longer be able to extract the UITextView from a TextEditor.

halfer
  • 19,824
  • 17
  • 99
  • 186
gavrilikhin.d
  • 554
  • 1
  • 7
  • 20