Add animations

This commit is contained in:
Hariharan Mudaliar 2025-11-18 02:28:03 +05:30
commit 4aa481ecb1
5 changed files with 175 additions and 21 deletions

View file

@ -11,15 +11,20 @@ struct HoverButton: View {
var icon: String var icon: String
var iconColor: Color = .white; var iconColor: Color = .white;
var scale: Image.Scale = .medium var scale: Image.Scale = .medium
var pressEffect: PressEffect? = nil
var action: () -> Void var action: () -> Void
var contentTransition: ContentTransition = .symbolEffect; var contentTransition: ContentTransition = .symbolEffect;
@State private var isHovering = false @State private var isHovering = false
@State private var pressOffset: CGFloat = 0
var body: some View { var body: some View {
let size = CGFloat(scale == .large ? 40 : 30) let size = CGFloat(scale == .large ? 40 : 30)
Button(action: action) { Button(action: {
triggerPressEffect()
action()
}) {
Rectangle() Rectangle()
.fill(.clear) .fill(.clear)
.contentShape(Rectangle()) .contentShape(Rectangle())
@ -37,10 +42,31 @@ struct HoverButton: View {
} }
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.offset(x: pressOffset)
.onHover { hovering in .onHover { hovering in
withAnimation(.smooth(duration: 0.3)) { withAnimation(.smooth(duration: 0.3)) {
isHovering = hovering isHovering = hovering
} }
} }
} }
private func triggerPressEffect() {
guard let pressEffect else { return }
switch pressEffect {
case .nudge(let amount):
withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) {
pressOffset = amount
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
withAnimation(.spring(response: 0.28, dampingFraction: 0.72)) {
pressOffset = 0
}
}
}
}
enum PressEffect {
case nudge(CGFloat)
}
} }

View file

@ -600,23 +600,25 @@ private struct MinimalisticReminderDetailsView: View {
// MARK: - Playback Controls (Larger) // MARK: - Playback Controls (Larger)
private var playbackControls: some View { private var playbackControls: some View {
HStack(spacing: 16) { let controls = resolvedAuxControls
return HStack(spacing: 16) {
if Defaults[.showShuffleAndRepeat] { if Defaults[.showShuffleAndRepeat] {
auxButton(for: leftAuxControl) auxButton(for: controls.left)
} }
controlButton(icon: "backward.fill", size: 18) { controlButton(icon: "backward.fill", size: 18, pressEffect: .nudge(-8)) {
Task { await musicManager.previousTrack() } Task { await musicManager.previousTrack() }
} }
playPauseButton playPauseButton
controlButton(icon: "forward.fill", size: 18) { controlButton(icon: "forward.fill", size: 18, pressEffect: .nudge(8)) {
Task { await musicManager.nextTrack() } Task { await musicManager.nextTrack() }
} }
if Defaults[.showShuffleAndRepeat] { if Defaults[.showShuffleAndRepeat] {
auxButton(for: rightAuxControl) auxButton(for: controls.right)
} }
} }
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
@ -631,13 +633,22 @@ private struct MinimalisticReminderDetailsView: View {
frameSize: CGSize(width: 54, height: 54), frameSize: CGSize(width: 54, height: 54),
cornerRadius: 20, cornerRadius: 20,
foregroundColor: .white, foregroundColor: .white,
pressEffect: .none,
symbolEffectStyle: .replace,
action: { action: {
Task { await musicManager.togglePlay() } Task { await musicManager.togglePlay() }
} }
) )
} }
private func controlButton(icon: String, size: CGFloat = 18, isActive: Bool = false, action: @escaping () -> Void) -> some View { private func controlButton(
icon: String,
size: CGFloat = 18,
isActive: Bool = false,
pressEffect: MinimalisticSquircircleButton.PressEffect = .none,
symbolEffect: MinimalisticSquircircleButton.SymbolEffectStyle = .none,
action: @escaping () -> Void
) -> some View {
MinimalisticSquircircleButton( MinimalisticSquircircleButton(
icon: icon, icon: icon,
fontSize: size, fontSize: size,
@ -645,6 +656,8 @@ private struct MinimalisticReminderDetailsView: View {
frameSize: CGSize(width: 40, height: 40), frameSize: CGSize(width: 40, height: 40),
cornerRadius: 16, cornerRadius: 16,
foregroundColor: isActive ? .red : .white.opacity(0.85), foregroundColor: isActive ? .red : .white.opacity(0.85),
pressEffect: pressEffect,
symbolEffectStyle: symbolEffect,
action: action action: action
) )
} }
@ -663,11 +676,22 @@ private struct MinimalisticReminderDetailsView: View {
case .mediaOutput: case .mediaOutput:
MinimalisticMediaOutputButton() MinimalisticMediaOutputButton()
case .lyrics: case .lyrics:
controlButton(icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", isActive: enableLyrics) { controlButton(
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
isActive: enableLyrics,
symbolEffect: .replace
) {
enableLyrics.toggle() enableLyrics.toggle()
} }
} }
} }
private var resolvedAuxControls: (left: MusicAuxiliaryControl, right: MusicAuxiliaryControl) {
guard leftAuxControl == rightAuxControl else {
return (leftAuxControl, rightAuxControl)
}
return (leftAuxControl, MusicAuxiliaryControl.alternative(excluding: leftAuxControl))
}
private struct MinimalisticMediaOutputButton: View { private struct MinimalisticMediaOutputButton: View {
@ObservedObject private var routeManager = AudioRouteManager.shared @ObservedObject private var routeManager = AudioRouteManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel() @StateObject private var volumeModel = MediaOutputVolumeViewModel()
@ -795,15 +819,41 @@ private struct MinimalisticSquircircleButton: View {
let frameSize: CGSize let frameSize: CGSize
let cornerRadius: CGFloat let cornerRadius: CGFloat
let foregroundColor: Color let foregroundColor: Color
let pressEffect: PressEffect
let symbolEffectStyle: SymbolEffectStyle
let action: () -> Void let action: () -> Void
@State private var isHovering = false @State private var isHovering = false
@State private var pressOffset: CGFloat = 0
init(
icon: String,
fontSize: CGFloat,
fontWeight: Font.Weight,
frameSize: CGSize,
cornerRadius: CGFloat,
foregroundColor: Color,
pressEffect: PressEffect = .none,
symbolEffectStyle: SymbolEffectStyle = .none,
action: @escaping () -> Void
) {
self.icon = icon
self.fontSize = fontSize
self.fontWeight = fontWeight
self.frameSize = frameSize
self.cornerRadius = cornerRadius
self.foregroundColor = foregroundColor
self.pressEffect = pressEffect
self.symbolEffectStyle = symbolEffectStyle
self.action = action
}
var body: some View { var body: some View {
Button(action: action) { Button {
Image(systemName: icon) triggerPressEffect()
.font(.system(size: fontSize, weight: fontWeight)) action()
.foregroundColor(foregroundColor) } label: {
iconView()
.frame(width: frameSize.width, height: frameSize.height) .frame(width: frameSize.width, height: frameSize.height)
.background( .background(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
@ -812,10 +862,62 @@ private struct MinimalisticSquircircleButton: View {
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.offset(x: pressOffset)
.onHover { hovering in .onHover { hovering in
withAnimation(.easeOut(duration: 0.18)) { withAnimation(.easeOut(duration: 0.18)) {
isHovering = hovering isHovering = hovering
} }
} }
} }
private func triggerPressEffect() {
guard case let .nudge(amount) = pressEffect else { return }
withAnimation(.spring(response: 0.16, dampingFraction: 0.72)) {
pressOffset = amount
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) {
pressOffset = 0
}
}
}
@ViewBuilder
private func iconView() -> some View {
let image = Image(systemName: icon)
.font(.system(size: fontSize, weight: fontWeight))
.foregroundColor(foregroundColor)
switch symbolEffectStyle {
case .none:
image
case .replace:
if #available(macOS 14.0, *) {
image
.contentTransition(.symbolEffect(.replace))
.id(icon)
} else {
image
}
case .bounce:
if #available(macOS 14.0, *) {
image.symbolEffect(.bounce, value: icon)
} else {
image
}
}
}
enum PressEffect {
case none
case nudge(CGFloat)
}
enum SymbolEffectStyle {
case none
case replace
case bounce
}
} }

View file

@ -158,7 +158,7 @@ struct MusicControlsView: View {
) )
.fontWeight(.medium) .fontWeight(.medium)
// Lyrics shown under the author name (same font size as author) when enabled in settings // Lyrics shown under the author name (same font size as author) when enabled in settings
if Defaults[.enableLyrics] { if enableLyrics {
let transition = AnyTransition.asymmetric( let transition = AnyTransition.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity), insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .move(edge: .top).combined(with: .opacity) removal: .move(edge: .top).combined(with: .opacity)
@ -213,21 +213,23 @@ struct MusicControlsView: View {
} }
private var playbackControls: some View { private var playbackControls: some View {
HStack(spacing: 8) { let controls = resolvedAuxControls
return HStack(spacing: 8) {
if showShuffleAndRepeat { if showShuffleAndRepeat {
auxButton(for: leftAuxControl) auxButton(for: controls.left)
} }
HoverButton(icon: "backward.fill", scale: .medium) { HoverButton(icon: "backward.fill", scale: .medium, pressEffect: .nudge(-6)) {
MusicManager.shared.previousTrack() MusicManager.shared.previousTrack()
} }
HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) { HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) {
MusicManager.shared.togglePlay() MusicManager.shared.togglePlay()
} }
HoverButton(icon: "forward.fill", scale: .medium) { HoverButton(icon: "forward.fill", scale: .medium, pressEffect: .nudge(6)) {
MusicManager.shared.nextTrack() MusicManager.shared.nextTrack()
} }
if showShuffleAndRepeat { if showShuffleAndRepeat {
auxButton(for: rightAuxControl) auxButton(for: controls.right)
} }
} }
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
@ -253,6 +255,13 @@ struct MusicControlsView: View {
} }
} }
private var resolvedAuxControls: (left: MusicAuxiliaryControl, right: MusicAuxiliaryControl) {
guard leftAuxControl == rightAuxControl else {
return (leftAuxControl, rightAuxControl)
}
return (leftAuxControl, MusicAuxiliaryControl.alternative(excluding: leftAuxControl))
}
@ViewBuilder @ViewBuilder
private func auxButton(for control: MusicAuxiliaryControl) -> some View { private func auxButton(for control: MusicAuxiliaryControl) -> some View {
switch control { switch control {

View file

@ -213,9 +213,26 @@ enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Seriali
case .mediaOutput: case .mediaOutput:
return "Media Output" return "Media Output"
case .lyrics: case .lyrics:
return "Lyrics Toggle" return "Lyrics"
} }
} }
var symbolName: String {
switch self {
case .shuffle:
return "shuffle"
case .repeatMode:
return "repeat"
case .mediaOutput:
return "speaker.wave.2"
case .lyrics:
return "quote.bubble"
}
}
static func alternative(excluding control: MusicAuxiliaryControl) -> MusicAuxiliaryControl {
allCases.first { $0 != control } ?? .shuffle
}
} }
enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable { enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable {

View file

@ -12,7 +12,7 @@
<a href="https://github.com/Ebullioscopic/Atoll/releases"> <a href="https://github.com/Ebullioscopic/Atoll/releases">
<img src="https://img.shields.io/github/downloads/Ebullioscopic/Atoll/total?label=Downloads" alt="GitHub downloads"/> <img src="https://img.shields.io/github/downloads/Ebullioscopic/Atoll/total?label=Downloads" alt="GitHub downloads"/>
</a> </a>
<a href="https://discord.gg/zvjB4tJBkt"> <a href="https://discord.gg/PaqFkRTDF8">
<img src="https://img.shields.io/discord/1429481472942669896?label=Discord&logo=discord&color=7289da" alt="Discord server"/> <img src="https://img.shields.io/discord/1429481472942669896?label=Discord&logo=discord&color=7289da" alt="Discord server"/>
</a> </a>
</p> </p>
@ -30,7 +30,7 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://discord.gg/zvjB4tJBkt">Join our Discord community</a> <a href="https://discord.gg/PaqFkRTDF8">Join our Discord community</a>
</p> </p>
**Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll). **Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll).