shadowfacts.net/site/posts/2020-09-23-more-swiftui-tex...

6.2 KiB

metadata.title = "More SwiftUI Text View Features"
metadata.tags = ["swift"]
metadata.date = "2020-09-23 17:35:42 -0400"
metadata.shortDesc = "Adding additional features to the auto-expanding SwiftUI text view."
metadata.slug = "more-swiftui-text-views"

In my last post, I went over the implementation of a custom SwiftUI view that wraps UITextView in order to make it non-scrolling and auto-expanding. That worked quite well, but in continuing the reimplementation of Tusker's compose screen in SwiftUI, I ran into a couple more things I had to re-implement myself.

First up: a placeholder. Unfortunately, UIKit itself still does not provide a built-in way of adding placeholder text to UITextView, leaving it to app developers to implement themselves. When using UIKit, a simple solution is to add a UILabel on top of the text view and configuring it's position and font to match the text view's.

Replicating this is pretty simple, just move the wrapped UITextView inside a ZStack and add a Text for the placeholder. There are a couple slight complications, however. The first is that the text view's content isn't rendered starting at the very top left corner of the text view. Instead, it's slightly inset. With the fonts matching, I found by trial-and-error that having the placeholder inset 4 points from the left and 8 points from the top aligned it to just about the correct position. There's a slight discrepancy which seems to be a fraction of a point, but I decided to leave it at the nice, integral offsets. All but the most eagle-eyed users would never notice such a difference, especially as the placeholder disappears immediately when the user starts typing.

One additional caveat is that, unlike when you put a UILabel in front of a UITextView, when using a ZStack to position a Text on top of wrapped text view, the Text will intercept touches on the view behind it. So, if the text were empty and the user tapped on the placeholder label, the text view would not activate. A simple solution for this is to put the placeholder text behind the text view, which has the same visual appearance but results in the text view receiving all the touches within it.

struct ExpandingTextView: View {
	// ...
	var body: some View {
		ZStack {
			if !text.isEmpty {
				Text("Type something...")
					.font(.system(size: 20))
					.foregroundColor(.secondary)
					.offset(x: 4, y: 8)
			}

			WrappedTextView(text: $text, textDidChange: self.textDidChange)
				.frame(height: height ?? minHeight)
		}
	}
	// ...
}

This of course introduces yet another caveat in that setting a background color on the wrapped UITextView is no longer possible—since it would obscure the placeholder. So, if you want to add a background color, it needs to be added to the ZStack behind both the text view and the placeholder so that everything appears to be in the correct order.

struct ExpandingTextView: View {
	// ...
	var body: some View {
		ZStack {
			Color(UIColor.secondarySystemBackground)
			// ...
		}
	}
	// ...
}

Finally, I needed to be able to programatically focus the text view so that the user can start typing immediately. Actually focusing the text view is done by calling the becomeFirstResponder method that is present on all UIResponder subclasses. This needs to be done from the UIViewRepresentable implementation, since it's the only thing that has direct access to the underlying view. So, we need a mechanism to signal to the wrapper when it should instruct the text view to become the first responder. For this, the wrapped text view can take a binding to a boolean to indicate when the it should activate.

Whenever any of the inputs to the wrapped text view change (as well as immediately it's first created), the updateUIView method is called to synchronize the wrapped UIView's state with the SwiftUI wrapping struct's state. In this method, if the flag is true, we can call the becomeFirstResponder method on the text view to activate it. The flag also needs to be set back to false so that if it's changed later, it triggers another SwiftUI view update.

This all needs to happen inside the DispatchQueue.main.async block for two reasons. First, updating the view state inside during the view update is undefined behavior according to SwiftUI and should be avoided. The second reason is that, while calling becomeFirstResponder during updateUIView works fine on iOS 14, when running on iOS 13, it causes a crash deep inside UIKit, seemingly because the system is trying to present the keyboard before the view hierarchy is entirely on-screen.

struct WrappedTextView: UIViewRepresentable {
	// ...
	@Binding var becomeFirstResponder: Bool

	func makeUIView(context: Context) -> UITextView { /* ... */ }

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

			if self.becomeFirstResponder {
				uiView.becomeFirstResponder()
				self.becomeFirstResponder = false
			}
		}
	}
}

Actually making the text view focus when it appears is now a simple matter of giving the wrapped text view a binding to a boolean that we set to true during an onAppear callback.

struct ExpandingTextView: View {
	// ...
	@State private var becomeFirstResponder = false
	@State private var hasFirstAppeared = false

	var body: some View {
		ZStack {
			if !text.isEmpty {
				Text("Type something...")
					.font(.system(size: 20))
					.foregroundColor(.secondary)
					.offset(x: 4, y: 8)
			}

			WrappedTextView(text: $text, textDidChange: self.textDidChange, becomeFirstResponder: $becomeFirstResponder)
				.frame(height: height ?? minHeight)
		}
		.onAppear {
			if !hasFirstAppeared {
				hasFirstAppeared = true
				becomeFirstResponder = true
			}
		}
	}
	// ...
}

In this example, there's also a hasFirstAppeared state variable, so that the text view is only activated automatically the first time the view is shown. If the users dismisses the keyboard, leaves the app, and then returns, the keyboard should stay dismissed. This behavior could also be easily extracted out of the ExpandingTextView and into a container view, by having it pass a boolean binding through to the wrapper.