Warm tip: This article is reproduced from serverfault.com, please click

SwiftUI

发布于 2020-11-28 08:24:31

I have in my app a layout showing a list of rectangular cards - each one should be tappable (once) to reveal a set of action buttons and more information, etc.

I have implemented this using .onTapGesture() and I have also put .contentShape(Rectangle() to enforce the tappable area. However, while my implementation works fine for touchscreen interface, when I'm using it with the iPadOS mouse support, and on Catalyst for that matter, I see some very unexpected behaviour.

I've made a minimal reproducible example below that you can copy to recreate the problem.

Where the problems are when using mouse/trackpad input:

  • Not every click of the mouse is recorded as a tap gesture. This is happening mostly arbitrary except from in a few cases:
  • It seems to click either only in very specific areas, or when clicking multiple times in the same spot.
  • It seems that in a lot of cases only every other click is recorded. So I double click to get only one tap gesture.
  • It isn't evident in this example code, but in my main app the tappable areas are seemingly arbitrary - you can usually click near text or in alignment with it to record a tap gesture, but not always.

If you are running the example code you should be able to see the problem by repeatedly moving the mouse and attempting one click. It doesn't work unless you click multiple times in the same spot.

What does work as expected:

  • All input using touch instead of mouse; regardless of where you tap it records a tap gesture.
  • Mouse input when running as a native Mac target. The issues mentioned above are only for mouse/trackpad when running the example under iPadOS and Mac Catalyst.

Code I used to recreate this problem (has a counter to count every time a tap gesture is recorded):

struct WidgetCompactTaskItemView: View {
    
    let title: String
    let description: String
    
    var body: some View {
        HStack {
            Rectangle()
                .fill(Color.purple)
                .frame(maxWidth: 14, maxHeight: .infinity)
            VStack(alignment: .leading) {
                Text(title).font(.system(size: 14, weight: .bold, design: .rounded))
                Text(description).font(.system(.footnote, design: .rounded))
                    .frame(maxHeight: .infinity)
                    .fixedSize(horizontal: false, vertical: true)
                    .lineLimit(1)
                    .padding(.vertical, 0.1)
                Spacer()
            }
            .padding(.horizontal, 6)
            .padding(.top, 12)
        }
        .frame(maxWidth: .infinity, maxHeight: 100, alignment: .leading)
        .background(Color.black)
        .cornerRadius(16)
        .overlay(
                RoundedRectangle(cornerRadius: 16)
                    .stroke(Color.green, lineWidth: 0.5)
            )
    }
}

struct ContentView: View {
    @State var tapCounter = 0
    var body: some View {
        VStack {
            Text("Button tapped \(tapCounter) times.")
            WidgetCompactTaskItemView(title: "Example", description: "Description")
                .contentShape(Rectangle())
                .onTapGesture(count: 1) {
                    tapCounter += 1
                }
            Spacer()
        }
     }
}

I have tried several things including moving modifiers around, setting eoFill to true on the contentShape modifier (which didn't fix the problem but simply made different unexpected behaviour).

Any help to find a solution that works as expected and works consistently whether mouse or touch would be much appreciated. I am not sure if I am doing something wrong or if there is a bug here, so please try and recreate this example yourself using the code to see if you can reproduce the problem.

Questioner
themathsrobot
Viewed
0
themathsrobot 2020-12-01 21:08:26

So I realised that there was a much better solution that could bypass all the oddities that .onTapGesture had for me with mouse input. It was to encapsulate the whole view in a Button instead.

I made this into a modifier similar to onTapGesture so that it's much more practical.


import Foundation
import SwiftUI

public struct UltraPlainButtonStyle: ButtonStyle {
    public func makeBody(configuration: Self.Configuration) -> some View {
        configuration.label
    }
}

struct Tappable: ViewModifier {
    let action: () -> ()
    func body(content: Content) -> some View {
        Button(action: self.action) {
            content
        }
        .buttonStyle(UltraPlainButtonStyle())
    }
}

extension View {
    func tappable(do action: @escaping () -> ()) -> some View {
        self.modifier(Tappable(action: action))
    }
}

Going through this: I first have a button style which simply returns the label as is. This is necessary because the default PlainButtonStyle() still has a visible effect when clicked. I then create a modifier that encapsulates the content given in a Button with this button style, then add that as an extension to View.

Usage example

WidgetCompactTaskItemView(title: "Example", description: "Description")
                .tappable {
                    tapCounter += 1
                }

This has solved all problems I've been having with clickable area using a mouse.