Catch Tap gesture on Form in SwiftUI - ios

struct ContentView: View {
#State var text = ""
var body: some View {
Form {
Button(action: {
print("Button pressed")
}) {
Text("Button")
}
}.simultaneousGesture(TapGesture().onEnded( { print("tap") }))
}
}
I need both Button' action and tap gesture on Form to be caught, but only print("tap") is executed. For a VStack works fine, but it seems the Form is a little bit special. Any idea ?

if you do it like this, you will get the button tap (but also the form tap). I don't know if this helps you.
#State var text = ""
var body: some View {
Form {
Button(action: {
}) {
Text("Button")
}.onTapGesture {
print("button")
}
}.onTapGesture {
print("form")
} }
}

Related

SwiftUI | How to hide cancel button in alert window?

While testing new alert method in iOS 15(document), I just found a weird behavior that alert has.
Description
This is the codes:
import SwiftUI
struct ContentView: View {
#State var show = false
var body: some View {
VStack {
Button(action: {
show = true
}) {
Text("Alert")
}
}
.alert("alert", isPresented: $show) {
Button(action: {}) {
Text("button1")
}
Button("button2", role: .destructive, action: {})
}
}
}
As you can see, I added only 2 buttons but SwiftUI just adds Cancel button at the end of the buttons.
However, it doesn't happen when any of the buttons doesn't have a role.
import SwiftUI
struct ContentView: View {
#State var show = false
var body: some View {
VStack {
Button(action: {
show = true
}) {
Text("Alert")
}
}
.alert("alert", isPresented: $show) {
Button(action: {}) {
Text("button1")
}
Button(action: {}) {
Text("button1")
}
}
}
}
It's really weird. I even have no idea that it's intended by Apple or just a bug.
Question
Therefore my question is, how can I remove that cancel button after I added a button with a role. Is there any way to do this or I have to just accept it..
Any advice will be appreciated.
As role destructive button is for deletes user data, or performs an irreversible operation according to Apple docs.
In the alert view maybe because of you have a button with role .destructive so the alert default add a .cancel button. For closing the alert you should define a button with role .cancel
struct ContentView: View {
#State var show = false
var body: some View {
VStack {
Button(action: {
show = true
}) {
Text("Alert")
}
}
.alert("alert", isPresented: $show) {
Button(action: {}) {
Text("button1")
}
Button("button2", role: .cancel, action: {})
}
}
}

SwiftUI Modal Inherits SearchBar during Sheet Presentation

Consider the following example with a list and a button wrapped in a HStack that opens up a sheet:
struct ContentView: View {
#State var text: String = ""
#State var showSheet = false
var body: some View {
NavigationView {
List {
HStack {
button
}
Text("Hello World")
}
.searchable(text: $text)
}
}
var button: some View {
Button("Press", action: { showSheet = true })
.sheet(isPresented: $showSheet) {
modalView
}
}
var modalView: some View {
NavigationView {
List {
Text("Test")
}
}
}
}
On press of the button, a modal is presented to the user. However, the searchable modifier gets passed to the modal, see this video.
Now if the HStack is removed, everything works fine:
List {
button
Text("Hello World")
}
In addition, everything works also fine if the modal is not a NavigationView:
var modalView: some View {
List {
Text("Test")
}
}
Does somebody know what the problem here might be or is it once again one of those weird SwiftUI bugs?
putting the sheet, outside of the button and the List, works for me. I think .sheet is not meant to be inside a List, especially where searchable is operating.
struct ContentView: View {
#State var text: String = ""
#State var showSheet = false
var body: some View {
NavigationView {
List {
HStack {
button
}
Text("Hello World")
}
.searchable(text: $text)
}
.sheet(isPresented: $showSheet) {
modalView
}
}
var button: some View {
Button("Press", action: { showSheet = true })
}
var modalView: some View {
NavigationView {
List {
Text("Test")
}
}
}
}
Another workaround is to use navigationBarHidden = true, but then you must live without the navigation bar in the sheet view.
var modalView: some View {
NavigationView {
List {
Text("Test")
}
.navigationBarHidden(true)
}
}
Btw, on iPadOS it helps to use .searchable(text: $text, placement: .sidebar)

SwiftUI Transition not happening

I am new to SwiftUI and I am trying to use the .transition, but for some reason no transition happens.
You can see the code below:
View
import SwiftUI
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
var body: some View {
if self.viewModel.model.show {
Text("Showing")
.padding()
} else {
Text("Not Showing")
.padding()
.transition(.asymmetric(insertion: .scale, removal: .opacity))
}
Button {
self.viewModel.show()
} label: {
Text("Tap to change")
}
}
}
ViewModel
class ViewModel: ObservableObject {
#Published private(set) var model = Model()
func show() {
self.model.toggleShow()
}
}
Model
struct Model {
var show: Bool = true
mutating func toggleShow() {
self.show.toggle()
}
}
When I tap the button the text changes but no transition occurs.
I feel like I am missing something trivial here.
Can anyone please assist?
You need an animation (to animate transition) and a container (which performs actual transition, because default implicit Group does not do that).
Here is fixed part of code (tested with Xcode 13.2 / iOS 15.2)
*Note:Preview > Debug > Slow Animation for better visibility
var body: some View {
VStack { // << this !!
if self.viewModel.model.show {
Text("Showing")
.padding()
} else {
Text("Not Showing")
.padding()
.transition(.asymmetric(insertion: .scale, removal: .opacity))
}
}
.animation(.default, value: self.viewModel.model.show) // << here !!
Button {
self.viewModel.show()
} label: {
Text("Tap to change")
}
}
Your code is fine (besides the fact that you need a VStack wrapping the text and the button), you only need to tell SwiftUI to use the transition by wrapping the command inside withAnimation().
Here's what you simply need to do in ContentView (look at the Button):
#ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
if self.viewModel.model.show {
Text("Showing")
.padding()
} else {
Text("Not Showing")
.padding()
.transition(.asymmetric(insertion: .scale, removal: .opacity))
}
Button {
withAnimation { // This is what you need to trigger the transition
self.viewModel.show()
}
} label: {
Text("Tap to change")
}
}
.animation(.easeIn, value: self.viewModel.show)
}

SwiftUI button inactive inside NavigationLink item area

I have a view for a list item that displays some news cards within a navigationLink.
I am supposed to add a like/unlike button within each news card of navigationLink, without being took to NavigationLink.destination page.
It seems like a small button inside a big button.
When you click that small one, execute the small one without executing the bigger one.
(note: the click area is covered by the two buttons, smaller one has the priority)
(In javascript, it seems like something called .stopPropaganda)
This is my code:
var body: some View {
NavigationView {
List {
ForEach(self.newsData.newsList, id:\.self) { articleID in
NavigationLink(destination: NewsDetail(articleID: articleID)) {
HStack {
Text(newsTitle)
Button(action: {
self.news.isBookmarked.toggle()
}) {
if self.news.isBookmarked {
Image(systemName: "bookmark.fill")
} else {
Image(systemName: "bookmark")
}
}
}
}
}
}
}
}
Currently, the button action (like/dislike) will not be performed as whenever the button is pressed, the navigationLink takes you to the destination view.
I have tried this almost same question but it cannot solve this problem.
Is there a way that makes this possible?
Thanks.
as of XCode 12.3, the magic is to add .buttonStyle(PlainButtonStyle()) or BorderlessButtonStyle to the button, when said button is on the same row as a NavigationLink within a List.
Without this particular incantation, the entire list row gets activated when the button is pressed and vice versa (button gets activated when NavigationLink is pressed).
This code does exactly what you want.
struct Artcle {
var text: String
var isBookmarked: Bool = false
}
struct ArticleDetail: View {
var article: Artcle
var body: some View {
Text(article.text)
}
}
struct ArticleCell: View {
var article: Artcle
var toggle: () -> ()
#State var showDetails = false
var body: some View {
HStack {
Text(article.text)
Spacer()
Button(action: {
self.toggle()
}) {
Image(systemName: article.isBookmarked ? "bookmark.fill" : "bookmark").padding()
}
.buttonStyle(BorderlessButtonStyle())
}
.overlay(
NavigationLink(destination: ArticleDetail(article: article), isActive: $showDetails) { EmptyView() }
)
.onTapGesture {
self.showDetails = true
}
}
}
struct ContentView: View {
#State var articles: [Artcle]
init() {
_articles = State(initialValue: (0...10).map { Artcle(text: "Article \($0 + 1)") })
}
func toggleArticle(at index: Int) {
articles[index].isBookmarked.toggle()
}
var body: some View {
NavigationView {
List {
ForEach(Array(self.articles.enumerated()), id:\.offset) { offset, article in
ArticleCell(article: article) {
self.toggleArticle(at: offset)
}
}
}
}
}
}

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

Resources