diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index f28cb58..10a3f1f 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -28,6 +28,8 @@ struct LockScreenMusicPanel: View { @State private var isExpanded = false @State private var isVolumeSliderVisible = false @State private var collapseWorkItem: DispatchWorkItem? + @State private var parallaxResumeWorkItem: DispatchWorkItem? + @State private var isParallaxSuspended = false @State private var lastLoggedGlassSnapshot: GlassLogSnapshot? @Default(.lockScreenGlassStyle) var lockScreenGlassStyle @Default(.lockScreenShowAppIcon) var showAppIcon @@ -113,6 +115,9 @@ struct LockScreenMusicPanel: View { isActive = false cancelCollapseTimer() isVolumeSliderVisible = false + parallaxResumeWorkItem?.cancel() + parallaxResumeWorkItem = nil + isParallaxSuspended = false } .onChange(of: isExpanded) { _, expanded in updatePanelSize() @@ -239,7 +244,7 @@ struct LockScreenMusicPanel: View { } } .albumArtFlip(angle: musicManager.flipAngle) - .modifier(ParallaxMotionModifier(magnitude: 12, enableOverride: lockScreenParallaxEnabled)) + .parallax3D(magnitude: 12, enableOverride: lockScreenParallaxEnabled, suspended: isParallaxSuspended) .frame(width: size, height: size) .background(albumArtBackground(cornerRadius: cornerRadius)) } @@ -264,6 +269,7 @@ struct LockScreenMusicPanel: View { private func toggleExpanded() { let newState = !isExpanded + suspendParallaxInteraction() withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) { isExpanded = newState } @@ -282,6 +288,7 @@ struct LockScreenMusicPanel: View { guard isExpanded else { return } let workItem = DispatchWorkItem { + suspendParallaxInteraction() withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) { isExpanded = false } @@ -292,6 +299,17 @@ struct LockScreenMusicPanel: View { DispatchQueue.main.asyncAfter(deadline: .now() + collapseTimeout, execute: workItem) } + private func suspendParallaxInteraction(for duration: TimeInterval = 0.65) { + parallaxResumeWorkItem?.cancel() + isParallaxSuspended = true + + let workItem = DispatchWorkItem { + isParallaxSuspended = false + } + parallaxResumeWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: workItem) + } + private func cancelCollapseTimer() { collapseWorkItem?.cancel() collapseWorkItem = nil diff --git a/DynamicIsland/extensions/View+Parallax3D.swift b/DynamicIsland/extensions/View+Parallax3D.swift index 3afb3ba..a3dfcd4 100644 --- a/DynamicIsland/extensions/View+Parallax3D.swift +++ b/DynamicIsland/extensions/View+Parallax3D.swift @@ -6,51 +6,57 @@ // import SwiftUI +import AppKit import Defaults struct ParallaxMotionModifier: ViewModifier { var magnitude: Double var enableOverride: Bool? + var isSuspended: Bool @Default(.enableParallaxEffect) var enableParallaxEffect @State private var offset: CGSize = .zero @State private var isHovering = false + @State private var viewSize: CGSize = .zero func body(content: Content) -> some View { - if !(enableOverride ?? enableParallaxEffect) { + if isSuspended || !(enableOverride ?? enableParallaxEffect) { content } else { content + .contentShape(Rectangle()) .overlay( GeometryReader { proxy in Color.clear - .onContinuousHover { phase in - switch phase { - case .active(let location): - let width = proxy.size.width - let height = proxy.size.height - - // Normalize -1 to 1 - // (0,0) is top left, so we want center (width/2, height/2) to be (0,0) - let x = (location.x / width) * 2 - 1 - let y = (location.y / height) * 2 - 1 - - // Animate the transition to the new hover position - withAnimation(.interactiveSpring(response: 0.1, dampingFraction: 0.5)) { - offset = CGSize(width: x, height: y) - isHovering = true - } - case .ended: - // Return to center when hover ends - withAnimation(.spring(response: 0.35, dampingFraction: 0.6)) { - offset = .zero - isHovering = false - } - } + .allowsHitTesting(false) + .onAppear { + viewSize = proxy.size + } + .onChange(of: proxy.size) { _, newSize in + viewSize = newSize } } ) + .onContinuousHover { phase in + switch phase { + case .active(let location): + guard viewSize.width > 0, viewSize.height > 0 else { return } + guard NSEvent.pressedMouseButtons == 0 else { return } // Skip hover math while clicking to avoid lag + let x = (location.x / viewSize.width) * 2 - 1 + let y = (location.y / viewSize.height) * 2 - 1 + + withAnimation(.interactiveSpring(response: 0.1, dampingFraction: 0.5)) { + offset = CGSize(width: x, height: y) + isHovering = true + } + case .ended: + withAnimation(.spring(response: 0.35, dampingFraction: 0.6)) { + offset = .zero + isHovering = false + } + } + } .rotation3DEffect( .degrees(offset.height * magnitude), // Y movement rotates around X axis axis: (x: 1, y: 0, z: 0) @@ -65,7 +71,11 @@ struct ParallaxMotionModifier: ViewModifier { } extension View { - func parallax3D(magnitude: Double = 10) -> some View { - modifier(ParallaxMotionModifier(magnitude: magnitude, enableOverride: nil)) + func parallax3D( + magnitude: Double = 10, + enableOverride: Bool? = nil, + suspended: Bool = false + ) -> some View { + modifier(ParallaxMotionModifier(magnitude: magnitude, enableOverride: enableOverride, isSuspended: suspended)) } } diff --git a/ReadMe.md b/ReadMe.md index b5562cf..af10462 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -116,6 +116,7 @@ Atoll turns the MacBook notch into a focused command surface for media, system i - Productivity: rich timers with live activity, precise colour picker with formats, and a searchable clipboard history. - Calendar: streamlined agenda with snapshot-driven updates to keep EventKit usage lean. - Lock Screen: weather, media, charging, and Bluetooth battery widgets that respect system accessory styles. +- Parallax interactions: hover tilt now stays responsive while clicks fire immediately, and the lock screen album art suspends the effect mid-animation for smooth expansions. - Customisation: minimalistic/standard layouts, animation styles, hover behaviour, and full shortcut remapping. ## Requirements