From ea2706ad963b4203782b4357175432c126c0e42d Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 20 Nov 2025 10:54:27 +0530 Subject: [PATCH] Reposition and animations --- .../LockScreen/LockScreenMusicPanel.swift | 100 ++++++++++++++---- .../LockScreenWeatherPanelManager.swift | 10 +- 2 files changed, 89 insertions(+), 21 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 78889bc..7ee68b3 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -420,18 +420,20 @@ struct LockScreenMusicPanel: View { private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View { let controls = resolvedAuxControls + let skipNudge: CGFloat = isExpanded ? 14 : 9 + return HStack(spacing: spacing) { if let leftControl = controls.left { auxButton(for: leftControl) } - controlButton(icon: "backward.fill", size: 18) { + controlButton(icon: "backward.fill", size: 18, pressNudge: -skipNudge) { musicManager.previousTrack() } playPauseButton - controlButton(icon: "forward.fill", size: 18) { + controlButton(icon: "forward.fill", size: 18, pressNudge: skipNudge) { musicManager.nextTrack() } @@ -453,6 +455,7 @@ struct LockScreenMusicPanel: View { Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: symbolSize, weight: .semibold)) .foregroundColor(.white) + .contentTransition(.symbolEffect(.replace)) .frame(width: frameSize, height: frameSize) .contentShape(Rectangle()) } @@ -464,37 +467,41 @@ struct LockScreenMusicPanel: View { size: CGFloat = 18, isActive: Bool = false, activeColor: Color = .red, + pressNudge: CGFloat? = nil, action: @escaping () -> Void ) -> some View { let frameSize: CGFloat = isExpanded ? 56 : 32 let iconSize: CGFloat = isExpanded ? max(size, 24) : size + let iconColor = isActive ? activeColor : .white.opacity(0.8) + let backgroundOpacity: Double = isActive ? 0.22 : 0.0 - return Button(action: { + return PanelControlButton( + icon: icon, + frameSize: frameSize, + iconSize: iconSize, + iconColor: iconColor, + backgroundOpacity: backgroundOpacity, + pressNudge: pressNudge + ) { registerInteraction() action() - }) { - Image(systemName: icon) - .font(.system(size: iconSize, weight: .medium)) - .foregroundColor(isActive ? activeColor : .white.opacity(0.8)) - .frame(width: frameSize, height: frameSize) - .contentShape(Rectangle()) } - .buttonStyle(PlainButtonStyle()) } private var mediaOutputControlButton: some View { let frameSize: CGFloat = isExpanded ? 56 : 32 let iconSize: CGFloat = isExpanded ? 26 : 18 - return Button(action: toggleVolumeSlider) { - Image(systemName: mediaOutputIcon) - .font(.system(size: iconSize, weight: .medium)) - .foregroundColor(shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8)) - .frame(width: frameSize, height: frameSize) - .contentShape(Rectangle()) - } + return PanelControlButton( + icon: mediaOutputIcon, + frameSize: frameSize, + iconSize: iconSize, + iconColor: shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8), + backgroundOpacity: shouldShowVolumeSlider ? 0.22 : 0.0, + pressNudge: nil, + action: toggleVolumeSlider + ) .accessibilityLabel("Media output") - .buttonStyle(PlainButtonStyle()) } private func lyricsSection(alignment: Alignment) -> some View { @@ -791,3 +798,60 @@ struct LockScreenMusicPanel: View { print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: \(event) – \(styleDescriptor)") } } + +private struct PanelControlButton: View { + let icon: String + let frameSize: CGFloat + let iconSize: CGFloat + let iconColor: Color + let backgroundOpacity: Double + let pressNudge: CGFloat? + let action: () -> Void + + @State private var isHovering = false + @State private var pressOffset: CGFloat = 0 + + var body: some View { + Button(action: { + triggerPressEffect() + action() + }) { + RoundedRectangle(cornerRadius: frameSize / 2, style: .continuous) + .fill(backgroundColor) + .overlay( + Image(systemName: icon) + .font(.system(size: iconSize, weight: .medium)) + .foregroundStyle(iconColor) + .contentTransition(.symbolEffect(.replace)) + ) + } + .frame(width: frameSize, height: frameSize) + .buttonStyle(PlainButtonStyle()) + .offset(x: pressOffset) + .onHover { hovering in + withAnimation(.smooth(duration: 0.24)) { + isHovering = hovering + } + } + } + + private var backgroundColor: Color { + let hoveredOpacity = max(backgroundOpacity + 0.08, 0.18) + let appliedOpacity = isHovering ? hoveredOpacity : backgroundOpacity + return Color.white.opacity(min(appliedOpacity, 0.32)) + } + + private func triggerPressEffect() { + guard let pressNudge else { return } + + withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) { + pressOffset = pressNudge + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { + withAnimation(.spring(response: 0.28, dampingFraction: 0.72)) { + pressOffset = 0 + } + } + } +} diff --git a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift index d5966d6..77fca3c 100644 --- a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift @@ -38,7 +38,7 @@ final class LockScreenWeatherPanelManager { let fittingSize = hostingView.fittingSize hostingView.frame = NSRect(origin: .zero, size: fittingSize) - let targetFrame = frame(for: fittingSize, on: screen) + let targetFrame = frame(for: fittingSize, snapshot: snapshot, on: screen) let window = ensureWindow() window.setFrame(targetFrame, display: true) latestFrame = targetFrame @@ -78,14 +78,18 @@ final class LockScreenWeatherPanelManager { return newWindow } - private func frame(for size: CGSize, on screen: NSScreen) -> NSRect { + private func frame(for size: CGSize, snapshot: LockScreenWeatherSnapshot, on screen: NSScreen) -> NSRect { let screenFrame = screen.frame let originX = screenFrame.midX - (size.width / 2) let verticalOffset = screenFrame.height * 0.15 let maxY = screenFrame.maxY - size.height - 48 let baseY = min(maxY, screenFrame.midY + verticalOffset) let loweredY = baseY - 36 - let clampedY = max(screenFrame.minY + 80, loweredY) + + let inlineLift: CGFloat = snapshot.widgetStyle == .inline ? 28 : 0 + let adjustedY = loweredY + inlineLift + let upperClampedY = min(maxY, adjustedY) + let clampedY = max(screenFrame.minY + 80, upperClampedY) return NSRect(x: originX, y: clampedY, width: size.width, height: size.height) } }