Skip to content

Commit

Permalink
Custom popover (#179)
Browse files Browse the repository at this point in the history
  • Loading branch information
pakerwreah authored Apr 20, 2024
1 parent 8655c29 commit 700841f
Show file tree
Hide file tree
Showing 9 changed files with 316 additions and 162 deletions.
12 changes: 6 additions & 6 deletions Calendr.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
3430675E25F6BC15000D4003 /* HistoricalSchedulerTimeConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3430675D25F6BC15000D4003 /* HistoricalSchedulerTimeConverter.swift */; };
3430676325F6C130000D4003 /* TrackedHistoricalScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3430676225F6C130000D4003 /* TrackedHistoricalScheduler.swift */; };
3430ED03259634E00045DA53 /* NSStackView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3430ED02259634E00045DA53 /* NSStackView+Rx.swift */; };
3433E87A294AA3D50042533A /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3433E879294AA3D50042533A /* Notification.swift */; };
34427692269B5760004CFE1C /* MockMainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34427691269B5760004CFE1C /* MockMainViewController.swift */; };
3442769A269B5CA4004CFE1C /* UITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34427699269B5CA4004CFE1C /* UITestCase.swift */; };
3449402E25C348B20020E664 /* GeneralSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3449402D25C348B20020E664 /* GeneralSettingsViewController.swift */; };
Expand Down Expand Up @@ -78,6 +77,7 @@
347D0FF825954975002451EC /* CalendarCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347D0FF725954975002451EC /* CalendarCellViewModel.swift */; };
347D0FFE259566B5002451EC /* CalendarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347D0FFD259566B5002451EC /* CalendarViewModel.swift */; };
347E6D3E25AA2811009A6716 /* CalendarViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347E6D3D25AA2811009A6716 /* CalendarViewModelTests.swift */; };
347FF78F2BCB6A5500E5B63B /* Popover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347FF78E2BCB6A5500E5B63B /* Popover.swift */; };
3487A43825E706F800FCC7D7 /* NextEventViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3487A43725E706F800FCC7D7 /* NextEventViewModel.swift */; };
3487A43C25E70F5B00FCC7D7 /* NextEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3487A43B25E70F5B00FCC7D7 /* NextEventView.swift */; };
348B8D0425B2925100E518FE /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348B8D0325B2925100E518FE /* SettingsViewModel.swift */; };
Expand Down Expand Up @@ -201,7 +201,6 @@
3430675D25F6BC15000D4003 /* HistoricalSchedulerTimeConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoricalSchedulerTimeConverter.swift; sourceTree = "<group>"; };
3430676225F6C130000D4003 /* TrackedHistoricalScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackedHistoricalScheduler.swift; sourceTree = "<group>"; };
3430ED02259634E00045DA53 /* NSStackView+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSStackView+Rx.swift"; sourceTree = "<group>"; };
3433E879294AA3D50042533A /* Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = "<group>"; };
343B74C826D168A80081A440 /* UnitTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = UnitTests.xctestplan; path = CalendrTests/UnitTests.xctestplan; sourceTree = SOURCE_ROOT; };
343B74C926D169E80081A440 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = "<group>"; };
34427691269B5760004CFE1C /* MockMainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMainViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -256,6 +255,7 @@
347D0FF725954975002451EC /* CalendarCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarCellViewModel.swift; sourceTree = "<group>"; };
347D0FFD259566B5002451EC /* CalendarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarViewModel.swift; sourceTree = "<group>"; };
347E6D3D25AA2811009A6716 /* CalendarViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarViewModelTests.swift; sourceTree = "<group>"; };
347FF78E2BCB6A5500E5B63B /* Popover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Popover.swift; sourceTree = "<group>"; };
3487A43725E706F800FCC7D7 /* NextEventViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextEventViewModel.swift; sourceTree = "<group>"; };
3487A43B25E70F5B00FCC7D7 /* NextEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextEventView.swift; sourceTree = "<group>"; };
348B8D0325B2925100E518FE /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -555,6 +555,7 @@
34D25E49292F9E2100557E70 /* ImageButton.swift */,
34F128D025968044007DF31C /* Label.swift */,
348E704A25C61B8D00B3B160 /* Radio.swift */,
347FF78E2BCB6A5500E5B63B /* Popover.swift */,
);
path = Components;
sourceTree = "<group>";
Expand All @@ -567,7 +568,6 @@
34924CED259FD064009C3450 /* DateFormatter.swift */,
34651F5325E22A3900518C5A /* DateIntervalFormatter.swift */,
34EDE71A2AC46515007C5854 /* Error.swift */,
3433E879294AA3D50042533A /* Notification.swift */,
34E60D6226A0EA32004DA082 /* NSAccessibilityProtocol.swift */,
34E60D6026A0D6D6004DA082 /* NSAccessibilityProtocol+Rx.swift */,
34B5A09625B0F8A500F7F7ED /* NSButton.swift */,
Expand Down Expand Up @@ -1015,11 +1015,11 @@
346C5A4A2BC2D6F60007106C /* NextEventPreview.swift in Sources */,
34B2C59E279FFF0600B52BF3 /* NSGestureRecognizer+Rx.swift in Sources */,
34E004A725B61D5200241419 /* StatusItemViewModel.swift in Sources */,
3433E87A294AA3D50042533A /* Notification.swift in Sources */,
34D19FC0262299D300B2732C /* NSEdgeInsets.swift in Sources */,
34702168259E761100827AE7 /* Calendar.swift in Sources */,
34F201332693F562006CE2FF /* MockEventListSettings.swift in Sources */,
34C4599C25DEFFDA00561C29 /* BuildConfig.m in Sources */,
347FF78F2BCB6A5500E5B63B /* Popover.swift in Sources */,
34651F1525E1BB8400518C5A /* Strings.generated.swift in Sources */,
34651F5425E22A3900518C5A /* DateIntervalFormatter.swift in Sources */,
3421DA162693EDEB00056837 /* MockCalendarSettings.swift in Sources */,
Expand Down Expand Up @@ -1285,7 +1285,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.10.11;
MARKETING_VERSION = 1.11.0;
PRODUCT_BUNDLE_IDENTIFIER = br.paker.Calendr;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Calendr/Config/Calendr-Bridging-Header.h";
Expand All @@ -1311,7 +1311,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.10.11;
MARKETING_VERSION = 1.11.0;
PRODUCT_BUNDLE_IDENTIFIER = br.paker.Calendr;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Calendr/Config/Calendr-Bridging-Header.h";
Expand Down
12 changes: 11 additions & 1 deletion Calendr/Calendar/CalendarCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ class CalendarCellView: NSView {
borderLayer.frame = bounds
}

override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
return true
}

// Prevent propagating event to superview
override func mouseExited(with event: NSEvent) { }

Expand All @@ -172,7 +176,13 @@ class CalendarCellView: NSView {
removeTrackingArea(trackingArea)
}

addTrackingRect(bounds, owner: self, userData: nil, assumeInside: false)
let trackingArea = NSTrackingArea(
rect: bounds,
options: [.mouseEnteredAndExited, .activeInKeyWindow],
owner: self
)

addTrackingArea(trackingArea)
}

required init?(coder: NSCoder) {
Expand Down
241 changes: 241 additions & 0 deletions Calendr/Components/Popover.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
//
// Popover.swift
// Calendr
//
// Created by Paker on 14/04/24.
//

import AppKit

var popovers: [Popover] = []

@objc protocol PopoverDelegate {
@objc optional func popoverWillShow()
@objc optional func popoverDidShow()
@objc optional func popoverWillClose()
@objc optional func popoverDidClose()
}

class Popover: NSObject, PopoverWindowDelegate {

private var window: PopoverWindow?
private var isClosing = false

var contentViewController: NSViewController?
var delegate: PopoverDelegate?
var behavior: NSPopover.Behavior = .transient

func show(from view: NSView) {
present(from: view, edge: .maxY, spacing: 0, single: true)
}

func push(from view: NSView) {
present(from: view, edge: .minX, spacing: 8, single: false)
}

func present(from view: NSView, edge: NSRectEdge, spacing: CGFloat, single: Bool) {

if let window {
return window.move(to: view, edge: edge, spacing: spacing)
}

if single {
closeAll()
}

guard let contentViewController else { return }

delegate?.popoverWillShow?()

let contentView = contentViewController.view.forAutoLayout()
let container = NSVisualEffectView()
container.maskImage = .mask(withCornerRadius: 12)
container.state = .active
container.addSubview(contentView)
container.edges(to: contentView)

let window = PopoverWindow()
window.contentView = container
window.isOpaque = false
window.backgroundColor = .clear
window.styleMask = .borderless
window.level = .floating
window.isReleasedWhenClosed = false
window._delegate = self
window.move(to: view, edge: edge, spacing: spacing)
window.activate()

delegate?.popoverDidShow?()

self.window = window

popovers.append(self)
}

private var isMouseInside: Bool {
guard let window else { return false }
return NSMouseInRect(NSEvent.mouseLocation, window.frame, false)
}

private func closeAll() {
for popover in popovers {
popover.window?.performClose(nil)
}
}

func windowDidResignKey(_ notification: Notification) {

guard !isClosing else {
return
}

guard NSApp.isActive else {
return closeAll()
}

guard !isMouseInside else {
return
}

window?.performClose(nil)
}

func windowShouldClose(_ sender: NSWindow) -> Bool {
behavior == .transient
}

func windowWillClose(_ notification: Notification) {
isClosing = true
let wasTop = popovers.last == self

delegate?.popoverWillClose?()
popovers.removeAll { $0 == self }
delegate?.popoverDidClose?()

guard wasTop, let newTop = popovers.last?.window else {
return
}

newTop.activate()
}
}

@objc protocol PopoverWindowDelegate: NSWindowDelegate {
@objc optional func windowDidClose()
}

private class PopoverWindow: NSWindow {

override weak var delegate: NSWindowDelegate? {
set {
assert(newValue == nil)
_delegate = nil
}
get { _delegate }
}

weak var _delegate: PopoverWindowDelegate? {
didSet {
super.delegate = _delegate
}
}

override var canBecomeKey: Bool {
return true
}

override var acceptsFirstResponder: Bool {
return true
}

override func cancelOperation(_ sender: Any?) {
performClose(nil)
}

override func performClose(_ sender: Any?) {
guard delegate?.windowShouldClose?(self) != false else {
return
}
close()
}

override func close() {
super.close()
_delegate?.windowDidClose?()
}

func activate() {
makeKeyAndOrderFront(nil)
NSRunningApplication.current.activate()
NSApp.activate(ignoringOtherApps: true)
makeFirstResponder(nil)
}

func move(to anchor: NSView, edge: NSRectEdge, spacing: CGFloat) {

if let origin = relativePosition(to: anchor, edge: edge, spacing: spacing) {
setFrameOrigin(origin)
}
}

private func relativePosition(to view: NSView, edge: NSRectEdge, spacing: CGFloat) -> NSPoint? {

guard let viewWindow = view.window, let screen = NSScreen.main else {
return nil
}

struct Limits {
let minX: CGFloat
let maxX: CGFloat
let minY: CGFloat
let maxY: CGFloat
}

let limit = Limits(
minX: screen.visibleFrame.minX,
maxX: screen.visibleFrame.maxX - frame.width,
minY: screen.visibleFrame.minY,
maxY: screen.visibleFrame.maxY - frame.height
)

// screen coordinates are inverted
let viewFrame = viewWindow.convertToScreen(view.convert(view.bounds, to: nil))

var position: NSPoint?

let centerX = min(limit.maxX, max(limit.minX, viewFrame.midX - frame.width / 2))
let centerY = min(limit.maxY, max(limit.minY, viewFrame.midY - frame.height / 2))

switch edge {
case .minX:
position = NSPoint(x: max(limit.minX, viewFrame.minX - frame.width - spacing), y: centerY)
case .maxX:
position = NSPoint(x: min(limit.maxX, viewFrame.maxX + spacing), y: centerY)
case .minY:
position = NSPoint(x: centerX, y: min(limit.maxY, viewFrame.maxY + spacing))
case .maxY:
position = NSPoint(x: centerX, y: max(limit.minY, viewFrame.minY - frame.height - spacing))
default:
break
}

return position
}
}

private extension NSImage {

static func mask(withCornerRadius radius: CGFloat) -> NSImage {

let image = NSImage(size: NSSize(width: radius * 2, height: radius * 2), flipped: false) {
NSBezierPath(roundedRect: $0, xRadius: radius, yRadius: radius).fill()
NSColor.black.set()
return true
}

image.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius)
image.resizingMode = .stretch

return image
}
}
Loading

0 comments on commit 700841f

Please sign in to comment.