Unfortunately, there doesn't seem to be a "pure" SwiftUI solution to this. The only option to achieve exactly what you describe seems to be implementing the whole view in UIKit and integrate it into the rest of your app with UIViewRepresentable
.
Since you asked about SwiftUI, though, I can show you how far you could get using only that.
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 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
.
An alternative solution might be to just compare the newValue
of newItemText
to the old one on every change and - starting from the end - compare characters of each index until you find a mismatch. Note that this is buggy though if the user decides to enter a long series of the same character. E.g. you don't know where the a
was inserted when comparing "aaaaaaa"
and "aaaaaa"
.
Scrolling
We know where the cursor is located in the text now. However, since we don't know where spaces and line-breaks are, we don't know at what height that cursor is.
The only approach I found to solve this - and I know how messy that sounds - is to render a version of TextEditor
where the contained text is cut off at the cursor position and measure its height using a GeometryReader
:
struct ContentView: View {
@State var items: [String] = ["Apples", "Oranges", "Bananas", "Pears", "Mangos", "Grapefruit","Apples", "Oranges", "Bananas", "Pears", "Mangos", "Grapefruit"]
@State private var newItemText : String = ""
@Namespace var cursorPositionId
@State private var cursorPosition: Int = 0
@State private var uiTextView: UITextView?
@State private var renderedTextSize: CGSize?
@State private var renderedCutTextSize: CGSize?
var anchorVertical: CGFloat {
if let renderedCutTextSize = renderedCutTextSize, let renderedTextSize = renderedTextSize, renderedTextSize.height > 0 {
return renderedCutTextSize.height / renderedTextSize.height
}
return 1
}
var body: some View {
ZStack {
List {
VStack {
Text(newItemText)
.background(GeometryReader {
Color.clear.preference(key: TextSizePreferenceKey.self,
value: $0.size)
})
Text(String(newItemText[..<(newItemText.index(newItemText.startIndex, offsetBy: cursorPosition, limitedBy: newItemText.endIndex) ?? newItemText.endIndex)]))
.background(GeometryReader {
Color.clear.preference(key: CutTextSizePreferenceKey.self,
value: $0.size)
})
}
}
ScrollViewReader { (proxy: ScrollViewProxy) in
// ...
}
}
.onChange(of: newItemText) { newValue in
// ...
}
.onPreferenceChange(CutTextSizePreferenceKey.self) { size in
self.renderedCutTextSize = size
}
.onPreferenceChange(TextSizePreferenceKey.self) { size in
self.renderedTextSize = size
}
}
}
private struct TextSizePreferenceKey: PreferenceKey {
static let defaultValue = CGSize.zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
let newVal = nextValue()
value = CGSize(width: value.width + newVal.width, height: value.height + newVal.height)
}
}
private struct CutTextSizePreferenceKey: PreferenceKey {
static let defaultValue = CGSize.zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
let newVal = nextValue()
value = CGSize(width: value.width + newVal.width, height: value.height + newVal.height)
}
}
The property anchorVertical
provides a good estimate of the height where the cursor is located relative to the TextEditor
s total height. Theoretically we should now be able to just scroll to this percentage of the TextEditor
's height using ScrollViewProxy
:
ScrollViewReader { (proxy: ScrollViewProxy) in
List{
ForEach(items, id: \.self) {
Text("\($0)")
}
TextEditor(text: $newItemText)
.introspectTextView { uiTextView in
self.uiTextView = uiTextView
}
.id(cursorPositionId)
}
.onChange(of: cursorPosition) { pos in
proxy.scrollTo(cursorPositionId, anchor: UnitPoint.init(x: 0, y: anchorVertical))
}
}
Unfortunately, this doesn't work. UnitPoint
has an initializer for custom coordinates, however, scrollTo(_:anchor:)
does not behave as expected for values that are not integer. I.e. UnitPoint.top.height = 0.0
and UnitPoint.bottom.height = 1.0
work fine, but no matter what number I try in between (e.g. 0.3
or 0.9
), it always scrolls to the same random position in the text.
Either that is a bug in SwiftUI, or scrollTo(_:anchor:)
was never intended to be used with custom/decimal UnitPoint
s.
Therefore, I see no way to get it to scroll to the correct position using SwiftUI APIs.
A Reasonable Solution
I see two options for you going forward: Either you implement the whole sub-view in UIKit, or you cut down on your requirements a bit:
You could compare the cursorPosition
to the newValue
's length in .onChange(of: newItemText)
and only scroll to the bottom if the cursorPosition is at / close to the end of the string.