SwiftUI Auto-Expanding Text Views

I’m currently in the process of rewriting the Compose screen in Tusker to use SwiftUI. This has mostly been a smooth process, but there have been a few hiccups, the first of which was the main text box. The updates to SwiftUI introduced in iOS 14 included TextEditor, the SwiftUI equivalent of UITextView to allow multi-line text editing. Unfortunately, there’s no (straightforward) way of disabling scrolling, making it unsuitable for some designs where the text view is embedded in a separate scroll view. Additionally, the fact that it’s not available at all on iOS 13 means layouts that require non-scrolling multi-line text editors must wrap UITextView themselves in order to be achievable with SwiftUI.

You’d think this would be pretty simple: just use UIViewRepresentable with UITextView and disable scrolling. But if you try that approach, you’ll find a few issues that make things a bit more complex. While setting the isScrollEnabled property on the text view to false does indeed disable scrolling and make the text view expand itself as more lines are typed, the text view does not entirely respect SwiftUI’s layout system. Typing text that is larger than the available space causes the text view to expand outwards from the centerpoint, screwing up the layout, instead of wrapping the text onto the next line.

Enabling scrolling on the text view partially solves this, making the text wrap whenever the user types something longer than fits on a single line. Of course, this also reintroduces the problem that the text view now scrolls instead of expanding to fit the contents. The simplest solution I’ve come up with for this problem is to have the SwiftUI side of the layout automatically expand the text view’s frame whenever the contents changes. So, even though the UITextView is allowed to scroll, it never will because the layout will ensure that the actual size of the view is always taller than the text’s height. Additionally, with bouncing disabled, there’s no indication from the user’s perspective that this is anything other than a regular non-scrolling text view.

Actually implementing this is pretty simple. There’s a UIViewRepresentable which wraps the UITextView and plumbs a Binding<String> up to the text view. It also stores a closure that receives the UITextView which is invoked whenever the text changes, using the UITextViewDelegate method. This will allow the actual SwiftUI view to listen for text view changes and update the frame of the wrapped view.

import SwiftUI

struct WrappedTextView: UIViewRepresentable {
	typealias UIViewType = UITextView

	@Binding var text: String
	let textDidChange: (UITextView) -> Void

	func makeUIView(context: Context) -> UITextView {
		let view = UITextView()
		view.isEditable = true
		view.delegate = context.coordinator
		return view
	}

	func updateUIView(_ uiView: UITextView, context: Context) {
		uiView.text = self.text
		DispatchQueue.main.async {
			textDidChange(uiView)
		}
	}

	func makeCoordinator() -> Coordinator {
		return Coordinator(text: $text, textDidChange: textDidChange)
	}

	class Coordinator: NSObject, UITextViewDelegate {
		@Binding var text: String
		let textDidChange: (UITextView) -> Void

		init(text: Binding<String>, textDidChange: @escaping (UITextView) -> Void) {
			self._text = text
			self.textDidChange = textDidChange
		}

		func textViewDidChange(_ textView: UITextView) {
			self.text = textView.text
			self.textDidChange(textView)
		}
	}
}

One key line to note is that, in the updateUIView method, after the text is updated, the textDidChange closure is called. This is necessary because the UITextView.text setter does not call the delegate method automatically. So, if the text was changed programatically, the delegate method wouldn’t be called and, in turn, the did-change callback wouldn’t execute, preventing the height from updating. DispatchQueue.main.async is used to defer running updating the view height until the next runloop iteration for two reasons:

  1. So that we’re not modifying view state during view updates, as that’s undefined behavior in SwiftUI.
  2. Because the UITextView doesn’t recalculate its content size immediately when the text is set.

Waiting until the next runloop iteration solves both of those issues: the SwiftUI view updates will have finished and the text view will have recalculated its size.

The wrapping SwiftUI view is pretty simple. It passes the string binding through to the wrapped view and it also stores its minimum height, as well as an internal @State variable for the current height of the text view. The text view height is an optional, because before the text view appears, there is no height.

struct ExpandingTextView: View {
	@Binding var text: String
	let minHeight: CGFloat = 150
	@State private var textViewHeight: CGFloat?

	var body: some View {
		WrappedTextView(text: $text, textDidChange: self.textDidChange)
			.frame(height: height ?? minHeight)
	}

	private func textDidChange(_ textView: UITextView) {
		self.height = max(textView.contentSize.height, minHeight)
	}
}

Now, everything works correctly. The text view wraps text and expands to fit user input as expected, as well as updating its height when the content is altered in code.

Comments

Comments powered by ActivityPub. To respond to this post or to another comment, copy its URL into the search interface of your client for Mastodon, Pleroma, or other compatible software. Learn more.