Steven B.
Steven B.

Reputation: 1539

SwiftUI Button tvOS+iOS action working for iOS not on tvOS

I'm trying to share a single swiftUI Button View for iOS and tvOS. for iOS everything is working as expected but tvOS is not triggering button actions.

var body: some View {
    Button(action: tapEvent) {
        HStack {
            if let image = icon, let uiimage = UIImage(named: image) {
                Image(uiImage: uiimage)
                    .resizable()
                    .renderingMode(.template)
                    .foregroundColor(iconColorForState(active: active, buttonMode: mode))
                    .frame(width: 20, height: 20, alignment: .center)
            }
            if let title = label {
                Text(title)
                    .lineLimit(1)
            }
        }
    }
    .buttonStyle(PlainButtonStyle())
    .foregroundColor(foregroundColorForState(active: active, buttonMode: mode))
    .padding()
    .disabled(disabled)
    .background(self.focused ? focussedBackgroundColorFor(active: active, mode: mode) : backgroundColorFor(active: active, mode: mode))
    .frame(height: heightForButtonSize(size: size ?? .Base))
    .cornerRadius(8)
    .modifier(FocusableModifier { focused in
        self.focused = focused
    })
}

FocusableModifier (to allow building the button class in iOS and tvOS):

struct FocusableModifier: ViewModifier {
    private let isFocusable: Bool
    private let onFocusChange: (Bool) -> Void

    init (_ isFocusable: Bool = true, onFocusChange: @escaping (Bool) -> Void = { _ in }) {
        self.isFocusable = isFocusable
        self.onFocusChange = onFocusChange
    }

    @ViewBuilder
    func body(content: Content) -> some View {
        #if os(tvOS)
            content.focusable(isFocusable, onFocusChange: onFocusChange)
        #else
            content
        #endif
    }
}

Then I use this button like this in my iOS and tvOs views:

ButtonView(label: "Primary", mode: .Primary, tapEvent: { print("Tapped") })

Problem is that on iOS the button action is executed on tap, but on tvOS the button tap event is not executed.

Update: I found that when removing cornerRadius and the modifier, the button event DOES get triggered. If cornerRadius or modifier is present on the button, tvOS doesn't want to respond to button taps anymore. I still need the modifier and cornerRadius Though.

Upvotes: 3

Views: 1382

Answers (1)

Phil Dukhov
Phil Dukhov

Reputation: 87605

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:

enter image description here

Upvotes: 4

Related Questions