SwiftUI can't tap in Spacer of HStack
IosSwiftSwiftuiIos Problem Overview
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?
Ios Solutions
Solution 1 - Ios
As I've recently learned there is also:
HStack {
...
}
.contentShape(Rectangle())
.onTapGesture { ... }
Works well for me.
Solution 2 - Ios
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.
Solution 3 - Ios
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)
}
}
Solution 4 - Ios
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()
}
Solution 5 - Ios
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
}
Solution 6 - Ios
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())
}
Solution 7 - Ios
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: {
})
Solution 8 - Ios
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
}
}
}
}
}
}
Solution 9 - Ios
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.
Solution 10 - Ios
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.)