Atelier Clockwork

Building Minesweeper 2.5

Adding the missing part

One of the "missing" features in SwiftUI that's tripped me up since the first version of Minesweeper that I implemented is that due to the cross platform nature of SwiftUI, there's no concept of a "right click", in previous version I've worked around that by supporting an option click recognizer for the touch gesture recognizer, but that isn't ideal for the macOS implementation.

As far as I can tell, there's still no right click support natively in SwiftUI, so I decided to hack something together myself. I decided that it should support:

  • Left Click
  • Right Click
  • Pass the up / down state into the SwiftUI view rather than having some of the view state handled by AppKit.

To get that working, requires a handful of layers. The outermost layer is:

struct SecondaryActionButton: NSViewControllerRepresentable {
    let status: Int
    let primaryAction: () -> Void
    let secondaryAction: () -> Void

    func makeNSViewController(context: Context) -> ButtonHostingViewController {
        ButtonHostingViewController(status: status,
                                    primaryAction: primaryAction,
                                    secondaryAction: secondaryAction)
    }

    func updateNSViewController(_ nsViewController: ButtonHostingViewController, context: Context) {
        nsViewController.update(state: status)
    }
}

This is the SwiftUI view that wraps the AppKit controller, and handles passing the actions / state into the AppKit controller. Since this was build out as a test, right now I'm, just using an Int and the test code increments / decrements the value.

The next thing that came up was to get the up + down events from the gesture recoginzer, I needed to create a subclass that let me add events for mouse up / down events, as well an handling cancel events:

private class UpdatingGestureRecognizer: NSClickGestureRecognizer {
    var onMouseDown: (() -> Void)?
    var onMouseUp: (() -> Void)?

    override func reset() {
        super.reset()
        onMouseUp?()
    }

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)
        if buttonMask == 0x1 {
            onMouseDown?()
        }
    }

    override func rightMouseDown(with event: NSEvent) {
        super.rightMouseDown(with: event)
        if buttonMask == 0x2 {
            onMouseDown?()
        }
    }
}

Finally, I had to create the view controller that wraps the SwiftUI view and adds the gesture recognizers. To pass data from UIKit into SwiftUI, I created an ObservableObject that the ViewController owns and passes into the SwiftUI view, which keeps the UIKit and SwiftUI states in sync.

final class ButtonHostingViewController: NSHostingController<ButtonHostingViewController.Body> {
    class ViewModel: ObservableObject {
        @Published var status: Int = 0
        @Published var isPrimaryDown = false
        @Published var isSecondaryDown = false

        var isButtonDown: Bool {
            isPrimaryDown || isSecondaryDown
        }
    }

    struct Body: View {
        @ObservedObject var viewModel: ViewModel

        var body: some View {
            Text(viewModel.status, format: .number)
                .opacity(viewModel.isButtonDown ? 0.3 : 1)
        }
    }

    private let viewModel = ViewModel()
    private let primaryAction: () -> Void
    private let secondaryAction: () -> Void

    private lazy var primaryGestureRecognizer: NSClickGestureRecognizer = {
        let primaryGestureRecognizer = UpdatingGestureRecognizer(target: self, action: #selector(primaryEvent))
        primaryGestureRecognizer.onMouseUp = { [viewModel] in
            viewModel.isPrimaryDown = false
        }
        primaryGestureRecognizer.onMouseDown = { [viewModel] in
            viewModel.isPrimaryDown = true
        }
        return primaryGestureRecognizer
    }()

    private lazy var secondaryGestureRecognizer: NSClickGestureRecognizer = {
        let secondaryGestureRecognizer = UpdatingGestureRecognizer(target: self, action: #selector(secondaryEvent))
        secondaryGestureRecognizer.onMouseUp = { [viewModel] in
            viewModel.isSecondaryDown = false
        }
        secondaryGestureRecognizer.onMouseDown = { [viewModel] in
            viewModel.isSecondaryDown = true
        }
        secondaryGestureRecognizer.buttonMask = 0x2
        return secondaryGestureRecognizer
    }()

    init(status: Int,
         primaryAction: @escaping () -> Void,
         secondaryAction: @escaping () -> Void) {
        self.primaryAction = primaryAction
        self.secondaryAction = secondaryAction
        super.init(rootView: Body(viewModel: viewModel))
        view.addGestureRecognizer(primaryGestureRecognizer)
        view.addGestureRecognizer(secondaryGestureRecognizer)
    }

    @MainActor required dynamic init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func update(state: Int) {
        viewModel.status = state
    }

    @objc private func primaryEvent() -> Void {
        primaryAction()
    }

    @objc private func secondaryEvent() -> Void {
        secondaryAction()
    }
}

It's more verbose than I'd like, and this particular version tightly couples the button wrapper and the content, but it should work and it will be interesting to see if there's a performance hit on larger boards.

Part of why I implemented this is that I'm not running the macOS Ventura betas on my Mac yet, I'm holding out for the public betas, so I don't have a place to run the Mac build of Minesweeper quite yet. I'm interested in putting this together with my other code when I get a chance.