With iOS 14 Apple introduced a native way to work with multiline editable text. You can find the documentation here. If you need to work with Code that is pre iOS 14 the following approach can still be used.
Before starting this post I hope that everyone who is reading this article is healthy and stays it !
It has been quite a while since my last blog post. End of the last year there was quite a lot of work to do in my company and now with the recent pandemic I couldn't find enough time to neither work on some of my side projects nor to write some posts on my blog.
Luckily I found some free time in the last weeks to get a first look on SwiftUI. In this post I want to highlight how we can use UIKit components, that haven't been ported to yet, in SwiftUI. I want to show that creating an View
that wraps a UITextView
component. Maybe this will be become obsolete when Apple announces new additions to SwiftUI at WWDC 2020. Until then I hope this small article provides some nice insights for fellow SwiftUI developers.
Currently I am working on a small side project which is completely written in SwiftUI. For this I needed a multiline scrollable text input component. Since the only component which is natively available in SwiftUI, TextField
, is only supporting one line of text and is not scrollable, it was necessary to somehow use UITextView
within SwiftUI.
Fortunately this is rather easy to accomplish by using the UIViewRepresentable
protocol. This protocol helps us to use UIKit components in SwiftUI.
Features
Before we dive into the code, I first want to highlight what my TextView
component should do:
- Multiline input
- Scrollable
- Close keyboard by pressing a done button in a
UIToolbar
on top of the keyboard
Implementation
At first we create a new struct which conforms to UIViewRepresentable
. This defines the view which are going to use within other SwiftUI views.
struct TextView: UIViewRepresentable {
@Binding var text: String
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
}
}
To conform to the protocol we need to implement two methods: makeUIView
and updateUIView
. The first one creates an UIView
instance that needs to be displayed. The latter simply updates the displayed UIView
instance. In addition to the two methods we have added a @Binding var text: String
which represents the text that is entered into the text view. This would already be enough to use our TextView
in any SwiftUI of our choice but it wouldn't do much. We are missing two main features:
- The binding is not updated when the text inside the
UITextView
instance changes - The
UIToolbar
to close the keyboard when the user is done is still missing
For the first point we need to implement a so called Coordinator
class. You can think of the coordinator as something like a delegate to the UIView
instance.
struct TextView: UIViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
}
class Coordinator: NSObject, UITextViewDelegate {
var parent: TextView
init(_ uiTextView: TextView) {
self.parent = uiTextView
}
func textViewDidChange(_ textView: UITextView) {
// This populates the updated text value back to the SwiftUI world through the binding
self.parent.text = textView.text
}
}
}
With the help of the coordinator class we can react to the textViewDidChange
event and change the binding of the TextView
accordingly. You can become a lot more creative here and implement a lot more delegate methods if necessary.
The last thing which is missing is the UIToolbar
implementation. For that we are creating a small extension on UITextView
and using it inside the makeUIView
function of TextView
.
extension UITextView {
func addDoneButton(title: String, target: Any, selector: Selector) {
let toolBar = UIToolbar(frame: CGRect(x: 0.0,
y: 0.0,
width: UIScreen.main.bounds.size.width,
height: 44.0))
let flexible = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
let barButton = UIBarButtonItem(title: title, style: .plain, target: target, action: selector)
toolBar.setItems([flexible, barButton], animated: false)
self.inputAccessoryView = toolBar
}
@objc func doneButtonTapped(button: UIBarButtonItem) {
self.resignFirstResponder()
}
}
struct TextView: UIViewRepresentable {
...
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.addDoneButton(title: "Done", target: textView, selector: #selector(textView.doneButtonTapped(button:)))
return textView
}
...
}
Use it
Now we can finally use an UITextView
right in SwiftUI without any further changes. The only thing which we need to care of, is that the TextView
needs a dedicated height, but this can be easily set with the .frame()
modifier. In the snippet below we are setting the height to be fixed at 200 pts and the width to fill all the available space.
struct ContentView: View {
@State var text: String = ""
var body: some View {
TextView(text: self.$text)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 200, maxHeight: 200)
}
}
Conclusion
We've seen, that with the help of UIViewRepresentable
we can bridge the gap between missing UIKit components and SwiftUI, until all components have been natively ported by Apple itself, rather easily.
I hope that this post helps to give you a glimpse about what you can do to enhance SwiftUI with missing components from UIKit.
Hope to see you soon for the next article and stay healthy in this rough times!