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 @@ GitHub downloads - + Discord server

@@ -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).