How to detect a 'Click' gesture in SwiftUI tvOS

If you'd like to avoid UIKit, you can achieve the desired solution with Long Press Gesture by setting a really small duration of pressing.

1. Only Press:

If you only need to handle the pressing action and don't need long pressing at all.

ContentView()
   .onLongPressGesture(minimumDuration: 0.01, pressing: { _ in }) {
       print("pressed")
   }

2. Press and Long press:

If you need to handle both pressing and Long pressing.

var longPress: some Gesture {
    LongPressGesture(minimumDuration: 0.5)
       .onEnded { _ in
          print("longpress")
       }
}

ContentView()
   .highPriorityGesture(longPress)
   .onLongPressGesture(minimumDuration: 0.01, pressing: { _ in }) {
       print("press")
   }

Edit: onTapGesture() is now available starting in tvOS 16

tvOS 16

struct ContentView: View {
    @FocusState var focused1
    @FocusState var focused2

    var body: some View {
        HStack {
            Text("Clickable 1")
                .foregroundColor(self.focused1 ? Color.red : Color.black)
                .focusable(true)
                .focused($focused1)
                .onTapGesture {
                    print("clicked 1")
                }
            Text("Clickable 2")
                .foregroundColor(self.focused2 ? Color.red : Color.black)
                .focusable(true)
                .focused($focused2)
                .onTapGesture {
                    print("clicked 2")
                }
        }
        
    }
}

Previous Answer for tvOS 15 and earlier

It is possible, but not for the faint of heart. I came up with a somewhat generic solution that may help you. I hope in the next swiftUI update Apple adds a better way to attach click events for tvOS and this code can be relegated to the trash bin where it belongs.

The high level explanation of how to do this is to make a UIView that captures the focus and click events, then make a UIViewRepresentable so swiftUI can use the view. Then the view is added to the layout in a ZStack so it's hidden, but you can receive focus and respond to click events as if the user was really interacting with your real swiftUI component.

First I need to make a UIView that captures the events.

class ClickableHackView: UIView {
    weak var delegate: ClickableHackDelegate?
    
    override init(frame: CGRect) {
        super.init(frame: frame)        
    }

    override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
        if event?.allPresses.map({ $0.type }).contains(.select) ?? false {
            delegate?.clicked()
        } else {
            superview?.pressesEnded(presses, with: event)
        }
    }

    override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
        delegate?.focus(focused: isFocused)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override var canBecomeFocused: Bool {
        return true
    }
}

The clickable delegate:

protocol ClickableHackDelegate: class {
    func focus(focused: Bool)
    func clicked()
}

Then make a swiftui extension for my view

struct ClickableHack: UIViewRepresentable {
    @Binding var focused: Bool
    let onClick: () -> Void
    
    func makeUIView(context: UIViewRepresentableContext<ClickableHack>) -> UIView {
        let clickableView = ClickableHackView()
        clickableView.delegate = context.coordinator
        return clickableView
    }
    
    func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<ClickableHack>) {
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    
    class Coordinator: NSObject, ClickableHackDelegate {
        private let control: ClickableHack
        
        init(_ control: ClickableHack) {
            self.control = control
            super.init()
        }
        
        func focus(focused: Bool) {
            control.focused = focused
        }
        
        func clicked() {
            control.onClick()
        }
    }
}

Then I make a friendlier swiftui wrapper so I can pass in any kind of component I want to be focusable and clickable

struct Clickable<Content>: View where Content : View {
    let focused: Binding<Bool>
    let content: () -> Content
    let onClick: () -> Void
    
    @inlinable public init(focused: Binding<Bool>, onClick: @escaping () -> Void, @ViewBuilder content: @escaping () -> Content) {
        self.content = content
        self.focused = focused
        self.onClick = onClick
    }
    
    var body: some View {
        ZStack {
            ClickableHack(focused: focused, onClick: onClick)
            content()
        }
    }
}

Example usage:

struct ClickableTest: View {
    @State var focused1: Bool = false
    @State var focused2: Bool = false
    
    var body: some View {
        HStack {
            Clickable(focused: self.$focused1, onClick: {
                print("clicked 1")
            }) {
                Text("Clickable 1")
                    .foregroundColor(self.focused1 ? Color.red : Color.black)
            }
            Clickable(focused: self.$focused2, onClick: {
                print("clicked 2")
            }) {
                Text("Clickable 2")
                    .foregroundColor(self.focused2 ? Color.red : Color.black)
            }
        }
    }
}