Skip to content

Commit bc9a4b9

Browse files
Fix hover button animations: smooth expand/collapse with cooldown and cancellation guard
- Animate isHovered transitions with easeInOut(0.15s) for smooth icon→label expand - Add hoverCooldown (150ms) after cursor exits to block animation-induced re-entry - Fix Task cancellation: guard !Task.isCancelled after try? sleep so cancelled tasks don't execute their side-effects (was causing labels to get stuck open) - Add onHoverChange callback to propagate child hover state to parent - Use childHovered flag in EyedropperButton to suppress premature hide task - Use Timer.publish in ContentView for stable swap button visibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0573e9a commit bc9a4b9

2 files changed

Lines changed: 47 additions & 39 deletions

File tree

Pika/ButtonStyles/SwapButtonStyle.swift

Lines changed: 46 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ struct SwapButtonStyle: ButtonStyle {
1111

1212
@State private var isHovered: Bool = false
1313
@State private var hoverTask: Task<Void, Never>?
14+
@State private var hoverCooldown: Task<Void, Never>?
1415

1516
let configuration: Configuration
1617
let isVisible: Bool
@@ -38,48 +39,54 @@ struct SwapButtonStyle: ButtonStyle {
3839
}
3940
configuration.label
4041
}
41-
}.animation(.easeInOut, value: isHovered)
42-
.padding(.horizontal, 8)
43-
.padding(.vertical, 8)
44-
.mask(RoundedRectangle(cornerRadius: 100.0, style: .continuous))
45-
.background(
46-
ZStack {
47-
RoundedRectangle(cornerRadius: 100.0, style: .continuous)
48-
.fill(bgColor)
49-
.shadow(
50-
color: Color.black.opacity(0.2),
51-
radius: configuration.isPressed ? 1 : 2,
52-
x: 0,
53-
y: configuration.isPressed ? 1 : 2
54-
)
55-
.overlay(
56-
RoundedRectangle(cornerRadius: 100.0, style: .continuous)
57-
.stroke(fgColor.opacity(0.1))
58-
)
59-
}
60-
)
61-
.onHover { hover in
62-
onHoverChange?(hover)
63-
if hover {
64-
if hoverTask == nil {
65-
hoverTask = Task {
66-
try? await Task.sleep(for: .milliseconds(100))
67-
isHovered = true
68-
hoverTask = nil
69-
}
70-
}
71-
} else {
72-
hoverTask?.cancel()
42+
}
43+
.padding(.horizontal, 8)
44+
.padding(.vertical, 8)
45+
.mask(RoundedRectangle(cornerRadius: 100.0, style: .continuous))
46+
.background(
47+
ZStack {
48+
RoundedRectangle(cornerRadius: 100.0, style: .continuous)
49+
.fill(bgColor)
50+
.shadow(
51+
color: Color.black.opacity(0.2),
52+
radius: configuration.isPressed ? 1 : 2,
53+
x: 0,
54+
y: configuration.isPressed ? 1 : 2
55+
)
56+
.overlay(
57+
RoundedRectangle(cornerRadius: 100.0, style: .continuous)
58+
.stroke(fgColor.opacity(0.1))
59+
)
60+
}
61+
)
62+
.onHover { hover in
63+
onHoverChange?(hover)
64+
if hover {
65+
guard hoverCooldown == nil, hoverTask == nil else { return }
66+
hoverTask = Task {
67+
try? await Task.sleep(for: .milliseconds(100))
68+
guard !Task.isCancelled else { return }
69+
isHovered = true
7370
hoverTask = nil
74-
isHovered = false
71+
}
72+
} else {
73+
hoverTask?.cancel()
74+
hoverTask = nil
75+
isHovered = false
76+
hoverCooldown?.cancel()
77+
hoverCooldown = Task {
78+
try? await Task.sleep(for: .milliseconds(150))
79+
guard !Task.isCancelled else { return }
80+
hoverCooldown = nil
7581
}
7682
}
77-
.animation(.easeInOut, value: isHovered)
78-
.opacity(isVisible ? (configuration.isPressed ? 0.8 : 1.0) : 0.0)
79-
.foregroundColor(fgColor.opacity(0.8))
80-
.frame(height: 32.0)
81-
.animation(.easeInOut, value: isVisible)
82-
.animation(.easeInOut, value: configuration.isPressed)
83+
}
84+
.opacity(isVisible ? (configuration.isPressed ? 0.8 : 1.0) : 0.0)
85+
.foregroundColor(fgColor.opacity(0.8))
86+
.frame(height: 32.0)
87+
.animation(.easeInOut(duration: 0.15), value: isHovered)
88+
.animation(.easeInOut, value: isVisible)
89+
.animation(.easeInOut, value: configuration.isPressed)
8390
}
8491
}
8592

Pika/Views/EyedropperButton.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ struct EyedropperButton: View {
9797
} else if hoverTask == nil, !childHovered {
9898
hoverTask = Task {
9999
try? await Task.sleep(for: .milliseconds(250))
100+
guard !Task.isCancelled else { return }
100101
hoverVisible = false
101102
hoverTask = nil
102103
}

0 commit comments

Comments
 (0)