160

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?

5
  • 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. Commented Jul 24, 2019 at 20:52
  • 14
    @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 >) Commented Jul 24, 2019 at 20:54
  • 22
    @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 Commented Jul 24, 2019 at 20:57
  • 1
    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. Commented Jul 24, 2019 at 22:50
  • 1
    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 Commented Jul 24, 2019 at 22:52

13 Answers 13

348

As I've recently learned there is also:

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

Works well for me.

Sign up to request clarification or add additional context in comments.

7 Comments

This worked perfectly when applied to the contents of my Button. No need for the onTapGesture in that case.
Works with an HStack with spacers within it.
This works well. The accepted answer didn't work for me because it changes the accent color of the contents of the HStack.
Some more explanation from Hacking With Swift: hackingwithswift.com/quick-start/swiftui/… .
The fact that this solution is required disappoints me with SwiftUI in general.Anyway great solution!
|
32

It is better to use a Button for accessibility.

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.

3 Comments

This changes the background color on tap, although you can probably change that: stackoverflow.com/questions/56509640/… The ZStack solution from @jim-marquardt worked well for me.
Is not the same behavior with navigationLink
I tried this and am still unable to tap in the empty space at the center of the HStack. Did you get a different result?
30

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

Comments

22

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

1 Comment

This is awesome
11

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

Comments

10

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
}

7 Comments

Can you describe in more detail what this is doing? Is this creating a new ZStack everytime a user taps? Does it clean up the memory from this? This worked great by the way :).
@JosephAstrahan no it just creates an opaque view behind your current view that registers user taps
Does it destroy (free the memory) of the view after the tap has finished?
So it creates a 1 time view? Not creating that view everytime the user taps over and over I guess was my main question. The reason I asked was because it's within the on TapGesture function which implies it's creating the view over and over again.
This is a clean answer, especially for tracking phantom taps.
|
7

Just adding an empty background modifier works.

HStack {
  ...
}
.background()
.onTapGesture { ... }

Comments

5

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

})

Comments

2

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.

Comments

1

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.

Comments

1

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

Comments

1

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

Comments

0

An update to sergioblancoo's answer.

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

Just wrap your entire view in a button without the contentShape

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.