How to Detect a 'Click' Gesture in Swiftui Tvos

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

Siri remote swipe gestures

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:

Sample Image

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



Leave a reply



Submit