From 4aa481ecb151fba36f0a70ad480136d739fab270 Mon Sep 17 00:00:00 2001
From: Hariharan Mudaliar
Date: Tue, 18 Nov 2025 02:28:03 +0530
Subject: [PATCH] Add animations
---
DynamicIsland/components/HoverButton.swift | 28 +++-
.../Notch/MinimalisticMusicPlayerView.swift | 124 ++++++++++++++++--
.../components/Notch/NotchHomeView.swift | 21 ++-
DynamicIsland/models/Constants.swift | 19 ++-
ReadMe.md | 4 +-
5 files changed, 175 insertions(+), 21 deletions(-)
diff --git a/DynamicIsland/components/HoverButton.swift b/DynamicIsland/components/HoverButton.swift
index d1f6d0e..1b96caf 100644
--- a/DynamicIsland/components/HoverButton.swift
+++ b/DynamicIsland/components/HoverButton.swift
@@ -11,15 +11,20 @@ struct HoverButton: View {
var icon: String
var iconColor: Color = .white;
var scale: Image.Scale = .medium
+ var pressEffect: PressEffect? = nil
var action: () -> Void
var contentTransition: ContentTransition = .symbolEffect;
@State private var isHovering = false
+ @State private var pressOffset: CGFloat = 0
var body: some View {
let size = CGFloat(scale == .large ? 40 : 30)
- Button(action: action) {
+ Button(action: {
+ triggerPressEffect()
+ action()
+ }) {
Rectangle()
.fill(.clear)
.contentShape(Rectangle())
@@ -37,10 +42,31 @@ struct HoverButton: View {
}
}
.buttonStyle(PlainButtonStyle())
+ .offset(x: pressOffset)
.onHover { hovering in
withAnimation(.smooth(duration: 0.3)) {
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)
+ }
}
diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift
index 34aa282..2ecdb29 100644
--- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift
+++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift
@@ -600,23 +600,25 @@ private struct MinimalisticReminderDetailsView: View {
// MARK: - Playback Controls (Larger)
private var playbackControls: some View {
- HStack(spacing: 16) {
+ let controls = resolvedAuxControls
+
+ return HStack(spacing: 16) {
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() }
}
playPauseButton
- controlButton(icon: "forward.fill", size: 18) {
+ controlButton(icon: "forward.fill", size: 18, pressEffect: .nudge(8)) {
Task { await musicManager.nextTrack() }
}
if Defaults[.showShuffleAndRepeat] {
- auxButton(for: rightAuxControl)
+ auxButton(for: controls.right)
}
}
.frame(maxWidth: .infinity, alignment: .center)
@@ -631,13 +633,22 @@ private struct MinimalisticReminderDetailsView: View {
frameSize: CGSize(width: 54, height: 54),
cornerRadius: 20,
foregroundColor: .white,
+ pressEffect: .none,
+ symbolEffectStyle: .replace,
action: {
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(
icon: icon,
fontSize: size,
@@ -645,6 +656,8 @@ private struct MinimalisticReminderDetailsView: View {
frameSize: CGSize(width: 40, height: 40),
cornerRadius: 16,
foregroundColor: isActive ? .red : .white.opacity(0.85),
+ pressEffect: pressEffect,
+ symbolEffectStyle: symbolEffect,
action: action
)
}
@@ -663,11 +676,22 @@ private struct MinimalisticReminderDetailsView: View {
case .mediaOutput:
MinimalisticMediaOutputButton()
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()
}
}
}
+
+ private var resolvedAuxControls: (left: MusicAuxiliaryControl, right: MusicAuxiliaryControl) {
+ guard leftAuxControl == rightAuxControl else {
+ return (leftAuxControl, rightAuxControl)
+ }
+ return (leftAuxControl, MusicAuxiliaryControl.alternative(excluding: leftAuxControl))
+ }
private struct MinimalisticMediaOutputButton: View {
@ObservedObject private var routeManager = AudioRouteManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
@@ -795,15 +819,41 @@ private struct MinimalisticSquircircleButton: View {
let frameSize: CGSize
let cornerRadius: CGFloat
let foregroundColor: Color
+ let pressEffect: PressEffect
+ let symbolEffectStyle: SymbolEffectStyle
let action: () -> Void
@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 {
- Button(action: action) {
- Image(systemName: icon)
- .font(.system(size: fontSize, weight: fontWeight))
- .foregroundColor(foregroundColor)
+ Button {
+ triggerPressEffect()
+ action()
+ } label: {
+ iconView()
.frame(width: frameSize.width, height: frameSize.height)
.background(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
@@ -812,10 +862,62 @@ private struct MinimalisticSquircircleButton: View {
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
}
.buttonStyle(PlainButtonStyle())
+ .offset(x: pressOffset)
.onHover { hovering in
withAnimation(.easeOut(duration: 0.18)) {
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
+ }
}
diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift
index 61a8568..56b74cd 100644
--- a/DynamicIsland/components/Notch/NotchHomeView.swift
+++ b/DynamicIsland/components/Notch/NotchHomeView.swift
@@ -158,7 +158,7 @@ struct MusicControlsView: View {
)
.fontWeight(.medium)
// Lyrics shown under the author name (same font size as author) when enabled in settings
- if Defaults[.enableLyrics] {
+ if enableLyrics {
let transition = AnyTransition.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .move(edge: .top).combined(with: .opacity)
@@ -213,21 +213,23 @@ struct MusicControlsView: View {
}
private var playbackControls: some View {
- HStack(spacing: 8) {
+ let controls = resolvedAuxControls
+
+ return HStack(spacing: 8) {
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()
}
HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) {
MusicManager.shared.togglePlay()
}
- HoverButton(icon: "forward.fill", scale: .medium) {
+ HoverButton(icon: "forward.fill", scale: .medium, pressEffect: .nudge(6)) {
MusicManager.shared.nextTrack()
}
if showShuffleAndRepeat {
- auxButton(for: rightAuxControl)
+ auxButton(for: controls.right)
}
}
.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
private func auxButton(for control: MusicAuxiliaryControl) -> some View {
switch control {
diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift
index eeeec99..33d96fd 100644
--- a/DynamicIsland/models/Constants.swift
+++ b/DynamicIsland/models/Constants.swift
@@ -213,9 +213,26 @@ enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Seriali
case .mediaOutput:
return "Media Output"
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 {
diff --git a/ReadMe.md b/ReadMe.md
index 0ae4746..dc1fdc7 100644
--- a/ReadMe.md
+++ b/ReadMe.md
@@ -12,7 +12,7 @@
-
+
@@ -30,7 +30,7 @@
- Join our Discord community
+ Join our Discord community
**Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll).