122

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?

Quinn
  • 7,072
  • 7
  • 39
  • 61
  • 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. –  Jul 24 '19 at 20:52
  • 10
    @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 Jul 24 '19 at 20:54
  • 13
    @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 Jul 24 '19 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. –  Jul 24 '19 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: https://alejandromp.com/blog/2019/06/09/playing-with-swiftui-buttons/ –  Jul 24 '19 at 22:52

13 Answers13

274

As I've recently learned there is also:

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

Works well for me.

hnh
  • 13,957
  • 6
  • 30
  • 40
  • 1
    This worked perfectly when applied to the contents of my `Button`. No need for the `onTapGesture` in that case. – Kris McGinnes Oct 24 '19 at 16:43
  • Works with an HStack with spacers within it. – LondonGuy Nov 11 '19 at 01:19
  • This works well. The accepted answer didn't work for me because it changes the accent color of the contents of the HStack. – Cloud9999Strife Nov 19 '19 at 06:19
  • This is also the better solution for my case than the accepted answer as what I have is a line with different controls and not actually a button. But the whole thing should be tappable. Also, as Cloud9999Strife already mentioned it doesn't change the look. – G. Marc Nov 24 '19 at 09:41
  • 8
    Some more explanation from Hacking With Swift: https://www.hackingwithswift.com/quick-start/swiftui/how-to-control-the-tappable-area-of-a-view-using-contentshape . – David L Apr 05 '20 at 13:39
  • 4
    The fact that this solution is required disappoints me with SwiftUI in general.Anyway great solution! – C. Skjerdal Feb 03 '21 at 18:40
  • Learned the hard way [order matters on modifiers](https://www.hackingwithswift.com/books/ios-swiftui/why-modifier-order-matters), make sure `contentShape` is before `onTapGesture`. – robotsquidward Mar 06 '21 at 14:03
25

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.

rob mayoff
  • 375,296
  • 67
  • 796
  • 848
  • This changes the background color on tap, although you can probably change that: https://stackoverflow.com/questions/56509640/how-to-set-custom-highlighted-state-of-swiftui-button The ZStack solution from @jim-marquardt worked well for me. – choxi Aug 22 '19 at 00:30
  • Is not the same behavior with `navigationLink` – iGhost Aug 05 '20 at 22:39
16

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)
    }
}
pkamb
  • 33,281
  • 23
  • 160
  • 191
Asi Givati
  • 1,348
  • 1
  • 15
  • 31
13

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())
}
Olcay Ertaş
  • 5,987
  • 8
  • 76
  • 112
sergioblancoo
  • 131
  • 1
  • 4
9

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
}
Casper Zandbergen
  • 3,419
  • 2
  • 25
  • 49
  • 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 :). – Joseph Astrahan Dec 19 '19 at 01:53
  • @JosephAstrahan no it just creates an opaque view behind your current view that registers user taps – Casper Zandbergen Dec 19 '19 at 09:18
  • Does it destroy (free the memory) of the view after the tap has finished? – Joseph Astrahan Dec 19 '19 at 21:23
  • 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. – Joseph Astrahan Dec 20 '19 at 18:34
  • This is a clean answer, especially for tracking phantom taps. – DaWiseguy Feb 23 '21 at 20:16
8

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()
}
Jim Marquardt
  • 3,959
  • 1
  • 12
  • 21
4

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

})
William Hu
  • 15,423
  • 11
  • 100
  • 121
2

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
          }
        }
      }
    }
  }
}
Rufat Mirza
  • 1,425
  • 14
  • 20
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.

arsenius
  • 12,090
  • 7
  • 58
  • 76
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.)

Mikael Weiss
  • 839
  • 2
  • 14
  • 25
0

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.

jmoerdyk
  • 5,544
  • 7
  • 38
  • 49
0

Just adding an empty background modifier works.

HStack {
  ...
}
.background()
.onTapGesture { ... }
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

Tyler2P
  • 2,324
  • 26
  • 22
  • 31
devdchaudhary
  • 467
  • 2
  • 13