diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 7d37ba9..3beb5b5 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -276,33 +276,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight) } - // Use minimalistic or normal size based on settings - var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize - - // Only apply dynamic sizing when on stats tab and stats are enabled - guard coordinator.currentView == .stats && Defaults[.enableStatsFeature] else { - return baseSize - } - - let enabledGraphsCount = [ - Defaults[.showCpuGraph], - Defaults[.showMemoryGraph], - Defaults[.showGpuGraph], - Defaults[.showNetworkGraph], - Defaults[.showDiskGraph] - ].filter { $0 }.count - - // Calculate height based on layout: 1-3 graphs = single row, 4+ graphs = two rows - var requiredHeight = baseSize.height - - if enabledGraphsCount >= 4 { - // Two rows needed - add height for second row plus spacing - let extraHeight: CGFloat = 120 + 12 // Graph height + spacing - requiredHeight = baseSize.height + extraHeight - } - - // Width stays constant - no horizontal expansion - return CGSize(width: baseSize.width, height: requiredHeight) + // Use minimalistic or normal size based on settings, then adjust for stats layout + let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize + return statsAdjustedNotchSize( + from: baseSize, + isStatsTabActive: coordinator.currentView == .stats, + secondRowProgress: coordinator.statsSecondRowExpansion + ) } func ensureWindowSize(_ size: CGSize, animated: Bool, force: Bool = false) { diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index d2ed741..68c1c9f 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -48,7 +48,16 @@ struct ExpandedItem { class DynamicIslandViewCoordinator: ObservableObject { static let shared = DynamicIslandViewCoordinator() - @Published var currentView: NotchViews = .home + @Published var currentView: NotchViews = .home { + didSet { + handleStatsTabTransition(from: oldValue, to: currentView) + } + } + + @Published var statsSecondRowExpansion: CGFloat = 1 + private var statsSecondRowWorkItem: DispatchWorkItem? + private let statsSecondRowRevealDelay: TimeInterval = 0.5 + private let statsSecondRowAnimationDuration: TimeInterval = 0.3 @AppStorage("firstLaunch") var firstLaunch: Bool = true @@ -91,6 +100,27 @@ class DynamicIslandViewCoordinator: ObservableObject { private init() { selectedScreen = preferredScreen } + + private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) { + guard oldValue != newValue else { return } + statsSecondRowWorkItem?.cancel() + if newValue == .stats && Defaults[.enableStatsFeature] { + statsSecondRowExpansion = 0 + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + withAnimation(.easeInOut(duration: self.statsSecondRowAnimationDuration)) { + self.statsSecondRowExpansion = 1 + } + self.statsSecondRowWorkItem = nil + } + statsSecondRowWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + statsSecondRowRevealDelay, execute: workItem) + } else { + withAnimation(.easeInOut(duration: 0.2)) { + statsSecondRowExpansion = 0 + } + } + } func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") { let resolvedDuration: TimeInterval diff --git a/DynamicIsland/components/Notch/NotchStatsView.swift b/DynamicIsland/components/Notch/NotchStatsView.swift index 9bb3dd7..02ee976 100644 --- a/DynamicIsland/components/Notch/NotchStatsView.swift +++ b/DynamicIsland/components/Notch/NotchStatsView.swift @@ -67,6 +67,7 @@ struct NotchStatsView: View { @State private var statsHoverGraceActive = false @State private var statsHoverGraceWorkItem: DispatchWorkItem? @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared var availableGraphs: [GraphData] { var graphs: [GraphData] = [] @@ -148,18 +149,35 @@ struct NotchStatsView: View { } } } else if graphCount == 4 { - // 4 graphs: 2x2 grid - LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), - spacing: 12 - ) { - ForEach(0..= 4 && newValue < 0.999 + if shouldHold { + isResizingForStats = true + } else { + DispatchQueue.main.async { + isResizingForStats = false + } + } + } } + +private struct StatsCollapsibleRow: View { + let expansion: CGFloat + let height: CGFloat + let content: () -> Content + private let minVisibleHeight: CGFloat = 0.5 + + init(expansion: CGFloat, height: CGFloat, @ViewBuilder content: @escaping () -> Content) { + self.expansion = expansion + self.height = height + self.content = content + } + + var body: some View { + let clamped = max(0, min(expansion, 1)) + let targetHeight = clamped == 0 ? 0 : max(height * clamped, minVisibleHeight) + + return Group { + if targetHeight == 0 { + Color.clear.frame(height: 0) + } else { + content() + .frame(height: height, alignment: .top) + .clipped() + .mask( + Rectangle() + .frame(height: targetHeight) + .frame(maxWidth: .infinity, alignment: .top) + ) + .frame(height: targetHeight, alignment: .top) + } + } + .animation(.easeInOut(duration: 0.3), value: clamped) + } +} private func activateStatsHoverGrace(duration: TimeInterval = 0.45) { statsHoverGraceWorkItem?.cancel() statsHoverGraceActive = true diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index 8a346ab..8dd164d 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -110,6 +110,23 @@ class DynamicIslandViewModel: NSObject, ObservableObject { } } .store(in: &cancellables) + + coordinator.$statsSecondRowExpansion + .removeDuplicates() + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + guard self.notchState == .open else { return } + let updatedTarget = self.calculateDynamicNotchSize() + guard self.notchSize != updatedTarget else { return } + withAnimation(.easeInOut(duration: 0.3)) { + self.notchSize = updatedTarget + } + if let delegate = AppDelegate.shared { + delegate.ensureWindowSize(updatedTarget, animated: false, force: false) + } + } + .store(in: &cancellables) } private func setupDetectorObserver() { @@ -187,28 +204,12 @@ class DynamicIslandViewModel: NSObject, ObservableObject { private func calculateDynamicNotchSize() -> CGSize { // Use minimalistic size if minimalistic UI is enabled - var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize - - // Only apply dynamic sizing when on stats tab and stats are enabled - guard DynamicIslandViewCoordinator.shared.currentView == .stats && Defaults[.enableStatsFeature] else { - return baseSize - } - - let enabledGraphsCount = [ - Defaults[.showCpuGraph], - Defaults[.showMemoryGraph], - Defaults[.showGpuGraph], - Defaults[.showNetworkGraph], - Defaults[.showDiskGraph] - ].filter { $0 }.count - - // If 4+ graphs are enabled, increase width - if enabledGraphsCount >= 4 { - let extraWidth: CGFloat = CGFloat(enabledGraphsCount - 3) * 120 - return CGSize(width: baseSize.width + extraWidth, height: baseSize.height) - } - - return baseSize + let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize + return statsAdjustedNotchSize( + from: baseSize, + isStatsTabActive: DynamicIslandViewCoordinator.shared.currentView == .stats, + secondRowProgress: coordinator.statsSecondRowExpansion + ) } func close() { diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index 2e1ff7b..64f2723 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -17,6 +17,8 @@ let batterySneakSize: CGSize = .init(width: 160, height: 1) let openNotchSize: CGSize = .init(width: 640, height: 190) private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180) private let minimalisticLyricsExtraHeight: CGFloat = 40 +let statsSecondRowContentHeight: CGFloat = 120 +let statsGridSpacingHeight: CGFloat = 12 @MainActor var minimalisticOpenNotchSize: CGSize { @@ -37,6 +39,38 @@ var minimalisticOpenNotchSize: CGSize { let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14)) let minimalisticCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 35, bottom: 35), closed: cornerRadiusInsets.closed) +func statsAdjustedNotchSize( + from baseSize: CGSize, + isStatsTabActive: Bool, + secondRowProgress: CGFloat +) -> CGSize { + guard isStatsTabActive, Defaults[.enableStatsFeature] else { + return baseSize + } + + let enabledGraphsCount = [ + Defaults[.showCpuGraph], + Defaults[.showMemoryGraph], + Defaults[.showGpuGraph], + Defaults[.showNetworkGraph], + Defaults[.showDiskGraph] + ].filter { $0 }.count + + guard enabledGraphsCount >= 4 else { + return baseSize + } + + let clampedProgress = max(0, min(secondRowProgress, 1)) + guard clampedProgress > 0 else { + return baseSize + } + + var adjustedSize = baseSize + let extraHeight = (statsSecondRowContentHeight + statsGridSpacingHeight) * clampedProgress + adjustedSize.height += extraHeight + return adjustedSize +} + enum MusicPlayerImageSizes { static let cornerRadiusInset: (opened: CGFloat, closed: CGFloat) = (opened: 13.0, closed: 4.0) static let size = (opened: CGSize(width: 90, height: 90), closed: CGSize(width: 20, height: 20))