From bf9ecef9c570a8a4283947cd0c0e1bd9c898c7f0 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 28 Sep 2025 13:17:38 +0530 Subject: [PATCH] Somewhat bearable --- DynamicIsland/ContentView.swift | 152 +++++++++++++----- .../components/Notch/NotchStatsView.swift | 7 +- 2 files changed, 120 insertions(+), 39 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 636f001..7651385 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -30,25 +30,34 @@ struct ContentView: View { @Default(.showNetworkGraph) var showNetworkGraph @Default(.showDiskGraph) var showDiskGraph + // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { - // Only apply dynamic sizing when on stats tab and stats are enabled - guard coordinator.currentView == .stats && enableStatsFeature else { - return openNotchSize + // Always return the target size for the current view to ensure smooth transitions + let targetSize: CGSize + + if coordinator.currentView == .stats { + // Count enabled graphs using the same logic as NotchStatsView + var enabledGraphsCount = 0 + if showCpuGraph { enabledGraphsCount += 1 } + if showMemoryGraph { enabledGraphsCount += 1 } + if showGpuGraph { enabledGraphsCount += 1 } + if showNetworkGraph { enabledGraphsCount += 1 } + if showDiskGraph { enabledGraphsCount += 1 } + + if enabledGraphsCount > 3 { + // Expand height for 4+ graphs + targetSize = CGSize( + width: openNotchSize.width, + height: openNotchSize.height + 65 // Add extra height for second row + ) + } else { + targetSize = openNotchSize + } + } else { + targetSize = openNotchSize } - let enabledGraphsCount = [showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph].filter { $0 }.count - - // Calculate height based on layout: 1-3 graphs = single row, 4+ graphs = two rows - var requiredHeight = openNotchSize.height - - if enabledGraphsCount >= 4 { - // Two rows needed - add height for second row plus spacing - let extraHeight: CGFloat = 120 + 12 // Graph height + spacing - requiredHeight = openNotchSize.height + extraHeight - } - - // Width stays constant - no horizontal expansion - return CGSize(width: openNotchSize.width, height: requiredHeight) + return targetSize } @@ -57,6 +66,8 @@ struct ContentView: View { @State private var debounceWorkItem: DispatchWorkItem? @State private var isHoverStateChanging: Bool = false + @State private var isStatsTransitioning: Bool = false + @State private var lastHapticTime: Date = Date() @State private var gestureProgress: CGFloat = .zero @@ -68,7 +79,7 @@ struct ContentView: View { @Default(.showNotHumanFace) var showNotHumanFace @Default(.useModernCloseAnimation) var useModernCloseAnimation - + private let extendedHoverPadding: CGFloat = 30 private let zeroHeightHoverPadding: CGFloat = 10 @@ -98,18 +109,22 @@ struct ContentView: View { .conditionalModifier(!useModernCloseAnimation) { view in let hoverAnimationAnimation = Animation.bouncy.speed(1.2) let notchStateAnimation = Animation.spring.speed(1.2) + let viewTransitionAnimation = Animation.easeInOut(duration: 0.4) return view .animation(hoverAnimationAnimation, value: isHovering) .animation(notchStateAnimation, value: vm.notchState) + .animation(viewTransitionAnimation, value: coordinator.currentView) .animation(.smooth, value: gestureProgress) .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) } .conditionalModifier(useModernCloseAnimation) { view in let hoverAnimationAnimation = Animation.bouncy.speed(1.2) let notchStateAnimation = Animation.spring.speed(1.2) + let viewTransitionAnimation = Animation.easeInOut(duration: 0.4) return view .animation(hoverAnimationAnimation, value: isHovering) .animation(notchStateAnimation, value: vm.notchState) + .animation(viewTransitionAnimation, value: coordinator.currentView) } .conditionalModifier(Defaults[.openNotchOnHover]) { view in view.onHover { hovering in @@ -119,26 +134,11 @@ struct ContentView: View { .conditionalModifier(!Defaults[.openNotchOnHover]) { view in view .onHover { hovering in - withAnimation(vm.animation) { - isHovering = hovering - } - - // Only close if mouse leaves and the notch is open and no popovers are active - if !hovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive { - // Add slight delay for stats tab to prevent accidental closure during layout changes - let delay: Double = (coordinator.currentView == .stats) ? 0.2 : 0.0 - - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - // Double-check conditions after delay - if !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !vm.isStatsPopoverActive { - vm.close() - } - } - } + handleSimpleHover(hovering) } .onTapGesture { if (vm.notchState == .closed) && Defaults[.enableHaptics] { - haptics.toggle() + triggerHapticIfAllowed() } doOpen() } @@ -210,6 +210,19 @@ struct ContentView: View { } } } + .onChange(of: coordinator.currentView) { _, newView in + // Cancel any pending hover actions during view transitions + hoverWorkItem?.cancel() + debounceWorkItem?.cancel() + + // Set transition state briefly to prevent immediate closure + isStatsTransitioning = true + let delay = newView == .stats ? 1.0 : 0.5 + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + isStatsTransitioning = false + } + } .sensoryFeedback(.alignment, trigger: haptics) .contextMenu { SettingsLink(label: { @@ -521,7 +534,7 @@ struct ContentView: View { // Only provide haptic feedback if notch is closed if vm.notchState == .closed && Defaults[.enableHaptics] { - haptics.toggle() + triggerHapticIfAllowed() } // Delay opening the notch @@ -560,7 +573,9 @@ struct ContentView: View { } // Close the notch if it's open and no popovers are active - if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive { + let hasActivePopovers = vm.isBatteryPopoverActive || vm.isClipboardPopoverActive || vm.isColorPickerPopoverActive || vm.isStatsPopoverActive + + if vm.notchState == .open && !hasActivePopovers { vm.close() } } @@ -571,6 +586,67 @@ struct ContentView: View { } } + // Simple hover handler for non-openNotchOnHover mode + private func handleSimpleHover(_ hovering: Bool) { + // Cancel any existing work items first + hoverWorkItem?.cancel() + debounceWorkItem?.cancel() + + if hovering { + // Mouse entered + withAnimation(vm.animation) { + isHovering = true + } + return + } + + // Mouse exited - use debounce to prevent rapid firing + let debounce = DispatchWorkItem { + withAnimation(vm.animation) { + isHovering = false + } + + // Check if we should close after a delay + let hasPopovers = hasAnyActivePopovers() + + if vm.notchState == .open && !hasPopovers { + let isStatsTab = coordinator.currentView == .stats + let delay: Double = isStatsTab ? 0.8 : 0.2 + + let closeTask = DispatchWorkItem { + if !isHovering && vm.notchState == .open && !hasAnyActivePopovers() { + vm.close() + } + } + + hoverWorkItem = closeTask + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: closeTask) + } + } + + debounceWorkItem = debounce + // Longer debounce for stats tab to prevent rapid hover events + let debounceDelay = coordinator.currentView == .stats ? 0.2 : 0.05 + DispatchQueue.main.asyncAfter(deadline: .now() + debounceDelay, execute: debounce) + } + + // Helper function to check if any popovers are active + private func hasAnyActivePopovers() -> Bool { + return vm.isBatteryPopoverActive || + vm.isClipboardPopoverActive || + vm.isColorPickerPopoverActive || + vm.isStatsPopoverActive + } + + // Helper to prevent rapid haptic feedback + private func triggerHapticIfAllowed() { + let now = Date() + if now.timeIntervalSince(lastHapticTime) > 0.3 { // Minimum 300ms between haptics + haptics.toggle() + lastHapticTime = now + } + } + // MARK: - Gesture Handling private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) { @@ -588,7 +664,7 @@ struct ContentView: View { if translation > Defaults[.gestureSensitivity] { if Defaults[.enableHaptics] { - haptics.toggle() + triggerHapticIfAllowed() } withAnimation(.smooth) { gestureProgress = .zero @@ -617,7 +693,7 @@ struct ContentView: View { vm.close() if Defaults[.enableHaptics] { - haptics.toggle() + triggerHapticIfAllowed() } } } diff --git a/DynamicIsland/components/Notch/NotchStatsView.swift b/DynamicIsland/components/Notch/NotchStatsView.swift index 0209d93..3b695dd 100644 --- a/DynamicIsland/components/Notch/NotchStatsView.swift +++ b/DynamicIsland/components/Notch/NotchStatsView.swift @@ -340,6 +340,11 @@ struct NotchStatsView: View { if enableStatsFeature && Defaults[.autoStartStatsMonitoring] && !statsManager.isMonitoring { statsManager.startMonitoring() } + // Protect against hover interference during view transition + isResizingForStats = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + isResizingForStats = false + } } .onDisappear { // Keep monitoring running when tab is not visible @@ -349,7 +354,7 @@ struct NotchStatsView: View { .onChange(of: availableGraphs.count) { _, newCount in // Protect against hover interference during dynamic sizing isResizingForStats = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { isResizingForStats = false } }