AtelierClockwork

Wrapping up (for now)

With all of the basic UI in place, the last thing to get in place is figuring out how to get the right click gesture working. I came up with a proof of concept so now it’s just a matter of putting the pieces together.

The first step was to split the cell based on the OS involved, and I had to change the structure to get the behavior to work correctly. In particular, I needed to set an explicit frame, and set the foreground color before disabling the view to make sure that un-clickable cells kept the correct colors.

struct Cell: View {
    let value: Game.SpaceState
    let click: () -> Void
    let flag: () -> Void

    @ViewBuilder var body: some View {
        #if os(macOS)
            AppKitCell(state: value,
                       click: click,
                       flag: flag)
            .frame(width: 30, height: 30)
            .foregroundColor(value.foregroundColor)
            .disabled(isDisabled)
        #else
            if isDisabled {
                buttonContent
            } else {
                Button(action: click) {
                    buttonContent
                }
                .foregroundColor(value.foregroundColor)
                .highPriorityGesture(flagGesture)
                .buttonStyle(.plain)
            }
        #endif
    }
}

I then got to re-use the updating gesture recognizer from my proof of concept, but there was a lot of book keeping involved. Also of note, this is created as a NSHostingController that wraps a SwiftUI View, so all of the UI is still driven by SwiftUI, but with a thin enough shim of AppKit exposed that we can add some gesture recognizers.

final class ButtonHostingViewController: NSHostingController<ButtonHostingViewController.Body> {
    class ButtonViewModel: ObservableObject {
        @Published var state: Game.SpaceState = .unrevealed
        @Published var isPrimaryDown = false
        @Published var isSecondaryDown = false
        @Published var primaryAction: (() -> Void)? = nil
        @Published var secondaryAction: (() -> Void)? = nil

        var isButtonDown: Bool {
            isPrimaryDown || isSecondaryDown
        }
    }

    struct Body: View {
        @ObservedObject var viewModel: ButtonViewModel

        var body: some View {
            viewModel.state.content
                .frame(width: 30, height: 30)
                .background(viewModel.state.fillColor)
                .opacity(viewModel.isButtonDown ? 0.3 : 1)
        }
    }

    private let viewModel = ButtonViewModel()

    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(state: Game.SpaceState,
         primaryAction: @escaping () -> Void,
         secondaryAction: @escaping () -> Void) {
        super.init(rootView: Body(viewModel: viewModel))
        view.addGestureRecognizer(primaryGestureRecognizer)
        view.addGestureRecognizer(secondaryGestureRecognizer)
        update(state: state,
               primaryAction: primaryAction,
               secondaryAction: secondaryAction)
    }

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

    func update(state: Game.SpaceState,
                primaryAction: @escaping () -> Void,
                secondaryAction: @escaping () -> Void) {
        viewModel.state = state
        viewModel.primaryAction = primaryAction
        viewModel.secondaryAction = secondaryAction
    }

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

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

This handles creating the view, adding the gesture recognizers that update the state as it changes, and updating the SwiftUI view as needed. It feels like a lot of code for a really small behavioral change, but I’m thrilled that I got it working.