swiftuitvossiri-remote

Detect Siri Remote swipe in SwiftUI


How can you recognise Siri Remote swipe gestures from SwiftUI.

I seems like it has not yet been implemented, so how can I get around that?


Solution

  • 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()

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