62
votes

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?

9
Honest question: Exactly why would you expect someone to tap in a spacer? It's by definition, space. Maybe your UI is expecting something you might in UIKit? If so, please, details it. - dfd
@dfd Each row is simply just text with a chevron at the end of it, something like Object One > , is what the row would look like - and I would want the user to be able to tap anywhere on the row (That did not format with the spaces I thought it would - just imagine a space between the text and the >) - Quinn
@dfd I think it is pretty standard behaviour to want the user to be able to click anywhere on a table cell, hence why they have a didSelectRowAt method on their UIKit table views - Quinn
Sure, I agree. But maybe try something else instead of a Spacer. Maybe turn the entire thing into a Button? In SwiftUI a Spacer is just that - spacing. - dfd
Can't believe I'm going to say this... but yeah, an oldie but goodie! When I suggested a Button I had this in mind: alejandromp.com/blog/2019/06/09/playing-with-swiftui-buttons - dfd

9 Answers

153
votes

As I've recently learned there is also:

HStack {
  ...
}
.contentShape(Rectangle())
.onTapGesture { ... }

Works well for me.

16
votes

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.

6
votes

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

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
}
6
votes

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)
        }
    }
2
votes

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: {

})
2
votes

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
          }
        }
      }
    }
  }
}
1
votes

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.

0
votes

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.)