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

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()
}

Related

iOS 16 keyboard safe area not updated on push

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

SwiftUI NavigationLink for iOS 14.5 not working

I had the following code in Xcode 12.4 that worked perfectly
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: rows, spacing: 0) {
HStack {
if (type == "Quiz") {
NavigationLink(destination: Quiz(id: quiz.id)) {
VStack(alignment: .leading) {
Text("Quiz")
.font(.headline)
.foregroundColor(.white)
.padding(.top, 8)
.padding(.leading)
}
.background(Color.green)
.cornerRadius(12)
.shadow(color: .green, radius: 3, x: 0.0, y: 0.0)
}
} else {
NavigationLink(destination: Survey(id: survey.id)) {
VStack(alignment: .leading) {
Text("Survey")
.font(.headline)
.foregroundColor(.white)
.padding(.top, 8)
.padding(.leading)
}
.background(Color.green)
.cornerRadius(12)
.shadow(color: .green, radius: 3, x: 0.0, y: 0.0)
}
} // End If
if (type == "Quiz") {
NavigationLink(destination: QuizResults(id: quiz.id)) {
VStack(alignment: .leading) {
Text("Quiz Results")
.font(.headline)
.foregroundColor(.white)
.padding(.top, 8)
.padding(.leading)
}
.background(Color.blue)
.cornerRadius(12)
.shadow(color: .blue, radius: 3, x: 0.0, y: 0.0)
}
} else {
NavigationLink(destination: SurveyResults(id: survey.id)) {
VStack(alignment: .leading) {
Text("Survey Results")
.font(.headline)
.foregroundColor(.white)
.padding(.top, 8)
.padding(.leading)
}
.background(Color.blue)
.cornerRadius(12)
.shadow(color: .blue, radius: 3, x: 0.0, y: 0.0)
}
}
}
.padding([.leading, .trailing], 25)
}
.frame(height: 100)
I just updated Xcode to 12.5 and the above does not work any more.
It was working fine in 12.4!?
Now when I click the 'Quiz' element, it starts the transition to the Quiz View which is displays it but immediately closes the view and I'm back in the Detail View!?
Can someone see what I am doing wrong, and why now based on the update to 12.5 this stopped working?
UPDATE
I refined the code to the minimal possible reproducible form. What seems to be happening is that I have two or more NavigationLinks sets.
the first is the set to navigate the user to either the Quiz or Survey which the if statement addresses the user to the correct view to fill in.
Where the issue is in 12.5 is that the second set where the user can click to go see the overall results of the Quiz or Survey does not work when it's directly after the first navigation.
Like I said before hand it worked perfectly in 12.4 but seems like 12.5 does not agree with it. Can someone offer a better way for the user to click an element to either go fill in a quiz or survey or go see the results of a quiz or survey?
I got exactly the same problem, everything works fine with Xcode 12.4.
https://developer.apple.com/forums/thread/677333
I try to following this thread, it might work but on some case, I still have this bug.
NavigationLink(destination: EmptyView()) {
EmptyView()
}
Apparently, you can put this 3 lines of code close to your NavigationLink...
If someone got a better answer I will really appreciate it !
What a horrible bug! From my testing and some googling it happens when there are exactly 2 navigation links in a view. The code in the question has 4 but because of the if else statements there are effectively only 2 at a time.
I often don't know how many nav links I will have as it depends on what data the user has added/how many search hits there are etc. To be safe I've made a tripleEmptyNavigationLink modifier which I've stuck at the end of all my views. It's solving the popping behaviour but I'm still getting the 'Unable to present' warnings. Would love to know if anyone has anything better than this!
import SwiftUI
struct TripleEmptyNavigationLink: View {
var body: some View {
VStack {
NavigationLink(destination: EmptyView()) {EmptyView()}
NavigationLink(destination: EmptyView()) {EmptyView()}
NavigationLink(destination: EmptyView()) {EmptyView()}
}
}
}
struct TripleEmptyNavigationLinkBackground: ViewModifier {
func body(content: Content) -> some View {
content
.background(TripleEmptyNavigationLink())
}
}
extension View {
func tripleEmptyNavigationLink()-> some View {
self.modifier(TripleEmptyNavigationLinkBackground())
}
}
usage:
MyView()
.tripleEmptyNavigationLink()
In Xcode13 beta still has this issue.
So far solution:
1、Wrap NavigationLink with List or Form:
List {
NavigationLink(destination: Text("1")) {
Text("1")
}
NavigationLink(destination: Text("2")) {
Text("2")
}
NavigationLink(destination: Text("3")) {
Text("3")
}
}
2、Or Use one NavigationLink, and create destination view from func:
struct TaskIndexPage: View {
func buildView() -> some View {
// return you destination
switch self.option {
case 1:
return Text("\(option)")
default:
return Text("\(option)")
}
}
#State private var showDetail: Bool = false
#State private var option: Int = 0
var body: some View {
VStack {
Button {
showDetail = true
option = 1
} label: { Text("button 1") }
Button {
showDetail = true
option = 2
} label: { Text("button 2") }
Button {
showDetail = true
option = 3
} label: { Text("button 3") }
}
// handle navigating
NavigationLink(destination: self.buildView(), isActive: $showDetail) {}.opacity(0)
}
}
Adding a NavigationLink with an empty view didn't work for me. I solved my issue removing all NavigationLinks from the ForEach and using a single one to control the navigation to the detail view, a tap gesture and 2 state variables to keep track on what is being tapped on.
The example broken code and fix can be found at Paul Hudson's site.
https://www.hackingwithswift.com/forums/swiftui/unable-to-present-please-file-a-bug/7901/8237
Below is the complete working version
import SwiftUI
struct NavigationViewOptions {
enum OptionType { case main, optional }
typealias Option = (id: UUID, value: String, type: Self.OptionType)
static var options: [Option] = [
(UUID(), "Option 1", .main),
(UUID(), "Option 2", .optional),
(UUID(), "Option 3", .main),
(UUID(), "Option 4", .main),
(UUID(), "Option 5", .optional),
]
static func buildView(for option: Option) -> some View {
switch option.type {
case .main:
return Text("Main Option selected\n\(option.value)").font(.title).fontWeight(.bold)
case .optional:
return Text("Optional Option selected\n\(option.value)").font(.title3).italic().fontWeight(.medium)
}
}
}
struct NavigationViewWorking: View {
// State variables to leep track of what option has been tapped on and when to navigate to new view
#State private var selectedOption: NavigationViewOptions.Option = (id:UUID(),"",.main)
#State private var showDetail: Bool = false
var body: some View {
NavigationView {
ScrollView{
VStack (alignment:.leading) {
Text("NAVIGATION FIX FOR:\nUnable to present. Please file a bug.")
.padding(.bottom, 40)
ForEach(NavigationViewOptions.options, id: \.id) { option in
Text(option.value)
.font(.title)
.padding(.vertical, 10)
.foregroundColor(.accentColor) // same color as navigationLink
// handle tap on option
.onTapGesture {
selectedOption = option
showDetail = true
}
}
Spacer()
NavigationLink("", destination: NavigationViewOptions.buildView(for: selectedOption), isActive: $showDetail)
.opacity(0)
}
.navigationTitle("Options")
}
// INITIAL DETAIL VIEW
Text("Select option from the left")
}
}
}
For me the correct answer didn't work.
It showed Unable to present-message and then required view was pushed and poped out back quickly.
While playing around I found a working solution. I keep NotificationLink's without label set as a plain List items.
NavigationView {
ZStack {
List {
NavigationLink(isActive: $isFirstViewPresented,
destination: firstView,
label: EmptyView.init)
NavigationLink(isActive: $isSecondViewPresented,
destination: secondView,
label: EmptyView.init)
}
.listStyle(.plain)
//...
Button("Show first view") { isFirstViewPresented.toggle() }
Button("Show second view") { isSecondViewPresented.toggle() }
}
}
Don't forget to wrap active-properties with #State.
It also has some benefits as for me (all the navigation links are placed at the top of the view-getter and I don't need to look for it through all the code.
I could never find a reliable solution to this horrible bug. So I decided to create a custom NavigationLink, https://gist.github.com/Arutyun2312/a0dab7eecaa84bde99c435fecae76274. This works way better than expected, because all swiftui related functions continue working as usual. Seems like the bug is specifically with NavigationLink.
struct NavigationLink: View {
fileprivate init<T: View>(body: T) {
self.body = .init(body)
}
let body: AnyView
}
private struct NavigationLinkImpl<Destination: View, Label: View>: View {
let destination: () -> Destination?
#State var isActive = false
#ViewBuilder let label: () -> Label
var body: some View {
NavigationLinkImpl1(destination: destination, isActive: $isActive, label: label)
}
}
private struct NavigationLinkImpl1<Destination: View, Label: View>: View {
let destination: () -> Destination
#Binding var isActive: Bool
#ViewBuilder let label: () -> Label
#State var model = Model()
var body: some View {
Button(action: action, label: label)
.introspectNavigationController(customize: handle)
.id(isActive)
}
func handle(nav: UINavigationController) {
if isActive {
if model.destination == nil {
let dest = UIHostingController<Destination>(rootView: destination())
nav.pushViewController(dest, animated: true)
model.destination = dest
}
} else {
if let dest = model.destination {
if let i = nav.viewControllers.lastIndex(of: dest) {
nav.setViewControllers(.init(nav.viewControllers.prefix(i + 1)), animated: true)
}
model.destination = nil
}
}
if isActive != model.contains(nav: nav) { // detect pop
isActive = model.contains(nav: nav)
}
}
final class Model {
var destination: UIHostingController<Destination>?
func contains(nav: UINavigationController) -> Bool { destination.map { nav.viewControllers.contains($0) } ?? false }
}
func action() { isActive = true }
}
extension NavigationLink {
init<Destination: View, Label: View>(destination: #autoclosure #escaping () -> Destination, #ViewBuilder label: #escaping () -> Label) {
self.init(body: NavigationLinkImpl(destination: destination, label: label))
}
init<Destination: View, Label: View>(destination: #autoclosure #escaping () -> Destination, isActive: Binding<Bool>, #ViewBuilder label: #escaping () -> Label) {
self.init(body: NavigationLinkImpl1(destination: destination, isActive: isActive, label: label))
}
init<Destination: View>(_ text: String, destination: #autoclosure #escaping () -> Destination, isActive: Binding<Bool>) {
self.init(destination: destination(), isActive: isActive) { Text(text) }
}
init<Destination: View>(_ text: String, destination: #autoclosure #escaping () -> Destination) {
self.init(destination: destination()) { Text(text) }
}
}
Put this in a file, and your existing NavigationLinks will work just fine. Tested in ios 14 and 15
Like anybody else on iOS 14.5.1 my application is hit by this awful bug. I have more than 3 NavigationLinks in the page, and I was not lucky to modify the numbers of the NavigationLinks (by adding a dummy NavigationLink) to get the correct behaviour.
A workaround that is Okay for me is to add a NavigationLink conditionally into the view.
Instead of:
var body: some View {
NavigationLink(destination: AnotherView(), isActive: $someCondition) { EmptyView() }
}
I have this:
var body: some View {
if someCondition {
NavigationLink(destination: AnotherView(), isActive: $someCondition) { EmptyView() }
}
}
The behaviour is not exactly the same, as you lose some navigation animation candy, but at least you have a working application again with relatively easy to understand fix.
You can also short-circuit it to 14.5 only, and normal behaviour elsewhere:
/// Assumes this gets fixed by Apple until 14.6 is out
var onIOS14_5: Bool {
let systemVersion = UIDevice.current.systemVersion
return systemVersion.starts(with: "14.5")
}
var body: some View {
if !onIOS14_5 || someCondition {
NavigationLink(destination: AnotherView(), isActive: $someCondition) { EmptyView() }
}
}
Perhaps this helps someone and lets all hope Apple will fix this embarrasing bug. Now I want my half day back.
In my case, the NavigationLink didn't work because of an .onTapGesture I added to dismiss the keyboard.
I got exactly the same problem.
my code:
class NavigationManager: ObservableObject {
static let shared: NavigationManager = {
return NavigationManager()
}()
#Published var showingMain: Bool
#Published var showingSub: Bool
#Published var content: AnyView
init() {
showingMain = false
showingSub = false
content = AnyView(EmptyView())
}
func forward<T:View>(content: #escaping () -> T ) {
showView()
self.content = AnyView(content())
}
private func showView() {
if !showingMain,!showingSub {
showingMain = true
} else if showingMain,!showingSub {
showingSub = true
} else if !showingMain,showingSub {
showingMain = true
}
}
}
struct NavigationLinkGroup: View {
#EnvironmentObject var navigationManager: NavigationManager
var body: some View {
Group {
NavigationLink(destination: navigationManager.content, isActive: $navigationManager.showingMain) {EmptyView()}
NavigationLink(destination: navigationManager.content, isActive: $navigationManager.showingSub) {EmptyView()}
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLinkGroup()
}
}
}
https://github.com/Ftrybe/CustomBackButtonOfSwiftUIApp/tree/master/CustomBackButtonOfSwiftUI
It seems that if there's more than one NavigationLink in NavigationView, this bug will be filed.
Here's my solution.
import SwiftUI
enum MyLink {
case myView1
case myView2
}
struct MyView1: View {
var body: some View {
Text("MyView1")
}
}
struct MyView2: View {
var body: some View {
Text("MyView2")
}
}
struct ExampleView: View {
#State var currentLink: MyLink = .myView1
#State var isLinkViewShow: Bool = false
func getLinkView(_ myLink: MyLink) -> some View {
if myLink == .myView1 {
return AnyView(MyView1())
} else {
return AnyView(MyView2())
}
}
var body: some View {
NavigationView {
VStack {
NavigationLink("",
destination: getLinkView(currentLink),
isActive: $isLinkViewShow)
// Press to navigate to MyView1
Button(action: {
currentLink = .myView1
isLinkViewShow = true
}) {
Text("To MyView1")
}
// Press to navigate to MyView2
Button(action: {
currentLink = .myView2
isLinkViewShow = true
}) {
Text("To MyView2")
}
}
}
}
}
Adding a delay gets auto-navigation working again.
NavigationLink(destination: PopupView(),isActive: $showView){}
&
.onAppear {
if (test()){
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {showView = true}
}
}

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.

How to check if a view is displayed on the screen? (Swift 5 and SwiftUI)

I have a view like below. I want to find out if it is the view which is displayed on the screen. Is there a function to achieve this?
struct TestView: View {
var body: some View {
Text("Test View")
}
}
You could use onAppear on any kind of view that conforms to View protocol.
struct TestView: View {
#State var isViewDisplayed = false
var body: some View {
Text("Test View")
.onAppear {
self.isViewDisplayed = true
}
.onDisappear {
self.isViewDisplayed = false
}
}
func someFunction() {
if isViewDisplayed {
print("View is displayed.")
} else {
print("View is not displayed.")
}
}
}
PS: Although this solution covers most cases, it has many edge cases that has not been covered. I'll be updating this answer when Apple releases a better solution for this requirement.
You can check the position of view in global scope using GeometryReader and GeometryProxy.
struct CustomButton: View {
var body: some View {
GeometryReader { geometry in
VStack {
Button(action: {
}) {
Text("Custom Button")
.font(.body)
.fontWeight(.bold)
.foregroundColor(Color.white)
}
.background(Color.blue)
}.navigationBarItems(trailing: self.isButtonHidden(geometry) ?
HStack {
Button(action: {
}) {
Text("Custom Button")
} : nil)
}
}
private func isButtonHidden(_ geometry: GeometryProxy) -> Bool {
// Alternatively, you can also check for geometry.frame(in:.global).origin.y if you know the button height.
if geometry.frame(in: .global).maxY <= 0 {
return true
}
return false
}
As mentioned by Oleg, depending on your use case, a possible issue with onAppear is its action will be performed as soon as the View is in a view hierarchy, regardless of whether the view is potentially visible to the user.
My use case is wanting to lazy load content when a view actually becomes visible. I didn't want to rely on the view being encapsulated in a LazyHStack or similar.
To achieve this I've added an extension onBecomingVisible to View that has the same kind of API as onAppear, but will only call the action when the view intersects the screen's visible bounds.
public extension View {
func onBecomingVisible(perform action: #escaping () -> Void) -> some View {
modifier(BecomingVisible(action: action))
}
}
private struct BecomingVisible: ViewModifier {
#State var action: (() -> Void)?
func body(content: Content) -> some View {
content.overlay {
GeometryReader { proxy in
Color.clear
.preference(
key: VisibleKey.self,
// See discussion!
value: UIScreen.main.bounds.intersects(proxy.frame(in: .global))
)
.onPreferenceChange(VisibleKey.self) { isVisible in
guard isVisible else { return }
action?()
action = nil
}
}
}
}
struct VisibleKey: PreferenceKey {
static var defaultValue: Bool = false
static func reduce(value: inout Bool, nextValue: () -> Bool) { }
}
}
Discussion
I'm not thrilled by using UIScreen.main.bounds in the code! Perhaps a geometry proxy could be used for this instead, or some #Environment value – I've not thought about this yet though.

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