feat: animate compact window transitions
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user