shadowfacts.net/site/posts/2020-11-10-swiftui-text-vie...

9.2 KiB

metadata.title = "Automatically Scroll the Text View Caret into View"
metadata.tags = ["swift"]
metadata.date = "2020-11-10 22:52:42 -0400"
metadata.shortDesc = "Caret hide and seek."
metadata.slug = "swiftui-text-view-caret"

That's right, it's time for this month's installment of the never ending SwiftUI text view saga! The text view previously implemented is of course auto-expanding and has scrolling disabled. While this mostly works, it has a rather unfortunate UX problem. Let's say the user is typing into the text view, and they reach the end of the screen. As they continue to type, the text will wrap onto the next line and the caret will go with it. But, because they're already at the bottom of the screen (or immediately above the bottom of the keyboard), the caret, along with the text that they're currently typing, will no longer be visible.

Ordinarily, UITextView would handle this for us. Whenever the cursor moves, it would simply scroll itself so that the cursor is visible. But because the UITextView expands to show its entire contents, the cursor never occluded by the text view's bounds. So, we need to handle it ourselves.

Using the textViewDidChangeSelection delegate method, we can tell whenever the cursor is moved.

struct WrappedTextView: UIViewRepresentable {
	// ...
	class Coordinator: NSObject, UITextViewDelegate {
		// ...
		func textViewDidChangeSelection(_ textView: UITextView) {
			ensureCursorVisible(textView: textView)
		}
	}
}

To actually make the cursor visible, we need a two key things: the scroll view that we're actually going to scroll, and the position of the cursor on screen.

Getting the scroll view instance is fairly straightforward. Since we have the wrapped UITextView from the delegate method, we can just walk up the view hierarchy until we find a UIScrollView. This does technically rely on an internal implementation detail of SwiftUI's ScrollView (unless you're also using UIViewRepresentable to wrap the scroll view yourself), namely that it's backed by an actual UIKit scroll view, but this is one that I'm willing to live with, given that it doesn't seem likely to change any time soon. We can also fail gracefully if the scroll view isn't found, to try and prevent any future catastrophic breakage.

class Coordinator: NSObject, UITextViewDelegate {
	// ...
	private func findParentScrollView(of view: UIView) -> UIScrollView? {
		var current = view
		while let superview = current.superview {
			if let scrollView = superview as? UIScrollView {
				return scrollView
			} else {
				current = superview
			}
		}
		return nil
	}
}

Now, getting the scroll view is as simple as calling this method with the text view:

class Coordinator: NSObject, UITextViewDelegate {
	// ...
	private func ensureCursorVisible(textView: UITextView) {
		guard let scrollView = findParentScrollView(of: textView) else {
			return
		}
	}
}

Next, we need to get the cursor's position. We can use the selectedTextRange property of UITextView and its carectRect(for:) method. Note that this is the seelctedTextRange property, not the selectedRange property. The text range version gives us a UITextRange which uses UITextPositions for its start and end locations, in constrast with selectedRange which is just a regular NSRange of integers. UITextPosition is an opaque class, subclasses of which are used internally by objects conforming to the UITextInput protocol and is what's accepted by the caretRect method.

class Coordinator: NSObject, UITextViewDelegate {
	// ...
	private func ensureCursorVisible(textView: UITextView) {
		guard let scrollView = findParentScrollView(of: textView),
		      let range = textView.selectedTextRange else {
			return
		}

		let cursorRect = textView.carectRect(for: range.start)
	}
}

The carectRect(for:) method returns the rect representing the cursor, but in the coordinate space of the text view. In order to actually scroll with it, we need to convert it into the scroll view's coordinate space. After that, we can use the UIScrollView.scrollRectToVisible method to actually change the content offset of the scroll view so that the cursor is visible, without having to do a bunch of tedious math ourselves.

class Coordinator: NSObject, UITextViewDelegate {
	// ...
	private func ensureCursorVisible(textView: UITextView) {
		// ...
		var rectToMakeVisible = textView.convert(cursorRect, to: scrollView)
		scrollView.scrollRectToVisible(rectToMakeVisible, animated: true)
	}
}

This works pretty well: when the user is typing and the cursor wraps onto a new line, the scroll view scrolls so that the cursor is back on-screen. However, there are a couple things that could be improved.

First off, the area of the text view that's being made visible is limited to exactly the size and position of the cursor. While this does mean the cursor becomes visible, it's at the very bottom of the screen, and only ever so slightly above the keyboard, which doesn't look great. It would be better if, after scrolling, there was a little bit of space between the edge of the screen and the line of text being edited. We can accomplish this by adjusting the rectangle we want to make visible.

class Coordinator: NSObject, UITextViewDelegate {
	// ...
	private func ensureCursorVisible(textView: UITextView) {
		// ...
		var rectToMakeVisible = textView.convert(cursorRect, to: scrollView)

		rectToMakeVisible.origin.y -= cursorRect.height
		rectToMakeVisible.size.height *= 3

		scrollView.scrollRectToVisible(rectToMakeVisible, animated: true)
	}
}

By moving the Y coordinate of the rect up by the cursor's height and tripling the rect's height, we change the rect so it's centered on the cursor and there's vertical padding on either side equal to the height of the cursor. Scrolling this rect into view will make sure that the cursor isn't all the way at the top or bottom of the screen, but instead has some room to breathe.

The other issue is that if the user is typing very quickly and the cursor is changing lines rapidly, the scroll view's offset will stop animating smoothly and start jittering around. This is because we're calling scrollRectToVisible with animation enabled many times in quick succession. Multiple animations end up running at simultaneously and are competing for which gets to control the content offset. This is slightly more complicated to fix.

We need some way of controlling when the animation is running, so that whenever we're about to start a new animation, we can cancel the old one so that it doesn't interfere. While passing animated: true to when scrolling the scroll view, there's no easy way of doing this, since we don't know how the scroll view is actually performing the animation internally. We need to control the animation entirely ourselves. We can do this using a UIViewPropertyAnimator, which allows cancelling an in-progress animation.

class Coordinator: NSObject, UITextViewDelegate {
	// ...
	private func ensureCursorVisible(textView: UITextView) {
		// ...

		let animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
			scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
		}
		animator.startAnimation()
	}
}

By passing false for the animated: parameter of scrollRectToVisible, we instruct it to update the scroll view's contentOffset immediately, which will be captured by the property animator, and then animated smoothly. I used a duration of a tenth of a second and a linear curve, because it felt quite natural and didn't cause a problem when typing quickly. (Using a non-linear curve could cause a problem, because as animations are rapidly started and stopped, the velocity of the animation would not stay constant. Given such a short duration, this may not be a problem, but, also given the short duration, the effects of a non-linear curve aren't really visible.)

We can then store the animator on the coordinator class so that when we next try to start an animation, we can cancel the any existing, non-completed one:

class Coordinator: NSObject, UITextViewDelegate {
	// ...
	private var cursorScrollPositionAnimator: UIViewPropertyAnimator?

	private func ensureCursorVisible(textView: UITextView) {
		// ...

		if let existing = self.cursorScrollPositionAnimator {
			existing.stopAnimation(false)
		}

		let animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
			scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
		}
		animator.startAnimation()
		self.cursorScrollPositionAnimator = animator
	}
}

The stopAnimation method takes a parameter called withoutFinishing for which we pass false because we want the in-progress animation to stop immediately where it is, without jumping to the end (this also skips calling any completion blocks of the animator, but this doesn't matter as we're not using any).

With that, it finally works correctly. As the user is typing, the scroll view is scrolled smoothly to keep the cursor in view at all times, and there are no issues with the user typing too quickly for the animations to keep up.