A collection of user interface components and drawing routines for building tasteful apps using AppKit.
appkit swift aqua ui mac

Add AquaDrawer

+655
+377
Sources/AquaKit/Drawers/AquaDrawer.swift
··· 1 + import AppKit 2 + 3 + extension AquaDrawer { 4 + /// This protocol replaces the AppKit `NSDrawerDelegate` protocol. 5 + @objc public protocol Delegate: NSObjectProtocol { 6 + /// This method is invoked on user-initiated attempts to open a drawer. 7 + @objc optional func drawerShouldOpen(_ drawer: AquaDrawer) -> Bool 8 + 9 + /// This method is invoked on user-initiated attempts to close a drawer. 10 + @objc optional func drawerShouldClose(_ drawer: AquaDrawer) -> Bool 11 + 12 + /// Notifies the delegate that the drawer will open. 13 + @objc optional func drawerWillOpen(_ notification: Notification) 14 + 15 + /// Notifies the delegate that the drawer did open. 16 + @objc optional func drawerDidOpen(_ notification: Notification) 17 + 18 + /// Notifies the delegate that the drawer will close. 19 + @objc optional func drawerWillClose(_ notification: Notification) 20 + 21 + /// Notifies the delegate that the drawer did close. 22 + @objc optional func drawerDidClose(_ notification: Notification) 23 + } 24 + } 25 + 26 + /// This is a replacement of the AppKit `NSDrawer` class, but it is not intended to replicate the interface and behaviour exactly. Instead, ``AquaDrawer`` is built on top of `NSWindowController`. 27 + public class AquaDrawer: NSWindowController { 28 + public enum State: Sendable, Equatable { 29 + case closedState 30 + case openingState 31 + case openState(edge: NSRectEdge) 32 + case closingState 33 + } 34 + 35 + public static let willOpenNotification: NSNotification.Name = NSNotification.Name("DrawerWillOpen") 36 + public static let didOpenNotification: NSNotification.Name = NSNotification.Name("DrawerDidOpen") 37 + public static let willCloseNotification: NSNotification.Name = NSNotification.Name("DrawerWillClose") 38 + public static let didCloseNotification: NSNotification.Name = NSNotification.Name("DrawerDidClose") 39 + 40 + public static let drawerTransitionDuration = 0.25 41 + public static let defaultDrawerExtent: CGFloat = 200.0 42 + public static let drawerOverlap: CGFloat = 14.0 43 + 44 + static let notificationDispatchTable: [Notification.Name: Selector] = [ 45 + willOpenNotification: #selector(Delegate.drawerWillOpen(_:)), 46 + didOpenNotification: #selector(Delegate.drawerDidOpen(_:)), 47 + willCloseNotification: #selector(Delegate.drawerWillClose(_:)), 48 + didCloseNotification: #selector(Delegate.drawerDidClose(_:)) 49 + ] 50 + 51 + public var delegate: Delegate? { 52 + didSet { 53 + if let oldValue { 54 + for notificationName in Self.notificationDispatchTable.keys { 55 + NotificationCenter.default.removeObserver( 56 + oldValue, 57 + name: notificationName, 58 + object: self 59 + ) 60 + } 61 + } 62 + 63 + for mapping in Self.notificationDispatchTable { 64 + if let delegate, delegate.responds(to: mapping.value) { 65 + NotificationCenter.default.addObserver( 66 + delegate, 67 + selector: mapping.value, 68 + name: mapping.key, 69 + object: self 70 + ) 71 + } 72 + } 73 + } 74 + } 75 + 76 + private let drawerChromeViewController: AquaDrawerChromeViewController 77 + 78 + /// The view controller for the inside of the drawer. 79 + public var drawerContentViewController: NSViewController? { 80 + get { drawerChromeViewController.contentViewController } 81 + set { drawerChromeViewController.contentViewController = newValue } 82 + } 83 + 84 + public private(set) var state: State = .closedState 85 + 86 + public var preferredExtents: [NSRectEdge: CGFloat] = [:] 87 + 88 + /// This replaces `NSDrawer.minContentSize`. 89 + public var minExtent: CGFloat? = 70.0 90 + 91 + /// This replaces `NSDrawer.maxContentSize`. 92 + public var maxExtent: CGFloat? = 200.0 93 + 94 + public var leadingOffset: CGFloat = 20.0 95 + public var trailingOffset: CGFloat = 10.0 96 + public var preferredEdge: NSRectEdge = .minX 97 + 98 + @IBOutlet public var targetWindow: NSWindow? { 99 + didSet { 100 + if let oldValue { 101 + NotificationCenter.default.removeObserver(self, name: NSWindow.didResizeNotification, object: oldValue) 102 + } 103 + 104 + if let targetWindow { 105 + NotificationCenter.default.addObserver( 106 + self, 107 + selector: #selector(parentWindowDidResize(_:)), 108 + name: NSWindow.didResizeNotification, 109 + object: targetWindow 110 + ) 111 + } 112 + } 113 + } 114 + 115 + public override var acceptsFirstResponder: Bool { true } 116 + 117 + /// A drawer acts as its window’s delegate. 118 + public override var window: NSWindow? { 119 + didSet { 120 + oldValue?.delegate = nil 121 + window?.delegate = self 122 + } 123 + } 124 + 125 + public convenience init(preferredExtents: [NSRectEdge: CGFloat], preferredEdge: NSRectEdge) { 126 + self.init(panel: nil) 127 + self.preferredExtents = preferredExtents 128 + self.preferredEdge = preferredEdge 129 + } 130 + 131 + required init(panel: AquaDrawerPanel?) { 132 + let panel = 133 + panel 134 + ?? AquaDrawerPanel( 135 + contentRect: NSRect(origin: .zero, size: .zero), 136 + styleMask: [.resizable, .utilityWindow, .nonactivatingPanel], 137 + backing: .buffered, 138 + defer: false 139 + ) 140 + 141 + panel.backgroundColor = .clear 142 + panel.becomesKeyOnlyIfNeeded = false 143 + 144 + self.drawerChromeViewController = AquaDrawerChromeViewController() 145 + super.init(window: panel) 146 + 147 + contentViewController = drawerChromeViewController 148 + panel.initialFirstResponder = drawerChromeViewController.view 149 + } 150 + 151 + required init?(coder: NSCoder) { 152 + fatalError("init(coder:) has not been implemented") 153 + } 154 + 155 + @MainActor 156 + deinit { 157 + delegate = nil 158 + targetWindow = nil 159 + } 160 + 161 + func drawerExtent(along edge: NSRectEdge) -> CGFloat { 162 + let preferred = preferredExtents[edge] ?? Self.defaultDrawerExtent 163 + return preferred.clamped( 164 + lowerBound: minExtent, 165 + upperBound: maxExtent 166 + ) 167 + } 168 + 169 + func drawerFrame(edge: NSRectEdge, state: State) -> NSRect? { 170 + guard let targetWindow, let (axis, extreme) = edge.split, !state.isTransitioning else { return nil } 171 + let extent = drawerExtent(along: edge) 172 + let parentContentRect = targetWindow.contentRect(forFrameRect: targetWindow.frame) 173 + let coextent = parentContentRect.size[axis.opposite] - (leadingOffset + trailingOffset) 174 + 175 + var origin = parentContentRect.origin 176 + 177 + switch axis { 178 + case .horizontal: origin.y += trailingOffset 179 + case .vertical: origin.x += leadingOffset 180 + } 181 + 182 + let overlapCoefficient: CGFloat = 183 + switch extreme { 184 + case .min: 1 185 + case .max: -1 186 + } 187 + 188 + origin[axis] += overlapCoefficient * Self.drawerOverlap 189 + 190 + let contentSize = NSSize(extent: extent, coextent: coextent, axis: axis) 191 + var frame = NSWindow.frameRect( 192 + forContentRect: NSRect(origin: origin, size: contentSize), 193 + styleMask: [.resizable] 194 + ) 195 + 196 + switch extreme { 197 + case .max: 198 + frame.origin[axis] += parentContentRect.size[axis] 199 + if case .closedState = state { 200 + frame.origin[axis] -= frame.size[axis] 201 + } 202 + 203 + case .min: 204 + if case .openState = state { 205 + frame.origin[axis] -= frame.size[axis] 206 + } 207 + } 208 + 209 + return frame 210 + } 211 + 212 + /// Computes the visible edge based on available space, taking into account the ``preferredEdge`` and ``preferredExtents``. 213 + var visibleEdge: NSRectEdge { 214 + guard 215 + let targetWindow, 216 + let screenRect = targetWindow.screen?.visibleFrame 217 + else { return preferredEdge } 218 + 219 + let parentRect = targetWindow.frame 220 + 221 + for edge in [ 222 + preferredEdge, preferredEdge.opposite, preferredEdge.counterclockwiseNextEdge, preferredEdge.clockwiseNextEdge 223 + ] { 224 + guard let edge else { continue } 225 + let availableSpace = edge.spaceAvailable(from: parentRect, in: screenRect) 226 + if availableSpace >= drawerExtent(along: edge) { 227 + return edge 228 + } 229 + } 230 + 231 + return preferredEdge 232 + } 233 + 234 + public func open() { 235 + open(edge: visibleEdge) 236 + } 237 + 238 + private func open(edge: NSRectEdge) { 239 + guard 240 + case .closedState = state, let targetWindow, let window, 241 + let startFrame = drawerFrame(edge: edge, state: .closedState), 242 + let endFrame = drawerFrame(edge: edge, state: .openState(edge: edge)) 243 + else { return } 244 + 245 + drawerChromeViewController.drawerEdge = edge 246 + 247 + NotificationCenter.default.post(name: Self.willOpenNotification, object: self) 248 + 249 + targetWindow.makeFirstResponder(self) 250 + window.setFrame(startFrame, display: true, animate: false) 251 + targetWindow.addChildWindow(window, ordered: .below) 252 + 253 + state = .openingState 254 + 255 + resetWindowOrdering() 256 + 257 + NSAnimationContext.runAnimationGroup { context in 258 + context.duration = Self.drawerTransitionDuration 259 + window.animator().setFrame(endFrame, display: true) 260 + } completionHandler: { [weak self] in 261 + if let self { 262 + Task { @MainActor in 263 + self.state = .openState(edge: edge) 264 + NotificationCenter.default.post(name: Self.didOpenNotification, object: self) 265 + } 266 + } 267 + } 268 + } 269 + 270 + public override func close() { 271 + guard 272 + case .openState(let edge) = state, 273 + let targetWindow, let window, 274 + let frame = drawerFrame(edge: edge, state: .closedState) 275 + else { return } 276 + 277 + targetWindow.endEditing(for: nil) 278 + targetWindow.makeFirstResponder(targetWindow) 279 + NotificationCenter.default.post(name: Self.willCloseNotification, object: self) 280 + state = .closingState 281 + 282 + NSAnimationContext.runAnimationGroup { context in 283 + context.duration = Self.drawerTransitionDuration 284 + window.animator().setFrame(frame, display: true) 285 + } completionHandler: { [weak self] in 286 + if let self { 287 + Task { @MainActor in 288 + self.window?.orderOut(nil) 289 + self.state = .closedState 290 + NotificationCenter.default.post(name: Self.didCloseNotification, object: self) 291 + } 292 + } 293 + } 294 + } 295 + 296 + @objc private func parentWindowDidResize(_ notification: Notification) { 297 + guard case .openState(let edge) = state, let window else { return } 298 + guard let frame = drawerFrame(edge: edge, state: state) else { return } 299 + window.setFrame(frame, display: true) 300 + } 301 + 302 + private func resetWindowOrdering() { 303 + guard let targetWindow, let window else { return } 304 + window.order(.above, relativeTo: targetWindow.windowNumber) 305 + window.order(.below, relativeTo: targetWindow.windowNumber) 306 + } 307 + } 308 + 309 + extension AquaDrawer { 310 + @IBAction public func open(_ sender: Any?) { 311 + if let shouldOpen = delegate?.drawerShouldOpen?(self), !shouldOpen { 312 + return 313 + } 314 + 315 + open() 316 + } 317 + 318 + @IBAction public func close(_ sender: Any?) { 319 + if let shouldClose = delegate?.drawerShouldClose?(self), !shouldClose { 320 + return 321 + } 322 + 323 + close() 324 + } 325 + 326 + @IBAction public func toggle(_ sender: Any?) { 327 + switch state { 328 + case .closedState: open(self) 329 + case .openState: close(self) 330 + case .openingState, .closingState: break 331 + } 332 + } 333 + } 334 + 335 + extension AquaDrawer: NSWindowDelegate { 336 + public func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { 337 + guard case State.openState(edge: let edge) = state, let axis = edge.axis, let parentWindow = sender.parent else { 338 + return frameSize 339 + } 340 + var frameSize = frameSize 341 + 342 + let parentFrame = parentWindow.frame 343 + let parentContentRect = parentWindow.contentRect(forFrameRect: parentFrame) 344 + let coextent = parentContentRect.size[axis.opposite] - (leadingOffset + trailingOffset) 345 + 346 + frameSize[axis.opposite] = coextent 347 + 348 + if let minExtent, frameSize[axis] < minExtent { 349 + return frameSize 350 + } 351 + 352 + frameSize[axis].clamp(lowerBound: minExtent, upperBound: maxExtent) 353 + 354 + return frameSize 355 + } 356 + 357 + public func windowDidEndLiveResize(_ notification: Notification) { 358 + guard case State.openState(edge: let edge) = state, let axis = edge.axis, 359 + let window = notification.object as? NSWindow 360 + else { return } 361 + let frameSize = window.frame.size 362 + if let minExtent, frameSize[axis] < minExtent { 363 + close(nil) 364 + } else { 365 + preferredExtents[edge] = frameSize[axis] 366 + } 367 + } 368 + } 369 + 370 + extension AquaDrawer.State { 371 + var isTransitioning: Bool { 372 + switch self { 373 + case .openingState, .closingState: true 374 + default: false 375 + } 376 + } 377 + }
+60
Sources/AquaKit/Drawers/AquaDrawerChromeView.swift
··· 1 + import AppKit 2 + 3 + final class AquaDrawerChromeView: NSView { 4 + private var maskLayer: CAShapeLayer 5 + var drawerEdge: NSRectEdge? { 6 + didSet { 7 + var insets = NSEdgeInsets() 8 + if let edge = drawerEdge?.opposite { 9 + insets[edge] = AquaDrawer.drawerOverlap 10 + } 11 + additionalSafeAreaInsets = insets 12 + } 13 + } 14 + 15 + override init(frame frameRect: NSRect) { 16 + self.maskLayer = CAShapeLayer() 17 + super.init(frame: frameRect) 18 + 19 + wantsLayer = true 20 + clipsToBounds = true 21 + layer!.mask = maskLayer 22 + 23 + let visualEffectsView = NSVisualEffectView() 24 + visualEffectsView.translatesAutoresizingMaskIntoConstraints = false 25 + visualEffectsView.material = .sheet 26 + visualEffectsView.state = .active 27 + 28 + addSubview(visualEffectsView) 29 + NSLayoutConstraint.activate([ 30 + visualEffectsView.topAnchor.constraint(equalTo: topAnchor), 31 + visualEffectsView.bottomAnchor.constraint(equalTo: bottomAnchor), 32 + visualEffectsView.leftAnchor.constraint(equalTo: leftAnchor), 33 + visualEffectsView.rightAnchor.constraint(equalTo: rightAnchor) 34 + ]) 35 + } 36 + 37 + required init?(coder: NSCoder) { fatalError() } 38 + 39 + override var safeAreaInsets: NSEdgeInsets { 40 + let defaultInset = 10.0 41 + return NSEdgeInsets( 42 + top: defaultInset + additionalSafeAreaInsets.top, 43 + left: defaultInset + additionalSafeAreaInsets.left, 44 + bottom: defaultInset + additionalSafeAreaInsets.bottom, 45 + right: defaultInset + additionalSafeAreaInsets.right 46 + ) 47 + } 48 + 49 + override func layout() { 50 + super.layout() 51 + 52 + maskLayer.frame = bounds 53 + maskLayer.path = 54 + NSBezierPath( 55 + roundedRect: bounds, 56 + xRadius: AquaDrawerFrameView.cornerRadius, 57 + yRadius: AquaDrawerFrameView.cornerRadius 58 + ).cgPath 59 + } 60 + }
+50
Sources/AquaKit/Drawers/AquaDrawerChromeViewController.swift
··· 1 + import AppKit 2 + 3 + class AquaDrawerChromeViewController: NSViewController { 4 + let chromeView = AquaDrawerChromeView() 5 + let frameView = AquaDrawerFrameView() 6 + 7 + var drawerEdge: NSRectEdge? { 8 + get { chromeView.drawerEdge } 9 + set { chromeView.drawerEdge = newValue } 10 + } 11 + 12 + var contentViewController: NSViewController? { 13 + didSet { 14 + if let oldValue { 15 + oldValue.view.removeFromSuperview() 16 + oldValue.removeFromParent() 17 + } 18 + if let contentViewController { 19 + addChild(contentViewController) 20 + let contentView = contentViewController.view 21 + contentView.translatesAutoresizingMaskIntoConstraints = false 22 + view.addSubview(contentView, positioned: .below, relativeTo: frameView) 23 + NSLayoutConstraint.activate([ 24 + contentView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor), 25 + contentView.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor), 26 + contentView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 27 + contentView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) 28 + ]) 29 + } 30 + } 31 + } 32 + 33 + override func loadView() { 34 + view = chromeView 35 + } 36 + 37 + override func viewDidLoad() { 38 + super.viewDidLoad() 39 + 40 + frameView.translatesAutoresizingMaskIntoConstraints = false 41 + view.addSubview(frameView) 42 + 43 + NSLayoutConstraint.activate([ 44 + frameView.leftAnchor.constraint(equalTo: view.leftAnchor), 45 + frameView.rightAnchor.constraint(equalTo: view.rightAnchor), 46 + frameView.topAnchor.constraint(equalTo: view.topAnchor), 47 + frameView.bottomAnchor.constraint(equalTo: view.bottomAnchor) 48 + ]) 49 + } 50 + }
+163
Sources/AquaKit/Drawers/AquaDrawerFrameView.swift
··· 1 + import AppKit 2 + 3 + final class AquaDrawerFrameView: NSView { 4 + private var dragStart: NSPoint = .zero 5 + private var activePosition: NSCursor.FrameResizePosition? 6 + 7 + static let borderThickness: CGFloat = 6 8 + static var cornerRadius: CGFloat { borderThickness * 2 } 9 + private static var activeAreaThickness: CGFloat { Self.borderThickness * 2 } 10 + 11 + override var acceptsFirstResponder: Bool { false } 12 + 13 + override init(frame frameRect: NSRect) { 14 + super.init(frame: frameRect) 15 + 16 + addTrackingArea( 17 + NSTrackingArea( 18 + rect: bounds, 19 + options: [.activeAlways, .mouseMoved, .inVisibleRect], 20 + owner: self, 21 + userInfo: nil 22 + ) 23 + ) 24 + } 25 + 26 + var strokePath: NSBezierPath { 27 + let inset = Self.borderThickness / 2 28 + let strokeRadius = Self.cornerRadius - inset 29 + 30 + let strokeRect = bounds.insetBy(dx: inset, dy: inset) 31 + 32 + let path = NSBezierPath( 33 + roundedRect: strokeRect, 34 + xRadius: strokeRadius, 35 + yRadius: strokeRadius 36 + ) 37 + 38 + path.lineWidth = Self.borderThickness 39 + return path 40 + } 41 + 42 + override func draw(_ dirtyRect: NSRect) { 43 + super.draw(dirtyRect) 44 + 45 + NSGraphicsContext.saveGraphicsState() 46 + let shadow = NSShadow() 47 + shadow.shadowColor = .black.withAlphaComponent(0.7) 48 + shadow.shadowBlurRadius = 5.0 49 + shadow.set() 50 + 51 + NSColor.controlColor.setStroke() 52 + strokePath.stroke() 53 + NSGraphicsContext.restoreGraphicsState() 54 + } 55 + 56 + required init?(coder: NSCoder) { fatalError() } 57 + 58 + override func hitTest(_ point: NSPoint) -> NSView? { 59 + if frame.insetBy(dx: Self.borderThickness, dy: Self.borderThickness).contains(point) { 60 + return nil 61 + } 62 + return self 63 + } 64 + 65 + override func mouseMoved(with event: NSEvent) { 66 + let loc = convert(event.locationInWindow, from: nil) 67 + activePosition = frameResizePosition(at: loc) 68 + updateCursor() 69 + } 70 + 71 + override func mouseDown(with event: NSEvent) { 72 + dragStart = convert(event.locationInWindow, from: nil) 73 + 74 + if let window { 75 + NotificationCenter.default.post( 76 + name: NSWindow.willStartLiveResizeNotification, 77 + object: window 78 + ) 79 + } 80 + } 81 + 82 + override func mouseDragged(with event: NSEvent) { 83 + guard let window, let activePosition else { return } 84 + 85 + let current = convert(event.locationInWindow, from: nil) 86 + let delta = CGPoint(x: current.x - dragStart.x, y: current.y - dragStart.y) 87 + var frame = window.frame 88 + 89 + if activePosition.containsLeft { 90 + frame.size.width -= delta.x 91 + frame.origin.x += delta.x 92 + } else if activePosition.containsRight { 93 + frame.size.width += delta.x 94 + } 95 + 96 + if activePosition.containsBottom { 97 + frame.size.height -= delta.y 98 + frame.origin.y += delta.y 99 + } else if activePosition.containsTop { 100 + frame.size.height += delta.y 101 + } 102 + 103 + if let constrainedSize = window.delegate?.windowWillResize?(window, to: frame.size) { 104 + let widthDelta = frame.size.width - constrainedSize.width 105 + let heightDelta = frame.size.height - constrainedSize.height 106 + 107 + frame.size = constrainedSize 108 + 109 + if activePosition.containsLeft { frame.origin.x += widthDelta } 110 + if activePosition.containsBottom { frame.origin.y += heightDelta } 111 + } 112 + 113 + window.setFrame(frame, display: true) 114 + } 115 + 116 + override func mouseUp(with event: NSEvent) { 117 + if let window { 118 + NotificationCenter.default.post( 119 + name: NSWindow.didEndLiveResizeNotification, 120 + object: window 121 + ) 122 + } 123 + } 124 + 125 + private func frameResizePosition(at point: NSPoint) -> NSCursor.FrameResizePosition? { 126 + guard let contentView = superview else { return nil } 127 + 128 + let left = point.x <= Self.activeAreaThickness 129 + let right = point.x >= contentView.bounds.width - Self.activeAreaThickness 130 + let bottom = point.y <= Self.activeAreaThickness 131 + let top = point.y >= contentView.bounds.height - Self.activeAreaThickness 132 + 133 + if top, left { return .topLeft } 134 + if top, right { return .topRight } 135 + if bottom, left { return .bottomLeft } 136 + if bottom, right { return .bottomRight } 137 + if top { return .top } 138 + if bottom { return .bottom } 139 + if left { return .left } 140 + if right { return .right } 141 + 142 + return nil 143 + } 144 + 145 + var cursor: NSCursor { 146 + if let activePosition { 147 + NSCursor.frameResize(position: activePosition, directions: .all) 148 + } else { 149 + NSCursor.arrow 150 + } 151 + } 152 + 153 + private func updateCursor() { 154 + cursor.set() 155 + } 156 + } 157 + 158 + extension NSCursor.FrameResizePosition { 159 + fileprivate var containsLeft: Bool { self == .left || self == .topLeft || self == .bottomLeft } 160 + fileprivate var containsRight: Bool { self == .right || self == .topRight || self == .bottomRight } 161 + fileprivate var containsTop: Bool { self == .top || self == .topLeft || self == .topRight } 162 + fileprivate var containsBottom: Bool { self == .bottom || self == .bottomLeft || self == .bottomRight } 163 + }
+5
Sources/AquaKit/Drawers/AquaDrawerPanel.swift
··· 1 + import AppKit 2 + 3 + open class AquaDrawerPanel: NSPanel { 4 + open override var canBecomeKey: Bool { true } 5 + }