diff --git a/site/posts/2020-08-25-swiftui-expanding-text-view.md b/site/posts/2020-08-25-swiftui-expanding-text-view.md index c31a00b..a050c3c 100644 --- a/site/posts/2020-08-25-swiftui-expanding-text-view.md +++ b/site/posts/2020-08-25-swiftui-expanding-text-view.md @@ -39,7 +39,7 @@ struct WrappedTextView: UIViewRepresentable { func updateUIView(_ uiView: UITextView, context: Context) { uiView.text = self.text DispatchQueue.main.async { - textDidChange(uiView) + self.textDidChange(uiView) } } diff --git a/site/posts/2020-09-23-more-swiftui-text-views.md b/site/posts/2020-09-23-more-swiftui-text-views.md new file mode 100644 index 0000000..aa95c86 --- /dev/null +++ b/site/posts/2020-09-23-more-swiftui-text-views.md @@ -0,0 +1,112 @@ +``` +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](/2020/swiftui-expanding-text-view/), 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. + +```swift +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. + +```swift +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. + +```swift +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. + +```swift +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.