Building Minesweeper 5
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.