18

I have a TextField and some actionable elements like Button, Picker inside a view. I want to dismiss the keyboard when the use taps outside the TextField. Using the answers in this question, I achieved it. However the problem comes with other actionable items.

When I tap a Button, the action takes place but the keyboard is not dismissed. Same with a Toggle switch. When I tap on one section of a SegmentedStyle Picker, the keyboard is dimissed but the picker selection doesn't change.

Here is my code.


struct SampleView: View {

    @State var selected = 0
    @State var textFieldValue = ""

    var body: some View {
        VStack(spacing: 16) {
            TextField("Enter your name", text: $textFieldValue)
                .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
                .background(Color(UIColor.secondarySystemFill))
                .cornerRadius(4)


            Picker(selection: $selected, label: Text(""), content: {
                Text("Word").tag(0)
                Text("Phrase").tag(1)
                Text("Sentence").tag(2)
            }).pickerStyle(SegmentedPickerStyle())            

            Button(action: {
                self.textFieldValue = "button tapped"
            }, label: {
                Text("Tap to change text")
            })

        }.padding()
        .onTapGesture(perform: UIApplication.dismissKeyboard)
//        .gesture(TapGesture().onEnded { _ in UIApplication.dismissKeyboard()})
    }
}

public extension UIApplication {

    static func dismissKeyboard() {
        let keyWindow = shared.connectedScenes
                .filter({$0.activationState == .foregroundActive})
                .map({$0 as? UIWindowScene})
                .compactMap({$0})
                .first?.windows
                .filter({$0.isKeyWindow}).first
        keyWindow?.endEditing(true)
    }
}

As you can see in the code, I tried both options to get the tap gesture and nothing worked.

3
  • 1
    From the link in you post this answer works just perfect with any controls, why did you choose other approach (as for me it is definitely not reliable)? Commented Feb 22, 2020 at 7:13
  • Thanks for pointing out. I lost hope after trying the first 5-6 answers and posted this question. Commented Feb 22, 2020 at 7:37
  • @Asperi it could work, unfortunately, you lose build-in textfield behavior (text selection, etc .) ... generally, it doesn't exist a universal solution, I prefer to solve it ad hoc (case by case) Commented Feb 22, 2020 at 9:31

8 Answers 8

23

You can create an extension on View like so

extension View {
  func endTextEditing() {
    UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder),
                                    to: nil, from: nil, for: nil)
  }
}

and use it for the Views you want to dismiss the keyboard.

.onTapGesture {

      self.endTextEditing()
} 

I have just seen this solution in a recent raywenderlich tutorial so I assume it's currently the best solution.

Sign up to request clarification or add additional context in comments.

3 Comments

on which control you will apply .onTapGesture modifier, if Picker and Button should work? unfortunately, this approach is not usable here ...
I think you can just apply .onTapGesture on the VStack just like how the OP did it no?
I applied it on the main Encompassing view and it worked like a charm. I renamed the method to dismissKeyboard though. Thanks for this
9

I'd like to take Mark T.s Answer even further and add the entire function to an extension for View:

extension View {
    func hideKeyboardWhenTappedAround() -> some View  {
        return self.onTapGesture {
            UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), 
                  to: nil, from: nil, for: nil)
        }
    }
}

Can then be called like:

var body: some View {
    MyView()
      // ...
      .hideKeyboardWhenTappedAround()
      // ...
}

2 Comments

Unfortunately this stops taps on navigation links etc
For me is working perfect!
8

Dismiss the keyboard by tapping anywhere (like others suggested) could lead to very hard to find bug (or unwanted behavior).

  1. you loose default build-in TextField behaviors, like partial text selection, copy, share etc.
  2. onCommit is not called

I suggest you to think about gesture masking based on the editing state of your fields

/// Attaches `gesture` to `self` such that it has lower precedence
    /// than gestures defined by `self`.
    public func gesture<T>(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture

this help us to write

.gesture(TapGesture().onEnded({
            UIApplication.shared.windows.first{$0.isKeyWindow }?.endEditing(true)
        }), including: (editingFlag) ? .all : .none)

Tap on the modified View will dismiss the keyboard, but only if editingFlag == true. Don't apply it on TextField! Otherwise we are on the beginning of the story again :-)

This modifier will help us to solve the trouble with Picker but not with the Button. That is easy to solve while dismiss the keyboard from its own action handler. We don't have any other controls, so we almost done

Finally we have to find the solution for rest of the View, so tap anywhere (excluding our TextFields) dismiss the keyboard. Using ZStack filled with some transparent View is probably the easiest solution.

Let see all this in action (copy - paste - run in your Xcode simulator)

import SwiftUI
struct ContentView: View {

    @State var selected = 0

    @State var textFieldValue0 = ""
    @State var textFieldValue1 = ""

    @State var editingFlag = false

    @State var message = ""

    var body: some View {
        ZStack {
            // TODO: make it Color.clear istead yellow
            Color.yellow.opacity(0.1).onTapGesture {
                UIApplication.shared.windows.first{$0.isKeyWindow }?.endEditing(true)
            }
            VStack {

                TextField("Salutation", text: $textFieldValue0, onEditingChanged: { editing in
                    self.editingFlag = editing
                }, onCommit: {
                    self.onCommit(txt: "salutation commit")
                })
                    .padding()
                    .background(Color(UIColor.secondarySystemFill))
                    .cornerRadius(4)

                TextField("Welcome message", text: $textFieldValue1, onEditingChanged: { editing in
                    self.editingFlag = editing
                }, onCommit: {
                    self.onCommit(txt: "message commit")
                })
                    .padding()
                    .background(Color(UIColor.secondarySystemFill))
                    .cornerRadius(4)

                Picker(selection: $selected, label: Text(""), content: {
                    Text("Word").tag(0)
                    Text("Phrase").tag(1)
                    Text("Sentence").tag(2)
                })
                    .pickerStyle(SegmentedPickerStyle())
                    .gesture(TapGesture().onEnded({
                        UIApplication.shared.windows.first{$0.isKeyWindow }?.endEditing(true)
                    }), including: (editingFlag) ? .all : .none)


                Button(action: {
                    self.textFieldValue0 = "Hi"
                    print("button pressed")
                    UIApplication.shared.windows.first{$0.isKeyWindow }?.endEditing(true)
                }, label: {
                    Text("Tap to change salutation")
                        .padding()
                        .background(Color.yellow)
                        .cornerRadius(10)
                })

                Text(textFieldValue0)
                Text(textFieldValue1)
                Text(message).font(.largeTitle).foregroundColor(Color.red)

            }

        }
    }

    func onCommit(txt: String) {
        print(txt)
        self.message = [self.textFieldValue0, self.textFieldValue1].joined(separator: ", ").appending("!")
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

enter image description here

If you miss onCommit (it is not called while tap outside TextField), just add it to your TextField onEditingChanged (it mimics typing Return on keyboard)

TextField("Salutation", text: $textFieldValue0, onEditingChanged: { editing in
    self.editingFlag = editing
    if !editing {
        self.onCommit(txt: "salutation")
    }
 }, onCommit: {
     self.onCommit(txt: "salutation commit")
 })
     .padding()
     .background(Color(UIColor.secondarySystemFill))
     .cornerRadius(4)

Comments

1

@user3441734 is smart to enable the dismiss gesture only when needed. Rather than forcing every crevice of your forms to track state, you can:

  1. Monitor UIWindow.keyboardWillShowNotification / willHide

  2. Pass the current keyboard state via an EnvironmentKey set at the/a root view

Tested for iOS 14.5.

Attach dismiss gesture to the form

Form { }
    .dismissKeyboardOnTap()

Setup monitor in root view

// Root view
    .environment(\.keyboardIsShown, keyboardIsShown)
    .onDisappear { dismantleKeyboarMonitors() }
    .onAppear { setupKeyboardMonitors() }

// Monitors

    @State private var keyboardIsShown = false
    @State private var keyboardHideMonitor: AnyCancellable? = nil
    @State private var keyboardShownMonitor: AnyCancellable? = nil
    
    func setupKeyboardMonitors() {
        keyboardShownMonitor = NotificationCenter.default
            .publisher(for: UIWindow.keyboardWillShowNotification)
            .sink { _ in if !keyboardIsShown { keyboardIsShown = true } }
        
        keyboardHideMonitor = NotificationCenter.default
            .publisher(for: UIWindow.keyboardWillHideNotification)
            .sink { _ in if keyboardIsShown { keyboardIsShown = false } }
    }
    
    func dismantleKeyboarMonitors() {
        keyboardHideMonitor?.cancel()
        keyboardShownMonitor?.cancel()
    }

SwiftUI Gesture + Sugar


struct HideKeyboardGestureModifier: ViewModifier {
    @Environment(\.keyboardIsShown) var keyboardIsShown
    
    func body(content: Content) -> some View {
        content
            .gesture(TapGesture().onEnded {
                UIApplication.shared.resignCurrentResponder()
            }, including: keyboardIsShown ? .all : .none)
    }
}

extension UIApplication {
    func resignCurrentResponder() {
        sendAction(#selector(UIResponder.resignFirstResponder),
                   to: nil, from: nil, for: nil)
    }
}

extension View {

    /// Assigns a tap gesture that dismisses the first responder only when the keyboard is visible to the KeyboardIsShown EnvironmentKey
    func dismissKeyboardOnTap() -> some View {
        modifier(HideKeyboardGestureModifier())
    }
    
    /// Shortcut to close in a function call
    func resignCurrentResponder() {
        UIApplication.shared.resignCurrentResponder()
    }
}

EnvironmentKey

extension EnvironmentValues {
    var keyboardIsShown: Bool {
        get { return self[KeyboardIsShownEVK] }
        set { self[KeyboardIsShownEVK] = newValue }
    }
}

private struct KeyboardIsShownEVK: EnvironmentKey {
    static let defaultValue: Bool = false
}

Comments

0

You can set .allowsHitTesting(false) to your Picker to ignore the tap on your VStack

1 Comment

This is not working. I've tried using all possible combinations with .allowsHitTesting and the elements. Even tried putting a ZStack but of no avail.
0

I had a similar problem and I solved it on iOS 15+ without using UIKit in a way that in your example would look more or less like below. It also worked for me in another case when I added onTapGesture on a List with NavigationLinks displayed below a TextField.

struct SampleView: View {
    @State var selected = 0
    @State var textFieldValue = ""
    @FocusState private var keyboardShown: Bool

    var body: some View {
        VStack(spacing: 16) {
            TextField("Enter your name", text: $textFieldValue)
                .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
                .background(Color(UIColor.secondarySystemFill))
                .cornerRadius(4)
                .autocorrectionDisabled()
                .focused($keyboardShown) // save info if keyboard opened

            VStack {
                Picker(selection: $selected, label: Text(""), content: {
                    Text("Word").tag(0)
                    Text("Phrase").tag(1)
                    Text("Sentence").tag(2)
                }).pickerStyle(SegmentedPickerStyle())
                
                Button(action: {
                    self.textFieldValue = "button tapped"
                }, label: {
                    Text("Tap to change text")
                })
                
                Spacer() // let's also dismiss when we get a tap on empty space here
            }
            .contentShape(Rectangle()) // without this tap to dismiss on Spacer does not work
            .onTapGesture(count: keyboardShown ? 1 : .max, perform: { // if keyboard shown use single tap to close it, otherwise set .max to not interfere with other stuff
                keyboardShown = false
            })
        }.padding()
    }
}

Comments

0

I was still struggling with this issue, my workaround is as follows:

extension View {
    func keyboardDismiss() -> some View {
        modifier(KeyboardDismiss())
    }
}

private struct KeyboardDismiss: ViewModifier {
    func body(content: Content) -> some View {
        content.onTapGesture {
            UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
        }
    }
}

I am using the standard solution that is proposed everywhere. Then I use it in my form sections:

Section {}.keyboardDismiss()

then if there is a Picker in the section I use following modifiers to enable the interaction with the picker:

Picker()
   .contentShape(Rectangle())
   .allowsHitTesting(false)

Comments

-1

Apply this to root view

.onTapGesture {
    UIApplication.shared.endEditing()
}

1 Comment

Unfortunately this will also disable taping any other controls in the same view.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.