SwiftUI, Apple’s declarative framework for rapid user interface development, has continued to mature as a useful alternative to UIKit with a 2.0 release at WWDC this year. As a newbie to the framework, I enjoy the instant feedback that the canvas preview provides with each code modification and am thoroughly impressed by the elegance and simplicity of SwiftUI’s design. That being said, I have run into odd behaviors and challenging problems that make me want to tear my hair out at times. One such issue, which at first seems trivial, is how to dismiss the keyboard when a user taps an area on the screen that is outside the bounds of a TextField or TextEditor view. Here, I will discuss a few of the common issues surrounding keyboard dismissal and provide two solutions and workarounds that I have found after an embarrassing amount of googling and combing of StackOverflow.
Notes: 1. A demo iOS app that accompanies this post is available on GitHub. 2. This article was written using XCode 12.1, SwiftUI 2.0, and compiled for iOS 14.1.
The crux of the problem
SwiftUI doesn’t currently expose a mechanism for manual keyboard dismissal from within its own framework, so we have to fall back on UIKit. In UIKit, views that utilize the keyboard such as UITextField
and UITextView
can be dismissed by calling resignFirstResponder on the view itself, or endEditing on one of its parent views. Code in a simple view controller might look like this:
class ViewController: UIViewController {
@IBOutlet weak var textField: UITextField!
override func viewDidLoad() {
super.viewDidLoad()
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
self.view.addGestureRecognizer(tapGesture)
}
@IBAction func buttonPressed(_ sender: Any) {
dismissKeyboardFrom(view: textField)
}
@objc func dismissKeyboardFrom(view: UIView) {
view.resignFirstResponder()
// or view.endEditing()
}
@objc func dismissKeyboard() {
self.view.endEditing(true)
}
}
Here I have implemented both options for keyboard dismissal on a UITextField named textField
. To add support for keyboard dismissal on taps outside of textField
we add a tap gesture to the view controller’s view in viewDidLoad
. When an outside tap is recognized dismissKeyboard
gets called and the view controller’s view calls endEditing. This in turn leads to textField calling resignFirstResponder, dismissing the keyboard. As a second option, we also have a button that when pressed calls dismissKeyboardFrom(view:). Here we pass in textField
and directly call resignFirstResponder to dismiss the keyboard.
Side note: Both of these methods utilize the responder chain. If you are unfamiliar with the responder chain or need a refresher on UIResponder, UIEvent, UIControl, and how touch events and other gestures are handled in UIKit I would highly recommend reading this excellent article written by Bruno Rocha from Better Programming on Medium. The official docs on the responder chain from Apple are also extremely helpful and well written.
Solutions
So how do we bring this behavior into SwiftUI?
Option 1
The first solution I found comes from HackingWithSwift. Here, they suggest extending the View
protocol and utilizing the responder chain to send out a resignFirstResponder
message. The extension looks like this:
#if canImport(UIKit)
extension View {
func hideKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
#endif
This solution works beautifully if you attach the function to a submit button as they do in their example:
struct ContentView: View {
@State private var tipAmount = ""
var body: some View {
VStack {
TextField("Name: ", text: $tipAmount)
.textFieldStyle(RoundedBorderTextFieldStyle())
.keyboardType(.decimalPad)
Button("Submit") {
print("Tip: \(self.tipAmount)")
self.hideKeyboard()
}
}
}
}
One would have hoped we could extend this idea by adding a tap gesture view modifier to a VStack
or Form
to get the keyboard to dismiss on an outside tap as in:
...
VStack {
TextView("", $myText)
}
.onTapGesture {
self.dismissKeyboard()
}
...
But this won’t work as expected. The first problem is that views created in SwiftUI typically only cover a portion of the screen, unlike the view associated with a view controller, which usually covers the entire screen. In the example on the right I have colored the background of the VStack
pink and as you can see, adding dismissKeyboard
to the VStack will still leave a lot of non-interactive whitespace, which may be frustrating to users as they have to find just the right spot to get the behavior they expect.
The second problem is that when you try and use dismissKeyboard
in a Form
it may break some of the other interactive controls in the view. For example, if we have something like:
struct FormViewFix1: View {
@State var name = ""
@State var yearsExpereince: Int = 0
var body: some View {
NavigationView {
Form {
Section(header: Text("Applicant Info"), content: {
HStack {
Text("Name:")
.fontWeight(.thin)
TextField("", text: $name).background(Color(.systemYellow).opacity(0.3))
}
Picker("Years of experience:",
selection: $yearsExpereince,
content: {
ForEach(0..<11) { years in
Text("\(years)")
}
})
HStack {
Spacer()
Button(action: {
print("Submit button pressed.")
},
label: {
Text("Submit")
})
.padding(3)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(lineWidth: 1.5)
.foregroundColor( Color(.systemBlue) )
)
Spacer()
}
})
}
.onTapGesture {
self.hideKeyboard()
}
.navigationTitle(Text("Application"))
}
}
}
Our keyboard will show and dismiss, but we can no longer interact with the picker view or press the submit button. Clearly, we need to try something else.
Option 2
The second fix I am going to show you adds keyboard dismissal App-wide and fixes the issues we observed in the last section. The original solution and other ideas from StackOverflow can be found here. In short, you can add a tap gesture recognizer to the underlying UIWindow
of the app that acts in a similar fashion to how we set up keyboard dismissal in UIKit by adding a tap gesture to the view of our view controller. To begin, extend UIApplication with a new method:
extension UIApplication {
func addTapGestureRecognizer() {
guard let window = windows.first else { return }
let tapGesture = UITapGestureRecognizer(target: window, action: #selector(UIView.endEditing))
tapGesture.cancelsTouchesInView = false
tapGesture.delegate = self
tapGesture.name = "MyTapGesture"
window.addGestureRecognizer(tapGesture)
}
}
The addTapGestureRecognizer
method creates a UITapGestureRecognizer
, tapGesture
, that will target the App’s window and call UIView.endEditing
to dismiss the keyboard. Since we still want the other parts of the user experience to function as normal, we set cancelsTouchesInView
to false
. We set the UIApplication
as the delegate for tapGesture
(you’ll see why in a minute) and add the gesture to the first window in the windows array of UIApplication
.
Simultaneous gestures, such as a double tap, are important for selecting text in a TextView
. If we leave things as they stand, by default our new tap gesture will get called for both double and single taps, meaning when we try and double tap to select text, it will dismiss the keyboard. Oops! To fix this we can implement the gestureRecognizer(_ gestureRecognizer: , shouldRecognizeSimultaneouslyWith otherGestureRecognizer: ) delegate method of UIGestureRecognizerDelegate
and return false.
extension UIApplication: UIGestureRecognizerDelegate {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return false // set to `false` if you don't want to detect tap during other gestures
}
}
The last step is to add the gesture to the app window when the application launches, which can be done like this if you are using the new SwiftUI App lifecycle:
@main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onAppear(perform: UIApplication.shared.addTapGestureRecognizer)
}
}
}
Or, if you are still using the UIKit lifecycle you can instead skip extending UIApplication and set up your SceneDelegate similar to:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = ContentView()
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
let tapGesture = UITapGestureRecognizer(target: window, action: #selector(UIView.endEditing))
tapGesture.cancelsTouchesInView = false
tapGesture.delegate = self
tapGesture.name = "MyTapGesture"
window.addGestureRecognizer(tapGesture)
}
}
}
extension SceneDelegate: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return false
}
}
The final results give us the user interaction we were looking for. Touching anywhere outside of the TextField dismisses the keyboard and all of our other controls work as expected. Success!
Final Thoughts
The second solution appears to give us back the behavior we are interested in, but I do have a few concerns that should be mentioned. First, I haven’t played much with apps that support multiple windows for iPads, but I suspect this current fix may not work as expected in a multi-window scenario. Second, I am a bit concerned future updates to SwiftUI and its inner workings may break this current fix, or worse, introduce new bugs that are hard to diagnose. My hope is that in the next update to SwiftUI Apple will give us an official solution to manual keyboard dismissal. At least we have a solution that can keep us productive while we wait.