Skip to content

Commit 028f8ad

Browse files
BohdanBohdan
authored andcommitted
Fixed animation issues when do fast switching animation style
1 parent 839c128 commit 028f8ad

20 files changed

+369
-192
lines changed

LanguageFlag/Animations/Core/AnimationCoordinator.swift

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ final class AnimationCoordinator {
88
private let preferences = UserPreferences.shared
99
private var isShowing = false
1010

11+
/// Generation counter that increments on each animateIn, used to invalidate
12+
/// stale animateOut completion blocks that would otherwise hide the window
13+
/// after a new animation has already started.
14+
private var generation: Int = 0
15+
1116
// Animation styles that need frame setup before animation
1217
private let stylesNeedingFrameSetup: Set<AnimationStyle> = [
1318
.slide, .scale, .pixelate, .bounce, .flip, .swing, .elastic,
@@ -32,14 +37,18 @@ final class AnimationCoordinator {
3237
window: NSWindow,
3338
style: AnimationStyle,
3439
duration: TimeInterval,
35-
screenRect: CGRect
40+
screenRect: CGRect,
41+
force: Bool = false
3642
) {
3743
// If the window is already showing and reset is disabled,
38-
// let the current animation continue.
44+
// let the current animation continue unless forced (e.g., style preview).
3945
// The flag image/title will still update (handled by LanguageViewController).
40-
if isShowing, !preferences.resetAnimationOnChange { return }
46+
if isShowing, !preferences.resetAnimationOnChange, !force { return }
4147
isShowing = true
4248

49+
// Increment generation so any in-flight animateOut completions become no-ops
50+
generation += 1
51+
4352
// Set opacity before animation if window is being shown for first time
4453
if window.alphaValue == 0 {
4554
window.alphaValue = CGFloat(preferences.opacity)
@@ -75,8 +84,17 @@ final class AnimationCoordinator {
7584
) {
7685
isShowing = false
7786

87+
// Capture the current generation so the completion block can verify
88+
// that no new animateIn has started since this animateOut was dispatched.
89+
let capturedGeneration = generation
90+
7891
let frameResetCompletion: (() -> Void)? = stylesNeedingFrameReset.contains(style) ? { [weak self] in
7992
guard let self = self else { return }
93+
94+
// If a new animateIn has started since this animateOut was dispatched,
95+
// skip the cleanup to avoid hiding the newly-shown window.
96+
guard self.generation == capturedGeneration else { return }
97+
8098
let targetFrame = self.positionCalculator.calculateWindowFrame(
8199
in: screenRect,
82100
position: self.preferences.displayPosition,

LanguageFlag/Animations/Core/BaseWindowAnimation.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class BaseWindowAnimation {
1212
/// - Returns: The prepared CALayer, or nil if preparation fails
1313
func prepareLayer(from window: NSWindow) -> CALayer? {
1414
guard let contentView = window.contentView else { return nil }
15+
1516
contentView.wantsLayer = true
1617
return contentView.layer
1718
}
@@ -79,6 +80,7 @@ class BaseWindowAnimation {
7980
animation.toValue = to
8081
animation.duration = duration
8182
animation.timingFunction = timing
83+
8284
return animation
8385
}
8486

@@ -97,10 +99,29 @@ class BaseWindowAnimation {
9799
timing: CAMediaTimingFunction = CAMediaTimingFunction(name: .easeOut)
98100
) {
99101
contentView.alphaValue = from
102+
100103
NSAnimationContext.runAnimationGroup { context in
101104
context.duration = duration
102105
context.timingFunction = timing
103106
contentView.animator().alphaValue = to
104107
}
105108
}
106109
}
110+
111+
/// Delegate helper to handle CAAnimation completion reliably across animation overrides
112+
class AnimationCompletionDelegate: NSObject, CAAnimationDelegate {
113+
114+
// MARK: - Properties
115+
private let completion: (Bool) -> Void
116+
117+
// MARK: - Initialization
118+
init(completion: @escaping (Bool) -> Void) {
119+
self.completion = completion
120+
super.init()
121+
}
122+
123+
// MARK: - CAAnimationDelegate
124+
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
125+
completion(flag)
126+
}
127+
}

LanguageFlag/Animations/FilterBased/BlurAnimation.swift

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,24 @@ class BlurAnimation: BaseWindowAnimation, WindowAnimation {
1818
let blurFilter = FilterBuilder.gaussianBlur(radius: 0.0)
1919
applyFilters([blurFilter], to: layer)
2020

21-
CATransaction.begin()
22-
CATransaction.setAnimationDuration(duration)
23-
CATransaction.setAnimationTimingFunction(AnimationTiming.easeOut)
24-
CATransaction.setCompletionBlock {
25-
self.clearFilters(from: layer)
26-
completion?()
27-
}
28-
29-
let animation = createAnimation(
21+
let blurAnim = createAnimation(
3022
keyPath: "filters.CIGaussianBlur.inputRadius",
3123
from: 20.0,
3224
to: 0.0,
3325
duration: duration
3426
)
35-
layer.add(animation, forKey: "blur")
36-
27+
blurAnim.fillMode = .forwards
28+
blurAnim.isRemovedOnCompletion = false
29+
blurAnim.delegate = AnimationCompletionDelegate { [weak self] finished in
30+
guard let self, finished else { return }
31+
self.clearFilters(from: layer)
32+
completion?()
33+
}
34+
35+
CATransaction.begin()
36+
CATransaction.setAnimationDuration(duration)
37+
CATransaction.setAnimationTimingFunction(AnimationTiming.easeOut)
38+
layer.add(blurAnim, forKey: "blur")
3739
CATransaction.commit()
3840

3941
animateAlpha(contentView: contentView, from: 0.0, to: 1.0, duration: duration)
@@ -51,23 +53,25 @@ class BlurAnimation: BaseWindowAnimation, WindowAnimation {
5153
let blurFilter = FilterBuilder.gaussianBlur(radius: 20.0)
5254
applyFilters([blurFilter], to: layer)
5355

54-
CATransaction.begin()
55-
CATransaction.setAnimationDuration(duration)
56-
CATransaction.setAnimationTimingFunction(AnimationTiming.easeIn)
57-
CATransaction.setCompletionBlock {
58-
self.clearFilters(from: layer)
59-
completion?()
60-
}
61-
62-
let animation = createAnimation(
56+
let blurAnim = createAnimation(
6357
keyPath: "filters.CIGaussianBlur.inputRadius",
6458
from: 0.0,
6559
to: 20.0,
6660
duration: duration,
6761
timing: AnimationTiming.easeIn
6862
)
69-
layer.add(animation, forKey: "blur")
70-
63+
blurAnim.fillMode = .forwards
64+
blurAnim.isRemovedOnCompletion = false
65+
blurAnim.delegate = AnimationCompletionDelegate { [weak self] finished in
66+
guard let self, finished else { return }
67+
self.clearFilters(from: layer)
68+
completion?()
69+
}
70+
71+
CATransaction.begin()
72+
CATransaction.setAnimationDuration(duration)
73+
CATransaction.setAnimationTimingFunction(AnimationTiming.easeIn)
74+
layer.add(blurAnim, forKey: "blur")
7175
CATransaction.commit()
7276

7377
animateAlpha(

LanguageFlag/Animations/FilterBased/DigitalMaterializeAnimation.swift

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,17 @@ class DigitalMaterializeAnimation: BaseWindowAnimation, WindowAnimation {
3232
CATransaction.begin()
3333
CATransaction.setAnimationDuration(duration)
3434
CATransaction.setAnimationTimingFunction(AnimationTiming.linear)
35-
CATransaction.setCompletionBlock {
36-
layer.mask = nil
37-
self.clearFilters(from: layer)
38-
completion?()
39-
}
4035

4136
// Animate Mask from bottom to top (build up)
4237
let maskAnim = createAnimation(keyPath: "locations", from: [0.95, 1.0, 1.0], to: [0.0, 0.0, 0.05], duration: duration)
4338
maskAnim.fillMode = .forwards
4439
maskAnim.isRemovedOnCompletion = false
40+
maskAnim.delegate = AnimationCompletionDelegate { [weak self] finished in
41+
guard let self, finished else { return }
42+
layer.mask = nil
43+
self.clearFilters(from: layer)
44+
completion?()
45+
}
4546
maskLayer.add(maskAnim, forKey: "scanline")
4647
maskLayer.locations = [0.0, 0.0, 0.05] // Final state (Model matches End for Mask, but safe to persist)
4748

@@ -88,16 +89,17 @@ class DigitalMaterializeAnimation: BaseWindowAnimation, WindowAnimation {
8889
CATransaction.begin()
8990
CATransaction.setAnimationDuration(duration)
9091
CATransaction.setAnimationTimingFunction(AnimationTiming.linear)
91-
CATransaction.setCompletionBlock {
92-
layer.mask = nil
93-
self.clearFilters(from: layer)
94-
completion?()
95-
}
9692

9793
// Animate Mask from top to bottom (dematerialize)
9894
let maskAnim = createAnimation(keyPath: "locations", from: [0.0, 0.0, 0.05], to: [0.95, 1.0, 1.0], duration: duration)
9995
maskAnim.fillMode = .forwards
10096
maskAnim.isRemovedOnCompletion = false
97+
maskAnim.delegate = AnimationCompletionDelegate { [weak self] finished in
98+
guard let self, finished else { return }
99+
layer.mask = nil
100+
self.clearFilters(from: layer)
101+
completion?()
102+
}
101103
maskLayer.add(maskAnim, forKey: "scanline")
102104
maskLayer.locations = [0.95, 1.0, 1.0] // Final state
103105

LanguageFlag/Animations/FilterBased/EnergyPortalAnimation.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,16 @@ class EnergyPortalAnimation: BaseWindowAnimation, WindowAnimation {
2929
CATransaction.begin()
3030
CATransaction.setAnimationDuration(duration)
3131
CATransaction.setAnimationTimingFunction(AnimationTiming.easeOut)
32-
CATransaction.setCompletionBlock {
33-
self.clearFilters(from: layer)
34-
completion?()
35-
}
3632

3733
// Animate twirl (3pi -> 0)
3834
let twirlAnim = createAnimation(keyPath: "filters.CITwirlDistortion.inputAngle", from: CGFloat.pi * 3, to: 0.0, duration: duration)
3935
twirlAnim.fillMode = .forwards
4036
twirlAnim.isRemovedOnCompletion = false
37+
twirlAnim.delegate = AnimationCompletionDelegate { [weak self] finished in
38+
guard let self, finished else { return }
39+
self.clearFilters(from: layer)
40+
completion?()
41+
}
4142
layer.add(twirlAnim, forKey: "twirl")
4243

4344
// Animate zoom (30 -> 0)
@@ -85,15 +86,16 @@ class EnergyPortalAnimation: BaseWindowAnimation, WindowAnimation {
8586
CATransaction.begin()
8687
CATransaction.setAnimationDuration(duration)
8788
CATransaction.setAnimationTimingFunction(AnimationTiming.easeIn)
88-
CATransaction.setCompletionBlock {
89-
self.clearFilters(from: layer)
90-
completion?()
91-
}
9289

9390
// Animate twirl (0 -> 3pi)
9491
let twirlAnim = createAnimation(keyPath: "filters.CITwirlDistortion.inputAngle", from: 0.0, to: CGFloat.pi * 3, duration: duration)
9592
twirlAnim.fillMode = .forwards
9693
twirlAnim.isRemovedOnCompletion = false
94+
twirlAnim.delegate = AnimationCompletionDelegate { [weak self] finished in
95+
guard let self, finished else { return }
96+
self.clearFilters(from: layer)
97+
completion?()
98+
}
9799
layer.add(twirlAnim, forKey: "twirl")
98100

99101
// Animate zoom (0 -> 30)

LanguageFlag/Animations/FilterBased/HologramAnimation.swift

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,16 @@ class HologramAnimation: BaseWindowAnimation, WindowAnimation {
2626
CATransaction.begin()
2727
CATransaction.setAnimationDuration(duration)
2828
CATransaction.setAnimationTimingFunction(AnimationTiming.easeOut)
29-
CATransaction.setCompletionBlock {
30-
// Legacy: Explicitly set final values before clearing?
31-
// Since we conform to Strategy, clearing is standard.
32-
// But we use fillMode.forwards to hold until clear.
33-
self.clearFilters(from: layer)
34-
completion?()
35-
}
3629

3730
// Animate pixelation (8.0 -> 1.0)
3831
let pixelAnim = createAnimation(keyPath: "filters.CIPixellate.inputScale", from: 8.0, to: 1.0, duration: duration)
3932
pixelAnim.fillMode = .forwards
4033
pixelAnim.isRemovedOnCompletion = false
34+
pixelAnim.delegate = AnimationCompletionDelegate { [weak self] finished in
35+
guard let self, finished else { return }
36+
self.clearFilters(from: layer)
37+
completion?()
38+
}
4139
layer.add(pixelAnim, forKey: "pixel")
4240

4341
// Animate saturation (1.5 -> 1.0)
@@ -82,15 +80,16 @@ class HologramAnimation: BaseWindowAnimation, WindowAnimation {
8280
CATransaction.begin()
8381
CATransaction.setAnimationDuration(duration)
8482
CATransaction.setAnimationTimingFunction(AnimationTiming.easeIn)
85-
CATransaction.setCompletionBlock {
86-
self.clearFilters(from: layer)
87-
completion?()
88-
}
8983

9084
// Animate pixelation (1.0 -> 8.0)
9185
let pixelAnim = createAnimation(keyPath: "filters.CIPixellate.inputScale", from: 1.0, to: 8.0, duration: duration)
9286
pixelAnim.fillMode = .forwards
9387
pixelAnim.isRemovedOnCompletion = false
88+
pixelAnim.delegate = AnimationCompletionDelegate { [weak self] finished in
89+
guard let self, finished else { return }
90+
self.clearFilters(from: layer)
91+
completion?()
92+
}
9493
layer.add(pixelAnim, forKey: "pixel")
9594

9695
// Animate saturation (1.0 -> 1.5)

LanguageFlag/Animations/FilterBased/InkDiffusionAnimation.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@ class InkDiffusionAnimation: BaseWindowAnimation, WindowAnimation {
2525
CATransaction.begin()
2626
CATransaction.setAnimationDuration(duration)
2727
CATransaction.setAnimationTimingFunction(AnimationTiming.easeOut)
28-
CATransaction.setCompletionBlock {
29-
self.clearFilters(from: layer)
30-
completion?()
31-
}
3228

3329
let morphAnim = createAnimation(keyPath: "filters.CIMorphologyMaximum.inputRadius", from: 10.0, to: 0.0, duration: duration)
3430
morphAnim.fillMode = .forwards
3531
morphAnim.isRemovedOnCompletion = false
32+
morphAnim.delegate = AnimationCompletionDelegate { [weak self] finished in
33+
guard let self, finished else { return }
34+
self.clearFilters(from: layer)
35+
completion?()
36+
}
3637
layer.add(morphAnim, forKey: "morph")
3738

3839
let blurAnim = createAnimation(keyPath: "filters.CIGaussianBlur.inputRadius", from: 15.0, to: 0.0, duration: duration)
@@ -70,14 +71,15 @@ class InkDiffusionAnimation: BaseWindowAnimation, WindowAnimation {
7071
CATransaction.begin()
7172
CATransaction.setAnimationDuration(duration)
7273
CATransaction.setAnimationTimingFunction(AnimationTiming.easeIn)
73-
CATransaction.setCompletionBlock {
74-
self.clearFilters(from: layer)
75-
completion?()
76-
}
7774

7875
let morphAnim = createAnimation(keyPath: "filters.CIMorphologyMaximum.inputRadius", from: 0.0, to: 10.0, duration: duration)
7976
morphAnim.fillMode = .forwards
8077
morphAnim.isRemovedOnCompletion = false
78+
morphAnim.delegate = AnimationCompletionDelegate { [weak self] finished in
79+
guard let self, finished else { return }
80+
self.clearFilters(from: layer)
81+
completion?()
82+
}
8183
layer.add(morphAnim, forKey: "morph")
8284

8385
let blurAnim = createAnimation(keyPath: "filters.CIGaussianBlur.inputRadius", from: 0.0, to: 15.0, duration: duration)

LanguageFlag/Animations/FilterBased/LiquidRippleAnimation.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,16 @@ class LiquidRippleAnimation: BaseWindowAnimation, WindowAnimation {
2525
CATransaction.begin()
2626
CATransaction.setAnimationDuration(duration)
2727
CATransaction.setAnimationTimingFunction(AnimationTiming.easeOut)
28-
CATransaction.setCompletionBlock {
29-
self.clearFilters(from: layer)
30-
completion?()
31-
}
3228

3329
// Animate splash radius (150 -> 0)
3430
let splashAnim = createAnimation(keyPath: "filters.CICircleSplashDistortion.inputRadius", from: 150.0, to: 0.0, duration: duration)
3531
splashAnim.fillMode = .forwards
3632
splashAnim.isRemovedOnCompletion = false
33+
splashAnim.delegate = AnimationCompletionDelegate { [weak self] finished in
34+
guard let self, finished else { return }
35+
self.clearFilters(from: layer)
36+
completion?()
37+
}
3738
layer.add(splashAnim, forKey: "splash")
3839

3940
CATransaction.commit()
@@ -60,15 +61,16 @@ class LiquidRippleAnimation: BaseWindowAnimation, WindowAnimation {
6061
CATransaction.begin()
6162
CATransaction.setAnimationDuration(duration)
6263
CATransaction.setAnimationTimingFunction(AnimationTiming.easeIn)
63-
CATransaction.setCompletionBlock {
64-
self.clearFilters(from: layer)
65-
completion?()
66-
}
6764

6865
// Animate splash radius (0 -> 150)
6966
let splashAnim = createAnimation(keyPath: "filters.CICircleSplashDistortion.inputRadius", from: 0.0, to: 150.0, duration: duration)
7067
splashAnim.fillMode = .forwards
7168
splashAnim.isRemovedOnCompletion = false
69+
splashAnim.delegate = AnimationCompletionDelegate { [weak self] finished in
70+
guard let self, finished else { return }
71+
self.clearFilters(from: layer)
72+
completion?()
73+
}
7274
layer.add(splashAnim, forKey: "splash")
7375

7476
CATransaction.commit()

0 commit comments

Comments
 (0)