feat: animate compact window transitions

This commit is contained in:
2026-10-12 09:58:33 +02:00
parent e54cb2b549
commit f495c8b719
4 changed files with 401 additions and 111 deletions
+75 -19
View File
@@ -1,10 +1,12 @@
import AppKit
import QuartzCore
@MainActor
final class CompactWindowController: NSObject {
var onChange: (() -> Void)?
private(set) var isCompact = false
private(set) var isTransitioning = false
static let transitionDuration: TimeInterval = 0.25
var snappingEnabled: Bool {
didSet {
@@ -54,9 +56,14 @@ final class CompactWindowController: NSObject {
private var originatedFullScreen = false
private var videoAspect = 16.0 / 9.0
private var fittedAspect: Double?
private let reduceMotion: () -> Bool
private var frameAnimating = false
private var frameAnimationGeneration: UInt64 = 0
init(snappingEnabled: Bool = true) {
init(snappingEnabled: Bool = true,
reduceMotion: @escaping () -> Bool = { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }) {
self.snappingEnabled = snappingEnabled
self.reduceMotion = reduceMotion
super.init()
}
@@ -64,6 +71,7 @@ final class CompactWindowController: NSObject {
func attach(_ window: NSWindow) {
guard window !== self.window else { return }
stopFrameAnimation()
if let oldWindow = self.window, fullScreenTransition == nil,
!oldWindow.styleMask.contains(.fullScreen) {
restoreSnapshot(to: oldWindow)
@@ -105,11 +113,12 @@ final class CompactWindowController: NSObject {
reconcile()
}
func restore(resumeFullScreen: Bool = true) {
func restore(resumeFullScreen: Bool = true, animated: Bool = true) {
guard sessionActive else { return }
wantsCompact = false
wantsFullScreen = resumeFullScreen && originatedFullScreen
reconcile()
if !animated { stopFrameAnimation() }
reconcile(animated: animated)
}
func updateAspect(_ aspect: Double) {
@@ -137,6 +146,7 @@ final class CompactWindowController: NSObject {
fullScreenTransition = nil
reconcile()
case NSWindow.willCloseNotification:
stopFrameAnimation()
restoreDelegate()
NotificationCenter.default.removeObserver(self)
window = nil
@@ -204,8 +214,8 @@ final class CompactWindowController: NSObject {
}
// A toggle changes the destination, never reverses an in-flight native animation.
private func reconcile() {
guard let window, fullScreenTransition == nil else { return }
private func reconcile(animated: Bool = true) {
guard let window, fullScreenTransition == nil, !frameAnimating else { return }
guard sessionActive else {
setState(compact: isCompact, transitioning: false)
return
@@ -214,7 +224,7 @@ final class CompactWindowController: NSObject {
if window.styleMask.contains(.fullScreen) {
toggleNativeFullScreen(window, transition: .exiting)
} else if !isCompact {
enterCompact(window)
enterCompact(window, animated: animated)
} else {
if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) }
setState(compact: true, transitioning: false)
@@ -229,10 +239,17 @@ final class CompactWindowController: NSObject {
}
return
}
if snapshot != nil {
setState(compact: isCompact, transitioning: true)
restoreSnapshot(to: window)
setState(compact: false, transitioning: wantsFullScreen)
if let snapshot {
setState(compact: false, transitioning: animated)
window.aspectRatio = .zero
window.minSize = .zero
window.contentMinSize = .zero
animateFrame(snapshot.frame, of: window, animated: animated) { [weak self, weak window] in
guard let self, let window else { return }
self.restoreSnapshot(to: window)
self.reconcile()
}
return
}
if wantsFullScreen {
toggleNativeFullScreen(window, transition: .entering)
@@ -247,10 +264,10 @@ final class CompactWindowController: NSObject {
window.toggleFullScreen(nil)
}
private func enterCompact(_ window: NSWindow) {
setState(compact: false, transitioning: true)
private func enterCompact(_ window: NSWindow, animated: Bool) {
// After didExitFullScreen, this is the normal frame, not the fullscreen frame.
snapshot = Snapshot(window)
setState(compact: true, transitioning: animated)
window.aspectRatio = .zero
window.minSize = .zero
window.contentMinSize = .zero
@@ -267,8 +284,7 @@ final class CompactWindowController: NSObject {
window.level = .floating
window.hidesOnDeactivate = false
window.isMovableByWindowBackground = true
fitCompactWindow(window, initial: true)
setState(compact: true, transitioning: false)
fitCompactWindow(window, initial: true, animated: animated)
}
private func restoreSnapshot(to window: NSWindow) {
@@ -298,6 +314,42 @@ final class CompactWindowController: NSObject {
setState(compact: false, transitioning: false)
}
private func animateFrame(_ frame: NSRect, of window: NSWindow, animated: Bool,
completion: @escaping @MainActor () -> Void) {
guard animated, !reduceMotion(), window.frame != frame else {
window.setFrame(frame, display: true)
completion()
return
}
frameAnimationGeneration &+= 1
let generation = frameAnimationGeneration
frameAnimating = true
NSAnimationContext.runAnimationGroup { context in
context.duration = Self.transitionDuration
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrame(frame, display: true)
} completionHandler: { [weak self, weak window] in
MainActor.assumeIsolated {
guard let self, let window, self.window === window, self.frameAnimating,
self.frameAnimationGeneration == generation else { return }
self.frameAnimating = false
completion()
}
}
}
private func stopFrameAnimation() {
guard frameAnimating else { return }
frameAnimating = false
frameAnimationGeneration &+= 1
guard let window else { return }
// A zero-duration animator update cancels the native in-flight frame animation.
NSAnimationContext.runAnimationGroup { context in
context.duration = 0
window.animator().setFrame(window.frame, display: true)
}
}
private func setState(compact: Bool, transitioning: Bool) {
guard isCompact != compact || isTransitioning != transitioning else { return }
isCompact = compact
@@ -309,9 +361,10 @@ final class CompactWindowController: NSObject {
(window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame
}
private func fitCompactWindow(_ window: NSWindow, initial: Bool) {
private func fitCompactWindow(_ window: NSWindow, initial: Bool, animated: Bool = false) {
let visibleFrame = visibleFrame(for: window)
let usable = Self.usableFrame(visibleFrame)
let aspect = videoAspect
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame)
if !initial {
frame.size = Self.compactSize(aspect: videoAspect, width: window.frame.width, available: usable.size)
@@ -319,11 +372,14 @@ final class CompactWindowController: NSObject {
frame.origin.y = min(max(window.frame.minY, usable.minY), usable.maxY - frame.height)
}
window.aspectRatio = .zero
window.setFrame(frame, display: true)
if Self.canFitAspect(videoAspect, available: usable.size) {
window.aspectRatio = NSSize(width: videoAspect, height: 1)
animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in
guard let self, let window else { return }
if Self.canFitAspect(aspect, available: usable.size) {
window.aspectRatio = NSSize(width: aspect, height: 1)
}
self.fittedAspect = aspect
if initial { self.reconcile() }
}
fittedAspect = videoAspect
}
nonisolated static func initialFrame(aspect: Double, visibleFrame: NSRect) -> NSRect {