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?
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?
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
}
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.
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 View
s. 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
.