4

I'm trying to save the location of scrolled text in a UITextView so that I can return to that location upon loading the ViewController again. I have very long strings, so I want the user to be able to scroll to a specific location and then return to that location later.

I'm using the UITextView. scrollRangeToVisible function to automatically scroll the text view, but I don't know how to get the NSRange of the text that the user is seeing. Is this the best way to go about this? I tried using the setContentOffset function but that didn't seem to do anything.

Any help is appreciated. Thanks!

Kuldeep
  • 4,466
  • 8
  • 32
  • 59
L.EXE
  • 93
  • 6

2 Answers2

3

Here's a little extension on UITextView that uses its characterRange(at:) function instead. It also adds a computed property to return the currently visible text:

extension UITextView {

    private var firstVisibleCharacterPosition: UITextPosition? {
        // ⚠️ For some reason `characterRange(at:)` returns nil for points with a low y value.
        guard let scrolledPosition = characterRange(at: contentOffset)?.start else {
            return beginningOfDocument
        }
        return scrolledPosition
    }

    private var lastVisibleCharacterPosition: UITextPosition? {
        return characterRange(at: bounds.max)?.end
    }

    /// The range of the text that is currently displayed within the text view's bounds.
    var visibleTextRange: UITextRange? {

        guard
            let first = firstVisibleCharacterPosition,
            let last = lastVisibleCharacterPosition else {
                return nil
        }

        return textRange(from: first, to: last)
    }      

    /// The string that is currently displayed within the text view's bounds.
    var visibleText: String? {
        guard let visibleTextRange = visibleTextRange else {
            return nil
        }
        return text(in: visibleTextRange)
    }

}

I used these shorthand properties in the code above:

extension CGRect {

    var min: CGPoint {
        return .init(x: minX, y: minY)
    }

    var max: CGPoint {
        return .init(x: maxX, y: maxY)
    }

}
Mischa
  • 15,816
  • 8
  • 59
  • 117
1

I haven't tested this thoroughly but I believe the following should work. The APIs you need are documented in the UITextInput protocol, which UITextView adopts.

You first need to get the UITextPosition that corresponds to a given point inside the view. You'd then convert this value into a UTF-16 character offset. For example, here I print the visible text range (in terms of UTF-16 code units) of a textView every time the view is scrolled:

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let topLeft = CGPoint(x: textView.bounds.minX, y: textView.bounds.minY)
    let bottomRight = CGPoint(x: textView.bounds.maxX, y: textView.bounds.maxY)
    guard let topLeftTextPosition = textView.closestPosition(to: topLeft),
        let bottomRightTextPosition = textView.closestPosition(to: bottomRight)
        else {
            return
    }
    let charOffset = textView.offset(from: textView.beginningOfDocument, to: topLeftTextPosition)
    let length = textView.offset(from: topLeftTextPosition, to: bottomRightTextPosition)
    let visibleRange = NSRange(location: charOffset, length: length)
    print("Visible range: \(visibleRange)")
}

In my tests, UITextView tended to count lines that were barely included in the visible range (e.g. by only one pixel), so the reported visible range tended to be one or two lines larger than what a human user would say. You may have to experiment with the exact CGPoint you pass into closesPosition(to:) to get the results you want.

Ole Begemann
  • 135,006
  • 31
  • 278
  • 256
  • Thank you, this is very helpful. I tried implementing this, making a CGPoint out of the bounds.maxY instead of using bounds.origin. This works pretty well for about the first quarter of the text, but after that it just starts going to the beginning again, which is weird. – L.EXE Jun 29 '18 at 21:59
  • Hm, it seems to work fine for me. I edited my answer to include a more complete code sample. I only tested this in a playground with plain text (no formatting) with a length of approx. 3,000 characters, but I have confirmed the reported values to be accurate. – Ole Begemann Jun 30 '18 at 16:57