iOS 16 keyboard safe area not updated on push - ios

There's a strange keyboard issue on iOS 16, when pushing new screens. It seems the keyboard safe area is not updated when you come back from the pushed screen.
It's even reproducible with this chunk of code on an empty project:
struct ContentView: View {
#State var text = ""
var body: some View {
NavigationView {
VStack {
Spacer()
NavigationLink {
Text("test")
} label: {
Text("Tap me")
}
TextField("", text: $text)
.textFieldStyle(.roundedBorder)
}
.padding()
}
}
}
Steps to reproduce:
Open the keyboard
Press the button "tap me" and navigate to the other screen
Quickly come back to the previous screen
The keyboard is dismissed, but there's a large gap that fits the keyboard size.
Anyone else had a similar issue?

I found 2 ways to solve this problem and both will need to hide the keyboard before you go to the next screen
Add hide keyboard to the button which activates navigation to another view
#State var isActive: Bool = false
var body: some View {
NavigationView {
ZStack {
NavigationLink(isActive: $isActive, destination: { Text("Hello") }, label: EmptyView.init)
VStack {
TextField("Text here", text: .constant(""))
Button("Press me") {
resignFirstResponder()
isActive.toggle()
}
}
}
}
}
Add hide keyboard to onChange block
#State var isActive: Bool = false
var body: some View {
NavigationView {
ZStack {
NavigationLink(isActive: $isActive, destination: { Text("Hello") }, label: EmptyView.init)
.onChange(of: isActive) { newValue in
if newValue {
resignFirstResponder()
}
}
VStack {
TextField("Text here", text: .constant(""))
Button("Press me") {
isActive.toggle()
}
}
}
}
}
Code for hide keyboard:
public func resignFirstResponder() {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}

I have found a temporary workaround, it's not pretty but it does the job of removing the empty space that was previously occupied by keyboard. The solution is to call parent view from child in onDisappear, then in parent have a hidden TextField that is focused and almost immediately unfocused.
In parent view add properties:
#State private var dummyText = ""
#FocusState private var dummyFocus: Bool
And put a TextField somewhere in the parent view, inside a ZStack for example:
ZStack {
TextField("", text: $dummyText)
.focused($dummyFocus)
.opacity(0.01)
... your other layout ...
}
then call/navigate to the child view with completion block like this:
ChildView(didDismiss: {
if #available(iOS 16.0, *) {
dummyFocus = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
dummyFocus = false
}
}
})
In child view add property:
var didDismiss: () -> Void
and call the completion block in child's onDisappear:
.onDisappear {
didDismiss()
}
onDisappear and didDismiss() will only be called after the whole interactive swipe back animation completes. The code checks for iOS 16 so that it doesn't unnecessarily execute on prior versions.

I have come to another fix based on Frin's solution. In my case, all our SwiftUI views are embedded into some parent UIViewController since we have an app that is partially migrated to SwiftUI. What I did is to have a small class (KeyboardLayoutGuideFix) that creates a dummy textfield to capture the focus and then observes the view controller lifecycle to do:
On view disappear: if iOS16, put focus on the dummy textfield
On view appear: remove the focus from dummy textfield
This way, the keyboard layout seems to work as expected, although the keyboard will be dismissed next time you come back to the screen (this is the expected behavior in our case).
Here is the code:
public class KeyboardLayoutGuideFix: Behavior {
private weak var viewController: UIViewController?
private lazy var dummyTextField: UITextField = {
UITextField(frame: .zero).apply { text in
viewController?.view.addSubview(text)
text.alpha = 0
}
}()
private var needsEndEditing = false
private var disposeBag = Set<AnyCancellable>()
private init(viewController: UIViewController, lifeCycle: ControllerLifeCycle) {
self.viewController = viewController
super.init(frame: .zero)
lifeCycle.$isPresented.sink { [weak self] presented in
guard let self else { return }
if presented {
if self.needsEndEditing {
self.needsEndEditing = false
DispatchQueue.main.async {
self.viewController?.view.endEditing(true)
}
}
} else {
self.dummyTextField.becomeFirstResponder()
self.needsEndEditing = true
}
}.store(in: &disposeBag)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public static func apply(viewController: PlaytomicViewController) {
apply(viewController: viewController, lifeCycle: viewController.lifecycle)
}
public static func apply(viewController: UIViewController, lifeCycle: ControllerLifeCycle) {
if #available(iOS 16, *) {
let fix = KeyboardLayoutGuideFix(viewController: viewController, lifeCycle: lifeCycle)
fix.owner = viewController
}
}
}
and then use it in the container VC like:
override func viewDidLoad() {
super.viewDidLoad()
KeyboardLayoutGuideFix.apply(viewController: self)
}
Note that you will miss the following objects to make this work in your project, but you can adapt it to your own codebase:
Behavior: a class that allows you to assign dynamically other objects to a parent one, in this case it assigns the fix to the associated view controller, preventing the deallocation. You can remove it and use a local variable in your VC containing a reference to the fix
ControllerLifeCycle: A class that exposes a publisher to track the presentation state of the ViewController. You can replace it by explicit calls in viewWillAppear and viewWillDisappear
PlaytomicViewController: Base class that provides the lifecycle and updates the published property when appear/disappear

Related

SwiftUI - Dismissing a view, app goes back to content view instead of last view

I have 3 views within my app where clicking on a button on each view opens a new view. When button is clicked on 3rd view, I wish to dismiss 3rd view and 2nd view should appear. However I am noticing that app navigates back to first view instead of 2nd view.
Note: I have lots of elements, hence lots of code in my app. I removed all of them and adding minimal working code here with which I am still able to repro the problem.
// *** Main App***
#main
struct sample_sampleApp: App {
var body: some SwiftUI.Scene {
WindowGroup {
NavigationView {
ContentView().ignoresSafeArea()
}.navigationViewStyle(.stack)
}
}
}
// *** Content View or First View***
import SwiftUI
struct ContentView: View {
#State private var goToView2 = false
var body: some View {
VStack {
NavigationLink(destination: View2(), isActive: $goToView2) {
Button(action: { goToView2.toggle() }) {
Text("This is first view - Click to go to View 2").foregroundColor(.red).font(.title)
}
}
}
}
}
// *** View2 View***
import SwiftUI
import Combine
struct View2: View {
#ObservedObject var viewModel: View2ViewModel = View2ViewModel()
var body: some View {
VStack {
switch viewModel.state {
case .showView2:
VStack(alignment: .leading, spacing: 8) {
Button(action: { viewModel.navigateToView3() } ) {
Text("Second View - Click to go to View 3").foregroundColor(.blue).font(.title)
}
}
case .showView3:
View3()
}
}
.onAppear() {
viewModel.isViewVisible = true
viewModel.doSomething()
}
.onDisappear() {
viewModel.isViewVisible = false
}
}
}
// *** View model for view 2***
class View2ViewModel: ObservableObject {
enum View2AppState {
case showView2
case showView3
}
// UI changes when state gets updated.
#Published var state: View2AppState = .showView2
var isViewVisible = false
func doSomething() {
self.state = .showView2
}
func navigateToView3() {
self.state = .showView3
}
}
// *** View3***
struct View3: View {
#ObservedObject var viewModel: View3ViewModel = View3ViewModel()
#Environment(\.dismiss) var dismiss
var body: some View {
VStack {
switch viewModel.state {
case .showView3:
VStack(alignment: .leading, spacing: 8) {
Button(action: { dismiss() } ) {
Text("Third View - Click to dismiss this and to go back to view 2").foregroundColor(.green).font(.title)
}
}
}
}
.onAppear() {
viewModel.isViewVisible = true
viewModel.doSomething()
}
.onDisappear() {
viewModel.isViewVisible = false
}.navigationBarBackButtonHidden(true)
}
}
// *** View model for view 3***
class View3ViewModel: ObservableObject {
enum View3AppState {
case showView3
}
// UI changes when state gets updated.
#Published var state: View3AppState = .showView3
var isViewVisible = false
func doSomething() {
self.state = .showView3
}
}
Not sure what am I doing wrong. Sometime back I did use dismiss() while dismissing sheet and it worked fine, but not this this case. I am running this code on iOS 16.0, however my test app is set to iOS 15 as minimum version.
Edit: I tested on iOS 15.0 as well and was able to repro on it too, so something must be wrong with my code then. Not able to figure out what. I am using NavigationView in and navigation view style as Stack.
When button is clicked on 3rd view, I wish to dismiss 3rd view and 2nd view should appear.
Let's first have a look at the code of view2.
struct View2: View {
#ObservedObject var viewModel: View2ViewModel = View2ViewModel()
var body: some View {
VStack {
switch viewModel.state {
case .showView2:
VStack(alignment: .leading, spacing: 8) {
Button(action: { viewModel.navigateToView3() } ) {
Text("Second View - Click to go to View 3").foregroundColor(.blue).font(.title)
}
}
case .showView3:
View3()
}
}
}
}
// here viewModel.navigateToView3() is just changing this state
// func navigateToView3() {
// self.state = .showView3
// }
The current code behavior, when tapping to navigate to view3, replaces the content of view2 with view3 instead of actually navigating to it.
Therefore, when the dismiss function is called, it should not return to view2 as it is already in view2 displaying the content of view3.
So, going back to view1 on the dismiss press is actually the correct behavior as per the code.
If you desire the outcome you are asking, consider modifying the code using a closure passed into the child view to change the state in view2 or explore this answer to actually navigate to it.

Child subviews not resetting when a new parent is created in SwiftUI

I made a post about this yesterday and apologize for it not being clear or descriptive enough. Today I've made some more progress on the problem but still haven't found a solution.
In my program I have a main view called GameView(), a view called KeyboardView() and a view called ButtonView().
A ButtonView() is a simple button that displays a letter and when pressed tells the keyboardView it belongs to what letter it represents. When it's pressed it is also toggled off so that it cannot be pressed again. Here is the code.
struct ButtonView: View {
let impactFeedbackgenerator = UIImpactFeedbackGenerator(style: .medium)
var letter:String
var function:() -> Void
#State var pressed = false
var body: some View {
ZStack{
Button(action: {
if !self.pressed {
self.pressed = true
self.impactFeedbackgenerator.prepare()
self.impactFeedbackgenerator.impactOccurred()
self.function()
}
}, label: {
if pressed{
Text(letter)
.font(Font.custom("ComicNeue-Bold", size: 30))
.foregroundColor(.white)
.opacity(0.23)
} else if !pressed{
Text(letter)
.font(Font.custom("ComicNeue-Bold", size: 30))
.foregroundColor(.white)
}
})
}.padding(5)
}
}
A keyboard view is a collection of ButtonViews(), one for each button on the keyboard. It tells the GameView what button has been pressed if a button is pressed.
struct KeyboardView: View {
#Environment(\.parentFunction) var parentFunction
var topRow = ["Q","W","E","R","T","Y","U","I","O","P"]
var midRow = ["A","S","D","F","G","H","J","K","L"]
var botRow = ["Z","X","C","V","B","N","M"]
var body: some View {
VStack{
HStack(){
ForEach(0..<topRow.count, id: \.self){i in
ButtonView(letter: self.topRow[i], function: {self.makeGuess(self.topRow[i])})
}
}
HStack(){
ForEach(0..<midRow.count, id: \.self){i in
ButtonView(letter: self.midRow[i], function: {self.makeGuess(self.midRow[i])})
}
}
HStack(){
ForEach(0..<botRow.count, id: \.self){i in
ButtonView(letter: self.botRow[i], function: {self.makeGuess(self.botRow[i])})
}
}
}
}
func makeGuess(_ letter:String){
print("Keyboard: Guessed \(letter)")
self.parentFunction?(letter)
}
}
Finally a GameView() is where the keyboard belongs. It displays the keyboard along with the rest of the supposed game.
struct GameView: View {
#Environment(\.presentationMode) var presentation
#State var guessedLetters = [String]()
#State var myKeyboard:KeyboardView = KeyboardView()
var body: some View {
ZStack(){
Image("Background")
.resizable()
.edgesIgnoringSafeArea(.all)
.opacity(0.05)
VStack{
Button("New Game") {
self.newGame()
}.font(Font.custom("ComicNeue-Bold", size: 20))
.foregroundColor(.white)
.padding()
self.myKeyboard
.padding(.bottom, 20)
}
}.navigationBarTitle("")
.navigationBarBackButtonHidden(true)
.navigationBarHidden(true)
.environment(\.parentFunction, parentFunction)
}
func makeGuess(_ letter:String){
self.guessedLetters.append(letter)
}
func newGame(){
print("Started a new game.")
self.guessedLetters.removeAll()
self.myKeyboard = KeyboardView()
}
func parentFunction(_ letter:String) {
makeGuess(letter)
}
}
struct ParentFunctionKey: EnvironmentKey {
static let defaultValue: ((_ letter:String) -> Void)? = nil
}
extension EnvironmentValues {
var parentFunction: ((_ letter:String) -> Void)? {
get { self[ParentFunctionKey.self] }
set { self[ParentFunctionKey.self] = newValue }
}
}
The issue is that when I start a new game, the array is reset but not keyboardView(), the buttons that have been toggled off remain off, but since it's being replaced by a new keyboardView() shouldn't they go back to being toggled on?
I'll repeat what I said in an answer to your previous question - under most normal use cases you shouldn't instantiate views as variables, so if you find yourself doing that, you might be on the wrong track.
Whenever there's any state change, SwiftUI recomputes the body and reconstructs the view tree, and matches the child view states to the new tree.
When it detects that something has changed, it realizes that the new child view is truly new, so it resets its state, fires .onAppear and so forth. But when there's no change that it can detect, then it just keeps the same state for all the descendent views.
That's what you're observing.
Specifically, in your situation nothing structurally has changed - i.e. it's still:
GameView
--> KeyboardView
--> ButtonView
ButtonView
ButtonView
...
so, it keeps the state of ButtonViews as is.
You can signal to SwiftUI that the view has actually changed and that it should be updated by using an .id modifier (documentation isn't great, but you can find more info in blogs), where you need to supply any Hashable variable to it that's different than the current one, in order to reset it.
I'll use a new Bool variable as an example:
struct GameView {
#State var id: Bool = false // initial value doesn't matter
var body: some View {
VStack() {
KeyboardView()
.id(id) // use the id here
Button("new game") {
self.id.toggle() // changes the id
}
}
}
}
Every time the id changes, SwiftUI resets the state, so all the child views', like ButtonViews', states are reset.

Remove back button text from navigationbar in SwiftUI

I've recently started working in SwiftUI, came to the conclusion that working with navigation isn't really great yet. What I'm trying to achieve is the following. I finally managed to get rid of the translucent background without making the application crash, but now I ran into the next issue. How can I get rid of the "back" text inside the navbaritem?
I achieved the view above by setting the default appearance in the SceneDelegate.swift file like this.
let newNavAppearance = UINavigationBarAppearance()
newNavAppearance.configureWithTransparentBackground()
newNavAppearance.setBackIndicatorImage(UIImage(named: "backButton"), transitionMaskImage: UIImage(named: "backButton"))
newNavAppearance.titleTextAttributes = [
.font: UIFont(name: GTWalsheim.bold.name, size: 18)!,
.backgroundColor: UIColor.white
]
UINavigationBar.appearance().standardAppearance = newNavAppearance
One possible way that I could achieve this is by overriding the navigation bar items, however this has one downside (SwiftUI Custom Back Button Text for NavigationView) as the creator of this issue already said, the back gesture stops working after you override the navigation bar items. With that I'm also wondering how I could set the foregroundColor of the back button. It now has the default blue color, however I'd like to overwrite this with another color.
Piggy-backing on the solution #Pitchbloas offered, this method just involves setting the backButtonDisplayMode property to .minimal:
extension UINavigationController {
open override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
navigationBar.topItem?.backButtonDisplayMode = .minimal
}
}
It's actually really easy. The following solution is the fastest and cleanest i made.
Put this at the bottom of your SceneDelegate for example.
extension UINavigationController {
// Remove back button text
open override func viewWillLayoutSubviews() {
navigationBar.topItem?.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
}
}
This will remove the back button text from every NavigationView (UINavigationController) in your app.
I have found a straightforward approach to remove the back button text using SwiftUI only, and keeping the original chevron.
A drag gesture is added to mimic the classic navigation back button
when user wants to go back by swiping right. Following this, an extension of View is created to create a SwiftUI like modifier.
This is how to use it in code:
import SwiftUI
struct ContentView: View {
var body: some View {
ZStack {
// Your main view code here with a ZStack to have the
// gesture on all the view.
}
.navigationBarBackButtonTitleHidden()
}
}
This is how to create the navigationBarBackButtonTitleHidden() modifier:
import SwiftUI
extension View {
func navigationBarBackButtonTitleHidden() -> some View {
self.modifier(NavigationBarBackButtonTitleHiddenModifier())
}
}
struct NavigationBarBackButtonTitleHiddenModifier: ViewModifier {
#Environment(\.dismiss) var dismiss
#ViewBuilder #MainActor func body(content: Content) -> some View {
content
.navigationBarBackButtonHidden(true)
.navigationBarItems(
leading: Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.foregroundColor(.blue)
.imageScale(.large) })
.contentShape(Rectangle()) // Start of the gesture to dismiss the navigation
.gesture(
DragGesture(coordinateSpace: .local)
.onEnded { value in
if value.translation.width > .zero
&& value.translation.height > -30
&& value.translation.height < 30 {
dismiss()
}
}
)
}
}
Standard Back button title is taken from navigation bar title of previous screen.
It is possible the following approach to get needed effect:
struct TestBackButtonTitle: View {
#State private var hasTitle = true
var body: some View {
NavigationView {
NavigationLink("Go", destination:
Text("Details")
.onAppear {
self.hasTitle = false
}
.onDisappear {
self.hasTitle = true
}
)
.navigationBarTitle(self.hasTitle ? "Master" : "")
}
}
}
So I actually ended up with the following solution that actually works. I am overwriting the navigation bar items like so
.navigationBarItems(leading:
Image("backButton")
.foregroundColor(.blue)
.onTapGesture {
self.presentationMode.wrappedValue.dismiss()
}
)
The only issue with this was that the back gesture wasn't working so that was solved by actually extending the UINavigationController
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
}
Now it's looking exactly the way I want it, the solution is kinda hacky... but it works for now, hopefully SwiftUI will mature a little bit so this can be done easier.
Using the Introspect framework, you can easily gain access to the underlying navigation item and set the backButtonDisplayMode to minimal.
Here’s how you might use that in the view that was pushed
var body: some View {
Text("Your body here")
.introspectNavigationController { navController in
navController.navigationBar.topItem?.backButtonDisplayMode = .minimal
}
}
If you want to:
Do it globally
Keep the standard back button (along with custom behaviours like an ability to navigate a few screens back on a long press)
Avoid introducing any third party frameworks
You can do it by setting the back button text color to Clear Color via appearance:
let navigationBarAppearance = UINavigationBarAppearance()
let backButtonAppearance = UIBarButtonItemAppearance(style: .plain)
backButtonAppearance.focused.titleTextAttributes = [.foregroundColor: UIColor.clear]
backButtonAppearance.disabled.titleTextAttributes = [.foregroundColor: UIColor.clear]
backButtonAppearance.highlighted.titleTextAttributes = [.foregroundColor: UIColor.clear]
backButtonAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.clear]
navigationBarAppearance.backButtonAppearance = backButtonAppearance
//Not sure you'll need both of these, but feel free to adjust to your needs.
UINavigationBar.appearance().standardAppearance = navigationBarAppearance
UINavigationBar.appearance().scrollEdgeAppearance = navigationBarAppearance
You can do it once when the app starts and forget about it.
A potential downside (depending on your preferences) is that the transition to the clear color is animated as the title of the current window slides to the left as you move to a different one.
You can also experiment with different text attributes.
Works on iOS 16
Solutions above didn't work for me. I wanted to make changes specific to view without any global (appearance or extension) and with minimal boilerplate code.
Since you can update NavigationItem inside the init of the View. You can solve this in 2 steps:
Get visible View Controller.
// Get Visible ViewController
extension UIApplication {
static var visibleVC: UIViewController? {
var currentVC = UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController
while let presentedVC = currentVC?.presentedViewController {
if let navVC = (presentedVC as? UINavigationController)?.viewControllers.last {
currentVC = navVC
} else if let tabVC = (presentedVC as? UITabBarController)?.selectedViewController {
currentVC = tabVC
} else {
currentVC = presentedVC
}
}
return currentVC
}
}
Update NavigationItem inside init of the View.
struct YourView: View {
init(hideBackLabel: Bool = true) {
if hideBackLabel {
// iOS 14+
UIApplication.visibleVC?.navigationItem.backButtonDisplayMode = .minimal
// iOS 13-
let button = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
UIApplication.visibleVC?.navigationItem.backBarButtonItem = button
}
}
}
custom navigationBarItems and self.presentationMode.wrappedValue.dismiss() worked but you are not allow to perform swiping back
You can either add the following code to make the swipe back again
//perform gesture go back
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
}
but the problem is, sometimes it will make your app crashed when you swipe half the screen and then cancel.
I would suggest the other way to remove the "Back" text.
Adding the isActive state to monitor whether the current screen is active or not. :)
struct ContentView: View {
#State var isActive = false
var body: some View {
NavigationView() {
NavigationLink(
"Next",
destination: Text("Second Page").navigationBarTitle("Second"),
isActive: $isActive
)
.navigationBarTitle(!isActive ? "Title" : "", displayMode: .inline)
}
}
}
I am accomplishing this by changing the title of the master screen before pushing the detail screen and then setting it back when it re-appears. The only caveat is when you go back to the master screen the title's re-appearance is a little noticeable.
Summary:
on master view add state var (e.g. isDetailShowing) to store if detail screen is showing or not
on master view use the navigationTitle modifier to set the title based on the current value of isDetailShowing
on master view use onAppear modifier to set the value of isDetailShowing to false
on the NavigationLink in master screen use the simultaneousGesture modifier to set the isDetailShowing to true
struct MasterView: View {
#State var isDetailShowing = false
var body: some View {
VStack {
Spacer()
.frame(height: 20)
Text("Master Screen")
.frame(maxWidth: .infinity, alignment: .leading)
Spacer()
.frame(height: 20)
NavigationLink(destination: DetailView()) {
Text("Go to detail screen")
}
.simultaneousGesture(TapGesture().onEnded() {
isDetailShowing = true
})
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(isDetailShowing ? "" : "Master Screen Title")
.onAppear() {
isDetailShowing = false
}
}
}
struct DetailView: View {
var body: some View {
Text("This is the detail screen")
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Detail Screen Title")
}
}
you can use .toolbarRole(.editor)
Why not use Custom BackButton with Default Back Button Hidden
You could use Any Design You Prefer !
Works on iOS 16
First View
struct ContentView: View {
var body: some View {
NavigationView {
VStack(){
Spacer()
NavigationLink(destination: View2()) {
Text("Navigate")
.font(.title)
}
Spacer()
}
}
}
}
Second View
struct View2: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
NavigationView {
ZStack{
VStack{
HStack(alignment:.center){
//Any Design You Like
Image(systemName: "chevron.left")
.font(.title)
.foregroundColor(.blue)
.onTapGesture {
self.presentationMode.wrappedValue.dismiss()
}
.padding()
Spacer()
}
Spacer()
}
}
}
.navigationBarBackButtonHidden(true)
}
}

SwiftUI - dismissing keyboard on tapping anywhere in the view - issues with other interactive elements

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.
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.
Dismiss the keyboard by tapping anywhere (like others suggested) could lead to very hard to find bug (or unwanted behavior).
you loose default build-in TextField behaviors, like partial text
selection, copy, share etc.
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()
}
}
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)
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()
// ...
}
#user3441734 is smart to enable the dismiss gesture only when needed. Rather than forcing every crevice of your forms to track state, you can:
Monitor UIWindow.keyboardWillShowNotification / willHide
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
}
You can set .allowsHitTesting(false) to your Picker to ignore the tap on your VStack
Apply this to root view
.onTapGesture {
UIApplication.shared.endEditing()
}

SwiftUI - Close Keyboard on Scroll

I have a simple search list:
struct ContentView: View {
#State var text:String = ""
var items = 1...100
var body: some View {
VStack {
List {
TextField("Search", text: $text)
Section{
ForEach(items.filter({"\($0)".contains(text)}),id: \.self){(i) in
Text("option \(i)")
}
}
}
}
}
}
How can I make the keyboard close when scrolling for more than 2 cells/few points?
If you are using a ScrollView (probably also with a List but I haven't confirmed it), you could use the UIScrollView appearance, this will affect all ScrollViews though.
UIScrollView.appearance().keyboardDismissMode = .onDrag
A thorough discussion on how to resign the keyboard with various answers can be found for this question.
One solution to resign the keyboard on a drag gesture in the list is using a method on UIApplication window as shown below. For easier handling I created an extension on UIApplication and view modifier for this extension and finally an extension to View:
extension UIApplication {
func endEditing(_ force: Bool) {
self.windows
.filter{$0.isKeyWindow}
.first?
.endEditing(force)
}
}
struct ResignKeyboardOnDragGesture: ViewModifier {
var gesture = DragGesture().onChanged{_ in
UIApplication.shared.endEditing(true)
}
func body(content: Content) -> some View {
content.gesture(gesture)
}
}
extension View {
func resignKeyboardOnDragGesture() -> some View {
return modifier(ResignKeyboardOnDragGesture())
}
}
So the final modifier for resigning the keyboard is just one modifier that has to be placed on the list like this:
List {
ForEach(...) {
//...
}
}
.resignKeyboardOnDragGesture()
I have also implemented a pure swiftUI version of a search bar that might be interesting for you. You can find it in this answer.
As for now, since iOS 16 beta we have a new modifier scrollDismissesKeyboard() that allows to do exactly what you need.
In your example it should look like
struct ContentView: View {
#State var text: String = ""
var items = 1...100
var body: some View {
List {
TextField("Search", text: $text)
Section {
ForEach(items.filter({"\($0)".contains(text)}), id: \.self) { (i) in
Text("option \(i)")
}
}
}
.scrollDismissesKeyboard(.interactively) // <<-- Put this line
}
}
The scrollDismissesKeyboard() modifier has a parameter that determine the dismiss rules. Here are the possible values:
.automatic: Dismissing based on the context of the scroll.
.immediately: The keyboard will be dismissed as soon as any scroll happens.
.interactively: The keyboard will move/disappear inline with the user’s gesture.
.never: The keyboard will never dismissed when user is scrolling.
Form {
...
}.gesture(DragGesture().onChanged { _ in
UIApplication.shared.windows.forEach { $0.endEditing(false) }
})
#FocusState wrapper along with .focused() TextField modifier can be useful.
struct ContentView: View {
#FocusState private var focusedSearchField: Bool
#State var text:String = ""
var items = 1...100
var body: some View {
VStack {
List {
TextField("Search", text: $text)
.focused($focusedSearchField)
Section{
ForEach(items.filter({"\($0)".contains(text)}),id: \.self){(i) in
Text("option \(i)")
}
}
} // to also allow swipes on items (theoretically)
.simultaneousGesture(DragGesture().onChanged({ _ in
focusedSearchField = false
}))
.onTapGesture { // dissmis on tap as well
focusedSearchField = false
}
}
}
}
struct EndEditingKeyboardOnDragGesture: ViewModifier {
func body(content: Content) -> some View {
content.highPriorityGesture (
DragGesture().onChanged { _ in
UIApplication.shared.endEditing()
}
)
}
}
extension View {
func endEditingKeyboardOnDragGesture() -> some View {
return modifier(EndEditingKeyboardOnDragGesture())
}
}

Resources