I've got a List view and each row of the list contains an HStack with some text view('s) and an image, like so:
HStack{
Text(group.name)
Spacer()
if (groupModel.required) { Text("Required").color(Color.gray) }
Image("ic_collapse").renderingMode(.template).rotationEffect(Angle(degrees: 90)).foregroundColor(Color.gray)
}.tapAction { self.groupSelected(self.group) }
This seems to work great, except when I tap in the empty section between my text and the image (where the Spacer() is) the tap action is not registered. The tap action will only occur when I tap on the text or on the image.
Has anyone else faced this issue / knows a workaround?
As I've recently learned there is also:
HStack {
...
}
.contentShape(Rectangle())
.onTapGesture { ... }
Works well for me.
Why not just use a Button?
Button(action: { self.groupSelected(self.group) }) {
HStack {
Text(group.name)
Spacer()
if (groupModel.required) { Text("Required").color(Color.gray) }
Image("ic_collapse").renderingMode(.template).rotationEffect(Angle(degrees: 90)).foregroundColor(Color.gray)
}
}.foregroundColor(.primary)
If you don't want the button to apply the accent color to the Text(group.name), you have to set the foregroundColor as I did in my example.
works like magic on every view:
extension View {
func onTapGestureForced(count: Int = 1, perform action: #escaping () -> Void) -> some View {
self
.contentShape(Rectangle())
.onTapGesture(count:count, perform:action)
}
}
The best approach in my opinion for accessibility reasons is to wrap the HStack inside of a Button label, and in order to solve the issue with Spacer can't be tap, you can add a .contentShape(Rectangle()) to the HStack.
So based on your code will be:
Button {
self.groupSelected(self.group)
} label: {
HStack {
Text(group.name)
Spacer()
if (groupModel.required) { Text("Required").color(Color.gray) }
Image("ic_collapse").renderingMode(.template).rotationEffect(Angle(degrees: 90)).foregroundColor(Color.gray)
}
.contentShape(Rectangle())
}
I've been able to work around this by wrapping the Spacer in a ZStack and adding a solid color with a very low opacity:
ZStack {
Color.black.opacity(0.001)
Spacer()
}
Simple extension based on Jim's answer
extension Spacer {
/// https://stackoverflow.com/a/57416760/3393964
public func onTapGesture(count: Int = 1, perform action: #escaping () -> Void) -> some View {
ZStack {
Color.black.opacity(0.001).onTapGesture(count: count, perform: action)
self
}
}
}
Now this works
Spacer().onTapGesture {
// do something
}
I just add the background color(except clear color) for HStack works.
HStack {
Text("1")
Spacer()
Text("1")
}.background(Color.white)
.onTapGesture(count: 1, perform: {
})
Although the accepted answer allows the mimicking the button functionality, visually it does not satisfy. Do not substitute a Button with a .onTapGesture or UITapGestureRecognizer unless all you need is an area which accepts finger tap events. Such solutions are considered hacky and are not good programming practices.
To solve your problem you need to implement the BorderlessButtonStyle ⚠️
Example
Create a generic cell, e.g. SettingsNavigationCell.
SettingsNavigationCell
struct SettingsNavigationCell: View {
var title: String
var imageName: String
let callback: (() -> Void)?
var body: some View {
Button(action: {
callback?()
}, label: {
HStack {
Image(systemName: imageName)
.font(.headline)
.frame(width: 20)
Text(title)
.font(.body)
.padding(.leading, 10)
.foregroundColor(.black)
Spacer()
Image(systemName: "chevron.right")
.font(.headline)
.foregroundColor(.gray)
}
})
.buttonStyle(BorderlessButtonStyle()) // <<< This is what you need ⚠️
}
}
SettingsView
struct SettingsView: View {
var body: some View {
NavigationView {
List {
Section(header: "Appearance".text) {
SettingsNavigationCell(title: "Themes", imageName: "sparkles") {
openThemesSettings()
}
SettingsNavigationCell(title: "Lorem Ipsum", imageName: "star.fill") {
// Your function
}
}
}
}
}
}
I've filed feedback on this, and suggest you do so as well.
In the meantime an opaque Color should work just as well as Spacer. You will have to match the background color unfortunately, and this assumes you have nothing to display behind the button.
Kinda in the spirit of everything that has been said:
struct NoButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(Color.black.opacity(0.0001))
}
}
extension View {
func wrapInButton(action: #escaping () -> Void) -> some View {
Button(action: action, label: {
self
})
.buttonStyle(NoButtonStyle())
}
}
I created the NoButtonStyle because the BorderlessButtonStyle was still giving an animation that was different than .onTapGesture
Example:
HStack {
Text(title)
Spacer()
Text("Select Value")
Image(systemName: "arrowtriangle.down.square.fill")
}
.wrapInButton {
isShowingSelectionSheet = true
}
Another option:
extension Spacer {
func tappable() -> some View {
Color.blue.opacity(0.0001)
}
}
Updated:
I've noticed that Color doesn't always act the same as a Spacer when put in a stack, so I would suggest not using that Spacer extension unless you're aware of those differences. (A spacer pushes in the single direction of the stack (if in a VStack, it pushes vertically, if in a HStack, it pushes out horizontally, whereas a Color view pushes out in all directions.)
This website helped answer the same question for me:
https://www.hackingwithswift.com/quick-start/swiftui/how-to-control-the-tappable-area-of-a-view-using-contentshape
Try applying .ContentShape(Rectangle()) to the HStack.
Related
I have a simple VStack with views inside. When the keyboard appears, the views are moving up. I can deal with it.
But when I click on a NavigationLink, and I go back, the views are stuck in their position, pretending the keyboard is still here.
I would be happy to have any solution :(
before and after
var body: some View {
ZStack {
Color(.systemGroupedBackground).edgesIgnoringSafeArea(.all)
VStack {
LogoView()
.padding(.vertical, 50)
if isSigningUp {
SignUpView()
} else {
SignInView()
}
Spacer()
HStack {
Text("Don't have an account ?")
Button(action: {
isSigningUp.toggle()
}) {
Text("\(invertedAuthCaseLabel) !").foregroundColor(Color("mainColor"))
}
}
}.padding()
.frame(maxWidth: 500)
}
}
I tried the .ignoringSafeArea(.keyboard) modifier but it didn't work, my views were still moving.
if you think the culprit here is keyboard, then you can force the keyboard to close in onDisappear(action:_). The following code might help you close the keyboard.
#if canImport(UIKit)
extension View {
func hideKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
#endif
In SwiftUI, I would like to use a background color for my view while also setting navigationViewStyle to .stack, to force a single-column stack navigation Plus-sized devices.
var body: some View {
TabView {
NavigationView {
ScrollView {
ForEach(0..<100) { _ in
Text("Hello, world!")
.padding()
}
.frame(maxWidth: .infinity)
}
.navigationTitle("Demo")
// .background(Color.yellow) // I can do this …
}
// .navigationViewStyle(.stack) // … or this, but not both!
.tabItem {
Label("Demo", systemImage: "swift")
}
}
}
However, when I do both, the navigation bar won't collapse when I scroll down. Also both the navigation bar and tab bar appear without background.
When I only set the background, but leave out the line that sets the navigationViewStyle, everything looks fine in portrait mode, or smaller devices. But on a Plus-size device in landscape, it looks like this:
So I guess I really can't do this without setting the navigationViewStyle.
What can I do to fix this? Is this a bug that should be fixed by Apple? All help is greatly appreciated.
Use the .navigationViewStyle view modifier on the ScrollView
struct ContentView: View {
var body: some View {
TabView {
NavigationView {
ScrollView {
ForEach(0..<100) { _ in
Text("Hello, world!")
.padding()
}
.frame(maxWidth: .infinity)
}
.navigationTitle("Demo")
.background(Color.yellow)
.navigationViewStyle(.stack)
}
.tabItem {
Label("Demo", systemImage: "swift")
}
}
}
}
Update
I guess, it is a bug. It does not work.
1
If all of your ScrollViews have the same background, use this once on a view.
struct ScrollViewBackground: ViewModifier {
let color: Color
func body(content: Content) -> some View {
content
.ignoresSafeArea(edges: .horizontal)
.onAppear {
UIScrollView.appearance().backgroundColor = UIColor(color)
}
}
}
extension View {
func setBackgroundColor(color: Color) -> some View {
return self.modifier(ScrollViewBackground(color: color))
}
}
2
Use introspect to access the underlaying UIScrollView and change its background. You need to also use .ignoresSafeArea(edges: .horizontal) on the ScrollView.
ScrollView {
Text("Item 2")
}
.introspectScrollView { scrollView in
scrollView.backgroundColor = UIColor(color.yellow)
}
In the following SwiftUI code I noticed some unexpected behaviour.
I wonder if this is a bug, if this is normal or if I am only missing something obvious.
List {
ForEach(self.myList, id: \.self.name) {
item in
HStack {
Spacer()
Button(action: {
print("Button One tapped!")
....
}) {
item.name.map(Text.init)
.font(.largeTitle)
.foregroundColor(.secondary)
}
Spacer()
Button(action: {
print("Button Two tapped!")
....
}) {
Image(systemName: "pencil.circle")
.font(.title)
.foregroundColor(.secondary)
.padding(.leading, 17)
}
}
}
.onDelete(perform: deleteFunc)
}
Now here is what happens when tapping one of the two buttons on a row.
I can see these two messages:
Button One tapped!
Button Two tapped!
I expect to see only one message, depending on the button tapped.
If the order of the messages varied according to the button tapped; I could use a boolean or two to enforce the end result I want. But the two messages always appear in the same order.
Has anyone had the same experience? Or does anyone see any mistake?
Use PlainButtonStyle (or any custom one), because default button style is detected by List automatically to highlight entire row.
Here is a simplified (from your code) demo:
struct DemoListWithButtons: View {
var body: some View {
List {
ForEach(0..<5, id: \.self) {
item in
HStack {
Spacer()
Button(action: {
print("Button One tapped!")
}) {
Text("First")
}.buttonStyle(PlainButtonStyle()) // << here !!
Spacer()
Button(action: {
print("Button Two tapped!")
}) {
Text("Second")
}.buttonStyle(PlainButtonStyle()) // << here !!
}
}
}
}
}
I stumbled upon a weird behaviour for Buttons in SwiftUI in combination with a custom ButtonStyle.
My target was to create a custom ButtonStyle with some kind of 'push-back animation'. I used the following setup for this:
struct CustomButton<Content: View>: View {
private let content: () -> Content
init(content: #escaping () -> Content) {
self.content = content
}
var body: some View {
VStack {
Button(action: { ... }) {
content()
}
.buttonStyle(PushBackButtonStyle(pushBackScale: 0.9))
}
}
}
private struct PushBackButtonStyle: ButtonStyle {
let pushBackScale: CGFloat
func makeBody(configuration: Self.Configuration) -> some View {
configuration
.label
.scaleEffect(configuration.isPressed ? pushBackScale : 1.0)
}
}
// Preview
struct Playground_Previews: PreviewProvider {
static var previews: some View {
CustomButton {
VStack(spacing: 10) {
HStack {
Text("Button Text").background(Color.orange)
}
Divider()
HStack {
Text("Detail Text").background(Color.orange)
}
}
}
.background(Color.red)
}
}
When I now try to touch on this button outside of the Text view, nothing will happen. No animation will be visible and the action block will not be called.
What I found out so far:
when you remove the .buttonStyle(...) it does work as expected (no custom animation of course)
or when you set a .background(Color.red)) on the VStack in the CustomButton it does also work as expected in combination with the .buttonStyle(...)
The question now is if anybody have a better idea of how to properly work around this issue or how to fix it?
Just add hit testing content shape in your custom button style, like below
Tested with Xcode 11.4 / iOS 13.4
private struct PushBackButtonStyle: ButtonStyle {
let pushBackScale: CGFloat
func makeBody(configuration: Self.Configuration) -> some View {
configuration
.label
.contentShape(Rectangle()) // << fix !!
.scaleEffect(configuration.isPressed ? pushBackScale : 1.0)
}
}
Simply use a .frame and it should work.
To make it easily testable I have rewritten it like this:
struct CustomButton: View {
var body: some View {
Button(action: { }) {
VStack(spacing: 10) {
HStack {
Text("Button Text").background(Color.orange)
.frame(minWidth: 0, maxWidth: .infinity)
.background(Color.orange)
}
Divider()
HStack {
Text("Detail Text").background(Color.orange)
.frame(minWidth: 0, maxWidth: .infinity)
.background(Color.orange)
}
}
}
.buttonStyle(PushBackButtonStyle(pushBackScale: 0.9))
}
}
private struct PushBackButtonStyle: ButtonStyle {
let pushBackScale: CGFloat
func makeBody(configuration: Self.Configuration) -> some View {
configuration
.label
.scaleEffect(configuration.isPressed ? pushBackScale : 1.0)
}
}
I hope I could help. :-)
#Edit With video.
my tapAction is not recognizing a tap when my foregroundColor is clear. When i remove the color it works fine.
That's my code:
ZStack {
RoundedRectangle(cornerRadius: 0)
.foregroundColor(Color.clear)
.frame(width: showMenu ? UIScreen.main.bounds.width : 0)
.tapAction {
self.showMenu.toggle()
}
RoundedRectangle(cornerRadius: 5)
.foregroundColor(Color.green)
.shadow(radius: 5, y: 2)
.padding(.trailing, 50)
.frame(width: showMenu ? UIScreen.main.bounds.width : 0)
}
.edgesIgnoringSafeArea(.top)
The accurate way is to use .contentShape(Rectangle()) on the view.
Described in this tutorial:
control-the-tappable-area-of-a-view by Paul Hudson #twostraws
VStack {
Image("Some Image").resizable().frame(width: 50, height: 50)
Spacer().frame(height: 50)
Text("Some Text")
}
.contentShape(Rectangle())
.onTapGesture {
print("Do Something")
}
how-to-control-the-tappable-area-of-a-view-using-contentshape stackoverflow
I have also discovered that a shape filled with Color.clear does not generate a tappable area.
Here are two workarounds:
Use Color.black.opacity(0.0001) (even on 10-bits-per-channel displays). This generates a color that is so transparent that it should have no effect on your appearance, and generates a tappable area that fills its frame. I don't know if SwiftUI is smart enough to skip rendering the color, so I don't know if it has any performance impact.
Use a GeometryReader to get the frame size, and then use the contentShape to generate the tappable area:
GeometryReader { proxy in
Color.clear.contentShape(Path(CGRect(origin: .zero, size: proxy.size)))
}
Here is the component
struct InvisibleButton: View {
let action: (() -> Void)?
var body: some View {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
action?()
}
}
}
usage: Put your view and InbisibleButton in ZStack
ZStack {
**yourView()**
InvisibleButton {
print("Invisible button tapped")
}
}
you also can make a modifier to simplify usage:
struct InvisibleButtonModifier: ViewModifier {
let action: (() -> Void)?
func body(content: Content) -> some View {
ZStack {
content
InvisibleButton(action: action)
}
}
}
**yourView()**
.modifier(InvisibleButtonModifier {
print("Invisible button tapped")
})
However, if your SwiftUI View has a UIKit view as a subview under, you will have to set Color.gray.opacity(0.0001) in order to UIView's touches be ignored
In my case a View that didn't trigger onTapGesture:
struct MainView: View {
var action: () -> Void
var body: some View {
NotTappableView()
.contentShape(Rectangle())
.onTapGesture(
action()
)
}
}
I solved this way:
struct MainView: View {
var action: () -> Void
var body: some View {
NotTappableView()
.overlay(
Color.clear
.contentShape(Rectangle())
.onTapGesture {
action()
}
)
}
}
This made whole untappable view now tappable.