Skip to content

Instantly share code, notes, and snippets.

@rdev
Created September 23, 2021 05:16
Show Gist options
  • Select an option

  • Save rdev/eacfcb2636c0537afab9e18917cbcc9d to your computer and use it in GitHub Desktop.

Select an option

Save rdev/eacfcb2636c0537afab9e18917cbcc9d to your computer and use it in GitHub Desktop.
SwiftUI ClickOutside component
//
// ClickOutside.swift
// ClickOutside
//
// Created by Max Rovensky on 11/07/2021.
//
import SwiftUI
class ClickOutsideHostingView<Content: View>: NSHostingView<Content> {
var frameBounds: CGRect
var action: () -> Void
private var clickMonitor: Any?
private var escMonitor: Any?
init(bounds: CGRect, action: @escaping () -> Void, content: () -> Content) {
self.action = action
self.frameBounds = bounds
super.init(rootView: content())
}
deinit {
// Clean up monitors
removeClickOutsideListener()
}
@available(*, unavailable)
@MainActor @objc dynamic required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@MainActor required init(rootView: Content) {
fatalError("init(rootView:) has not been implemented")
}
func addClickOutsideListener() {
clickMonitor = NSEvent.addLocalMonitorForEvents(matching: NSEvent.EventTypeMask.leftMouseDown) { event -> NSEvent? in
// GeometryReader bounds in .global coordinate system don't seem to be accounting for the toolbar,
// so we need to correct for this
if let windowFrameHeight = NSApp.keyWindow?.contentView?.frame.height,
let contentLayoutRectHeight = NSApp.keyWindow?.contentLayoutRect.height
{
let toolbarHeight = windowFrameHeight - contentLayoutRectHeight
let bounds = CGRect(
origin: CGPoint(x: self.frameBounds.origin.x, y: self.frameBounds.origin.y - toolbarHeight),
size: self.frameBounds.size
)
if !bounds.contains(event.locationInWindow) {
self.action()
}
}
return event
}
escMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [self] (event) -> NSEvent? in
if event.keyCode == 53 { // 53 = esc
self.action()
return nil
}
return event
}
}
func removeClickOutsideListener() {
if let click = clickMonitor, let esc = escMonitor {
NSEvent.removeMonitor(click)
NSEvent.removeMonitor(esc)
self.clickMonitor = nil
self.escMonitor = nil
}
}
}
struct ClickOutsideView<Content: View>: NSViewRepresentable {
var active: Binding<Bool>
var bounds: CGRect
var action: () -> Void
var content: () -> Content
func makeNSView(context: Context) -> ClickOutsideHostingView<Content> {
let view = ClickOutsideHostingView(bounds: bounds, action: action, content: content)
view.addClickOutsideListener()
return view
}
func updateNSView(_ view: ClickOutsideHostingView<Content>, context: Context) {
if active.wrappedValue == true {
view.addClickOutsideListener()
} else {
view.removeClickOutsideListener()
}
}
}
struct ClickOutside<Content: View>: View {
var active: Binding<Bool>
var action: () -> Void
var content: () -> Content
var body: some View {
GeometryReader { view in
ClickOutsideView(active: active, bounds: view.frame(in: .global), action: action, content: content)
}
}
}
import SwiftUI
struct MyView: View {
@State var modalVisible = true
var body: some View {
ClickOutside(active: $modalVisible, action: {
self.modalVisible = false
}) {
if modalVisible {
SomeModal()
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment