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.