7

Is it possible to scroll to the TextEditor's current cursor position?

I have a List with a TextEditor as the last row. I'm scrolling to the last row of the List to make sure that the TextEditor is above the keyboard.

The problem is that since I have scroll anchor set to .bottom, while editing long text, the text at the top is not visible as the scrollView is scrolling to the bottom of the row.

enter image description here

You should be able to re-create the issue with the below code. Just enter some long text and then try to edit that text at the top.


import SwiftUI


struct ContentView: View {

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

    @State private var newItemText : String = ""


    var body: some View {
        ScrollViewReader { (proxy: ScrollViewProxy) in
                List{
                     ForEach(items, id: \.self) {
                         Text("\($0)")
                     }
                     TextEditor(text: $newItemText)
                        .onChange(of: newItemText) { newValue in
                            proxy.scrollTo("New Item TextField", anchor: .bottom)
                        }.id("New Item TextField")
                }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
unknown
  • 788
  • 9
  • 24

1 Answers1

5

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

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 TextEditors 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 UnitPoints.

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.

theMomax
  • 919
  • 7
  • 8