skrew
skrew

Reputation: 889

SwiftUI on tvOS: How to know which item is selected in a list

In a list, i need to know which item is selected and this item have to be clickable.

This is what i try to do:

| item1 | info of the item3 (selected) |
| item2 |                              |
|*item3*|                              |
| item4 |                              |

I can make it with .focusable() but it's not clickable.

Button or NavigationLink works but i can't get the current item selected.

When you use Button or NavigationLink .focusable don't hit anymore.

So my question is:

How i can get the current item selected (so i can display more infos about this item) and make it clickable to display the next view ?

Sample code 1: Focusable works but .onTap doesn't exists on tvOS

import SwiftUI

struct TestList: Identifiable {
    var id: Int
    var name: String
}

let testData = [Int](0..<50).map { TestList(id: $0, name: "Row \($0)")  }


struct SwiftUIView : View {
    var testList: [TestList]

    var body: some View {
        List {
            ForEach(testList) { txt in
                TestRow(row: txt)
            }
        }
    }
}

struct TestRow: View {
    var row: TestList

    @State private var backgroundColor = Color.clear

    var body: some View {
        Text(row.name)
        .focusable(true) { isFocused in
            self.backgroundColor = isFocused ? Color.green : Color.blue
            if isFocused {
                print(self.row.name)
            }
        }
        .background(self.backgroundColor)
    }
}

Sample code 2: items are clickable via NavigationLink but there is no way to get the selected item and .focusable is not called anymore.

import SwiftUI

struct TestList: Identifiable {
    var id: Int
    var name: String
}

let testData = [Int](0..<50).map { TestList(id: $0, name: "Row \($0)")  }


struct SwiftUIView : View {
    var testList: [TestList]

    var body: some View {
        NavigationView {
            List {
                ForEach(testList) { txt in
                    NavigationLink(destination: Text("Destination")) {
                        TestRow(row: txt)
                    }
                }
            }
        }
    }
}

struct TestRow: View {
    var row: TestList

    @State private var backgroundColor = Color.clear

    var body: some View {
        Text(row.name)
        .focusable(true) { isFocused in
            self.backgroundColor = isFocused ? Color.green : Color.blue
            if isFocused {
                print(self.row.name)
            }
        }
        .background(self.backgroundColor)
    }
}

Upvotes: 3

Views: 2560

Answers (3)

Shaybc
Shaybc

Reputation: 3177

mark a view as focusable true (stating you want it to be able to have a focus), and implement onFocusChange to save the focus state

.focusable(true, onFocusChange: { focused in
    isFocused = focused
})

you need to save the isFocused as a @State var

 @State var isFocused: Bool = false

then style your View based on the isFocused value

.scaleEffect(isFocused ? 1.2 : 1.0)

here is a fully working example:

struct MyCustomFocus: View {
    @State var isFocused: Bool = false
    
    var body: some View {
        Text("Select Me")
            .focusable(true, onFocusChange: { focused in
                isFocused = focused
            })
            .shadow(color: Color.black, radius: isFocused ? 10 : 5, x: 5, y: isFocused ? 20 : 5)
            .scaleEffect(isFocused ? 1.2 : 1.0)
            .animation(.spring().speed(2))
            .padding()
    }
}

struct CustomFocusTest: View {
    var body: some View {
        VStack
        {
            HStack
            {
                MyCustomFocus()
                MyCustomFocus()
                MyCustomFocus()
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color.yellow)
        .ignoresSafeArea(.all) // frame then backround then ignore for full screen background (order matters)
        .edgesIgnoringSafeArea(.all)
    }
}

screenshot

Upvotes: 3

Jason Hawkins
Jason Hawkins

Reputation: 1

I haven't had much luck with custom button styles on tvOS, unfortunately.

However, to create a focusable, selectable custom view in SwiftUI on tvOS you can set the button style to plain. This allows you to keep the nice system-provided focus and selection animations, while you provide the destination and custom layout. Just add the .buttonStyle(PlainButtonStyle()) modifier to your NavigationLink:

struct VideoCard: View {
    var body: some View {
        NavigationLink(
            destination: Text("Video player")
        ) {
            VStack(alignment: .leading, spacing: .zero) {
                Image(systemName: "film")
                    .frame(width: 356, height: 200)
                    .background(Color.white)
                Text("Video Title")
                    .foregroundColor(.white)
                    .padding(10)
            }
            .background(Color.primary)
            .frame(maxWidth: 400)
        }
        .buttonStyle(PlainButtonStyle())
    }
}

Here's a screenshot of what it looks like in the simulator.

Clicking the button on the Siri remote, or Enter or a keyboard, should work as you'd expect.

Upvotes: 0

Jeff
Jeff

Reputation: 891

It seems like a major oversite to me you can't attach a click event in swiftui for tvos. I've come up with a hack that allows you to make most swiftui components selectable and clickable. Hope it helps.

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

Upvotes: 2

Related Questions