How to detect a 'Click' gesture in SwiftUI tvOS
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)
}
}
}
}
Detect Siri Remote swipe in SwiftUI
the other (more reliable but hacky) way is to use the GameController low-level x/y values for dPad control,
the Siri remote is considered a Game Controller as well, and it is the first one set to the connected game controllers of the apple tv,
so onAppear of a SwiftUI view you can do something like this:
import SwiftUI
import GameController
struct SwipeTestView: View
{
var body: some View
{
Text("This can be some full screen image or what not")
.onAppear(perform: {
let gcController = GCController.controllers().first
let microGamepad = gcController!.microGamepad
microGamepad!.reportsAbsoluteDpadValues = true
microGamepad!.dpad.valueChangedHandler = { pad, x, y in
let fingerDistanceFromSiriRemoteCenter: Float = 0.7
let swipeValues: String = "x: \(x), y: \(y), pad.left: \(pad.left), pad.right: \(pad.right), pad.down: \(pad.down), pad.up: \(pad.up), pad.xAxis: \(pad.xAxis), pad.yAxis: \(pad.yAxis)"
if y > fingerDistanceFromSiriRemoteCenter
{
print(">>> up \(swipeValues)")
}
else if y < -fingerDistanceFromSiriRemoteCenter
{
print(">>> down \(swipeValues)")
}
else if x < -fingerDistanceFromSiriRemoteCenter
{
print(">>> left \(swipeValues)")
}
else if x > fingerDistanceFromSiriRemoteCenter
{
print(">>> right \(swipeValues)")
}
else
{
//print(">>> tap \(swipeValues)")
}
}
})
.focusable() // <-- this is required only if you want to capture 'press' and 'LongPress'
.onLongPressGesture(minimumDuration: 1, perform: { // on press action
print(">>> Long press")
})
.onLongPressGesture(minimumDuration: 0.01, perform: { // on press action
print(">>> press")
})
}
}
this is a far more reliable solution and works every time, all you have to do is swipe finger from the center of the Siri remote outwards to your desired swipe direction (up / down / left / right),
you could also implement this way up+left, up+right, down+left, down+right, circular-clockwise swipe or circular-counter-clockwise and what ever you want.
You even might be able to implement magnification gesture and alike using the simultaneousGesture()
- Note: [12.SEP.2021] if you intend to run this code on a simulator know that for now the simulator does not support the controller as a GameController yet and the line:
GCController.controllers().first
will return nil, you need a real hardware to try it, see this answer
I wrote several extensions based on that and tested (tvOS 14.7), here is one that you can use as SwipeGesture for tvOS:
import SwiftUI
import GameController
// MARK: - View+swipeGestures
struct SwipeGestureActions: ViewModifier
{
// swipeDistance is how much x/y values needs to be acumelated by a gesture in order to consider a swipe (the distance the finger must travel)
let swipeDistance: Float = 0.7
// how much pause in milliseconds should be between gestures in order for a gesture to be considered a new gesture and not a remenat x/y values from the previous gesture
let secondsBetweenInteractions: Double = 0.2
// the closures to execute when up/down/left/right gesture are detected
var onUp: () -> Void = {}
var onDown: () -> Void = {}
var onRight: () -> Void = {}
var onLeft: () -> Void = {}
@State var lastY: Float = 0
@State var lastX: Float = 0
@State var totalYSwipeDistance: Float = 0
@State var totalXSwipeDistance: Float = 0
@State var lastInteractionTimeInterval: TimeInterval = Date().timeIntervalSince1970
@State var isNewSwipe: Bool = true
func resetCounters(x: Float, y: Float)
{
isNewSwipe = true
lastY = y // start counting from the y point the finger is touching
totalYSwipeDistance = 0
lastX = x // start counting from the x point the finger is touching
totalXSwipeDistance = 0
}
func body(content: Content) -> some View
{
content
.onAppear(perform: {
let gcController = GCController.controllers().first
let microGamepad = gcController!.microGamepad
microGamepad!.reportsAbsoluteDpadValues = false // assumes the location where the user first touches the pad is the origin value (0.0,0.0)
let currentHandler = microGamepad!.dpad.valueChangedHandler
microGamepad!.dpad.valueChangedHandler = { pad, x, y in
// if there is already a hendler set - execute it as well
if currentHandler != nil {
currentHandler!(pad, x, y)
}
/* check how much time passed since the last interaction on the siri remote,
* if enough time has passed - reset counters and consider these coming values as a new gesture values
*/
let nowTimestamp = Date().timeIntervalSince1970
let elapsedNanoSinceLastInteraction = nowTimestamp - lastInteractionTimeInterval
lastInteractionTimeInterval = nowTimestamp // update the last interaction interval
if elapsedNanoSinceLastInteraction > secondsBetweenInteractions
{
resetCounters(x: x, y: y)
}
/* accumelate the Y axis swipe travel distance */
let currentYSwipeDistance = y - lastY
lastY = y
totalYSwipeDistance = totalYSwipeDistance + currentYSwipeDistance
/* accumelate the X axis swipe travel distance */
let currentXSwipeDistance = x - lastX
lastX = x
totalXSwipeDistance = totalXSwipeDistance + currentXSwipeDistance
// print("y: \(y), x: \(x), totalY: \(totalYSwipeDistance) totalX: \(totalXSwipeDistance)")
/* check if swipe travel goal has been reached in one of the directions (up/down/left/right)
* as long as it is consedered a new swipe (and not a swipe that was already detected and executed
* and waiting for a few milliseconds stop between interactions)
*/
if (isNewSwipe)
{
if totalYSwipeDistance > swipeDistance && totalYSwipeDistance > 0 // swipe up detected
{
isNewSwipe = false // lock so next values will be disregarded until a few milliseconds of 'remote silence' achieved
onUp() // execute the appropriate closure for this detected swipe
}
else if totalYSwipeDistance < -swipeDistance && totalYSwipeDistance < 0 // swipe down detected
{
isNewSwipe = false
onDown()
}
else if totalXSwipeDistance > swipeDistance && totalXSwipeDistance > 0 // swipe right detected
{
isNewSwipe = false
onRight()
}
else if totalXSwipeDistance < -swipeDistance && totalXSwipeDistance < 0 // swipe left detected
{
isNewSwipe = false
onLeft()
}
else
{
//print(">>> tap")
}
}
}
})
}
}
extension View
{
func swipeGestures(onUp: @escaping () -> Void = {},
onDown: @escaping () -> Void = {},
onRight: @escaping () -> Void = {},
onLeft: @escaping () -> Void = {}) -> some View
{
self.modifier(SwipeGestureActions(onUp: onUp,
onDown: onDown,
onRight: onRight,
onLeft: onLeft))
}
}
and you can use it like this:
struct TVOSSwipeTestView: View
{
@State var markerX: CGFloat = UIScreen.main.nativeBounds.size.width / 2
@State var markerY: CGFloat = UIScreen.main.nativeBounds.size.height / 2
var body: some View
{
VStack
{
Circle()
.stroke(Color.white, lineWidth: 5)
.frame(width: 40, height: 40)
.position(x: markerX, y: markerY)
.animation(.easeInOut(duration: 0.5), value: markerX)
.animation(.easeInOut(duration: 0.5), value: markerY)
}
.background(Color.blue)
.ignoresSafeArea(.all)
.edgesIgnoringSafeArea(.all)
.swipeGestures(onUp: {
print("onUp()")
markerY = markerY - 40
},
onDown: {
print("onDown()")
markerY = markerY + 40
},
onRight: {
print("onRight()")
markerX = markerX + 40
},
onLeft: {
print("onLeft()")
markerX = markerX - 40
})
.focusable() // <-- this is required only if you want to capture 'press' and 'LongPress'
.onLongPressGesture(minimumDuration: 1, perform: { // on press action
print(">>> Long press")
})
.onLongPressGesture(minimumDuration: 0.01, perform: { // on press action go to middle of the screen
markerX = UIScreen.main.nativeBounds.size.width / 2
markerY = UIScreen.main.nativeBounds.size.height / 2
})
}
}
Is it possible to use PanGestureRecognizer for tvOS?
Yes it is! Check this Apple guide for more information.
Tap gesture recognizers can be used to detect button presses. By
default, a tap gesture recognizer is triggered when the Select button
is pressed. The allowedPressTypes property is used to specify which
buttons trigger the recognizer.
Examples
Detecting the Play/Pause button
let tapRecognizer = UITapGestureRecognizer(target: self, action: "tapped:")
tapRecognizer.allowedPressTypes = [NSNumber(integer: UIPressType.PlayPause.rawValue)];
self.view.addGestureRecognizer(tapRecognizer)
Detecting a swipe gesture
let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: "swiped:")
swipeRecognizer.direction = .Right
self.view.addGestureRecognizer(swipeRecognizer)
To get the location of touches, check the Getting the Location of Touches section in Apples documentation. You need to use locationInView
for that.
Returns the current location of the receiver in the coordinate system
of the given view.
SwiftUI Button tvOS+iOS action working for iOS not on tvOS
The problem in your code is that your focusable
modifier is blocking the clicks.
To solve this you can reimplement the button from the scratch. I've created CustomButton
inspired by this answer: it'll be a plain button on iOS and a custom one on Apple TV:
struct CustomButton<Content>: View where Content : View {
@State
private var focused = false
@State
private var pressed = false
let action: () -> Void
@ViewBuilder
let content: () -> Content
var body: some View {
contentView
.background(focused ? Color.green : .yellow)
.cornerRadius(20)
.scaleEffect(pressed ? 1.1 : 1)
.animation(.default, value: pressed)
}
var contentView: some View {
#if os(tvOS)
ZStack {
ClickableHack(focused: $focused, pressed: $pressed, action: action)
content()
.padding()
.layoutPriority(1)
}
#else
Button(action: action, label: content)
#endif
}
}
class ClickableHackView: UIView {
weak var delegate: ClickableHackDelegate?
override init(frame: CGRect) {
super.init(frame: frame)
}
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if validatePress(event: event) {
delegate?.pressesBegan()
} else {
super.pressesBegan(presses, with: event)
}
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if validatePress(event: event) {
delegate?.pressesEnded()
} else {
super.pressesEnded(presses, with: event)
}
}
override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if validatePress(event: event) {
delegate?.pressesEnded()
} else {
super.pressesCancelled(presses, with: event)
}
}
private func validatePress(event: UIPressesEvent?) -> Bool {
event?.allPresses.map({ $0.type }).contains(.select) ?? false
}
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
}
}
protocol ClickableHackDelegate: AnyObject {
func focus(focused: Bool)
func pressesBegan()
func pressesEnded()
}
struct ClickableHack: UIViewRepresentable {
@Binding var focused: Bool
@Binding var pressed: Bool
let action: () -> 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 pressesBegan() {
control.pressed = true
}
func pressesEnded() {
control.pressed = false
control.action()
}
}
}
Usage:
CustomButton(action: {
print("clicked 1")
}) {
Text("Clickable 1")
}
CustomButton(action: {
print("clicked 2")
}) {
Text("Clickable 2")
}
Result:
How to detect the circular Gestures on New Siri Remote 2nd Generation
Apple did not announce any new gesture recognizer for this new gesture, but you can implement it by yourself.
To do it you can use the GameController SDK that will allow you to get the absolute location of the user finger in the digitizer. Once you have the coordinates of the gesture location you can apply some trigonometry maths to detect if they are rotating the finger clockwise or anticlockwise.
I wrote a post with further details here:
https://dcordero.me/posts/capture_circular_gestures_on_siri_remote_2nd_generation.html
How to detect click-press on the touchpad on Apple TV's remote?
By reading the UIPressesEvent's. Detecting Gestures and Button Presses
override func pressesEnded(presses: Set<UIPress>, withEvent event: UIPressesEvent?) {
for press in presses {
if (press.type == .Select) {
// Select is pressed
} else {
super.pressesEnded(presses, withEvent: event)
}
}
}
Related Topics
Detailed Instruction on Use of Nsopenpanel
Binary Operator '+' Cannot Be Applied to Two 'T' Operands
Include an Extension for a Class Only If iOS11 Is Available
"Ambiguous Use of 'Propertyname'" Error Given Overridden Property with Didset Observer
Using Swift to Disable Sleep/Screen Saver for Osx
When Should I Use Optionals and When Should I Use Non-Optionals with Default Values
How to Set an Attributed Title Color for State in Swift
Why 'There Cannot Be More Than One Conformance, Even with Different Conditional Bounds'
How to Filter on an Array of Objects in Swift
Generic Swift Dictionary Extension for Nil Filtering
How to Use Unsafemutablerawpointer to Fill an Array
Weak VS Unowned in Swift. What Are the Internal Differences
Simple Clickable Link in Cocoa and Swift
Swift Safe Area Layout Guide and Visual Format Language
Why Does My Version of Filter Perform So Differently Than Swifts
Swiftui 2 Firebase Push Notification
How to Use Swift Package Manager with an Existing MACos Project