Tried to fix the detach animation

This commit is contained in:
Hariharan Mudaliar 2026-01-28 20:14:04 +05:30
commit a1678afe0a
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
4 changed files with 46 additions and 201 deletions

View file

@ -162,10 +162,7 @@ struct ContentView: View {
enqueueMusicControlWindowSync(forceRefresh: forceRefresh, delay: delay) enqueueMusicControlWindowSync(forceRefresh: forceRefresh, delay: delay)
} }
private var dynamicNotchResizeAnimation: Animation? { private var dynamicNotchResizeAnimation: Animation? {
if enableMinimalisticUI && reminderManager.activeWindowReminders.isEmpty == false { nil
return nil
}
return .easeInOut(duration: 0.4)
} }
private let zeroHeightHoverPadding: CGFloat = 10 private let zeroHeightHoverPadding: CGFloat = 10
@ -206,14 +203,27 @@ struct ContentView: View {
var body: some View { var body: some View {
let interactionsEnabled = !lockScreenManager.isLocked let interactionsEnabled = !lockScreenManager.isLocked
let notchHorizontalPadding: CGFloat = {
guard vm.notchState == .open else {
return activeCornerRadiusInsets.closed.bottom
}
if Defaults[.cornerRadiusScaling] {
return activeCornerRadiusInsets.opened.top - 5
}
return activeCornerRadiusInsets.opened.bottom - 5
}()
let hoverAreaPadding: CGFloat = {
if vm.notchState == .open && Defaults[.extendHoverArea] {
return 0
}
return vm.effectiveClosedNotchHeight == 0 ? zeroHeightHoverPadding : 0
}()
let notchBottomPadding = currentShadowPadding + hoverAreaPadding
ZStack(alignment: .top) { ZStack(alignment: .top) {
let mainLayout = NotchLayout() let mainLayout = NotchLayout()
.frame(alignment: .top) .frame(alignment: .top)
.padding(.horizontal, vm.notchState == .open .padding(.horizontal, notchHorizontalPadding)
? Defaults[.cornerRadiusScaling] ? (activeCornerRadiusInsets.opened.top - 5) : (activeCornerRadiusInsets.opened.bottom - 5)
: activeCornerRadiusInsets.closed.bottom
)
.padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0)
.background(.black) .background(.black)
.clipShape(currentNotchShape) .clipShape(currentNotchShape)
@ -224,23 +234,15 @@ struct ContentView: View {
: .clear, : .clear,
radius: Defaults[.cornerRadiusScaling] ? 10 : 5 radius: Defaults[.cornerRadiusScaling] ? 10 : 5
) )
.padding(.bottom, .padding(.bottom, notchBottomPadding)
currentShadowPadding + (
vm.notchState == .open && Defaults[.extendHoverArea]
? 0
: (vm.effectiveClosedNotchHeight == 0 ? zeroHeightHoverPadding : 0)
)
)
mainLayout mainLayout
.conditionalModifier(!useModernCloseAnimation) { view in .conditionalModifier(!useModernCloseAnimation) { view in
let hoverAnimation = Animation.bouncy.speed(1.2) let hoverAnimation = Animation.bouncy.speed(1.2)
let notchStateAnimation = Animation.spring.speed(1.2) let notchStateAnimation = Animation.spring.speed(1.2)
let viewTransitionAnimation = Animation.easeInOut(duration: 0.4)
return view return view
.animation(hoverAnimation, value: isHovering) .animation(hoverAnimation, value: isHovering)
.animation(notchStateAnimation, value: vm.notchState) .animation(notchStateAnimation, value: vm.notchState)
.animation(viewTransitionAnimation, value: coordinator.currentView)
.animation(.smooth, value: gestureProgress) .animation(.smooth, value: gestureProgress)
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
} }
@ -248,12 +250,10 @@ struct ContentView: View {
let hoverAnimation = Animation.bouncy.speed(1.2) let hoverAnimation = Animation.bouncy.speed(1.2)
let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0) let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0)
let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0) let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0)
let viewTransitionAnimation = Animation.easeInOut(duration: 0.4)
let notchAnimation = vm.notchState == .open ? openAnimation : closeAnimation let notchAnimation = vm.notchState == .open ? openAnimation : closeAnimation
return view return view
.animation(hoverAnimation, value: isHovering) .animation(hoverAnimation, value: isHovering)
.animation(notchAnimation, value: vm.notchState) .animation(notchAnimation, value: vm.notchState)
.animation(viewTransitionAnimation, value: coordinator.currentView)
.animation(.smooth, value: gestureProgress) .animation(.smooth, value: gestureProgress)
} }
.conditionalModifier(interactionsEnabled) { view in .conditionalModifier(interactionsEnabled) { view in
@ -382,13 +382,10 @@ struct ContentView: View {
maxHeight: dynamicNotchSize.height + currentShadowPadding, maxHeight: dynamicNotchSize.height + currentShadowPadding,
alignment: .top alignment: .top
) )
.animation(dynamicNotchResizeAnimation, value: dynamicNotchSize)
.animation(.easeInOut(duration: 0.4), value: coordinator.currentView)
.environmentObject(privacyManager) .environmentObject(privacyManager)
.onChange(of: dynamicNotchSize) { oldSize, newSize in .onChange(of: dynamicNotchSize) { oldSize, newSize in
guard oldSize != newSize else { return } guard oldSize != newSize else { return }
let delay: TimeInterval = dynamicNotchResizeAnimation == nil ? 0.25 : 1.0 runAfter(0.1) {
runAfter(delay) {
vm.shouldRecheckHover.toggle() vm.shouldRecheckHover.toggle()
} }
} }
@ -738,14 +735,9 @@ struct ContentView: View {
} }
} }
} }
.transition(.asymmetric(
insertion: .scale(scale: 0.8).combined(with: .opacity).animation(.easeInOut(duration: 0.4)),
removal: .scale(scale: 0.8).combined(with: .opacity).animation(.easeInOut(duration: 0.4))
))
.id(coordinator.currentView) // Force SwiftUI to treat each view as unique .id(coordinator.currentView) // Force SwiftUI to treat each view as unique
} }
} }
.animation(.easeInOut(duration: 0.4), value: coordinator.currentView)
.zIndex(1) .zIndex(1)
.allowsHitTesting(vm.notchState == .open) .allowsHitTesting(vm.notchState == .open)
.blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0) .blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0)

View file

@ -210,6 +210,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
defer: false defer: false
) )
window.animationBehavior = .none
window.contentView = NSHostingView( window.contentView = NSHostingView(
rootView: ContentView() rootView: ContentView()
.environmentObject(viewModel) .environmentObject(viewModel)
@ -254,6 +256,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
resizeWindows(to: requiredSize, animated: animateResize, force: false) resizeWindows(to: requiredSize, animated: animateResize, force: false)
} }
private func updateWindowSizeForTabSwitch() {
let requiredSize = calculateRequiredNotchSize()
resizeWindows(to: requiredSize, animated: false, force: true)
}
private func calculateRequiredNotchSize() -> CGSize { private func calculateRequiredNotchSize() -> CGSize {
// Check if inline sneak peek is showing and notch is closed // Check if inline sneak peek is showing and notch is closed
let isInlineSneakPeekActive = vm.notchState == .closed && let isInlineSneakPeekActive = vm.notchState == .closed &&
@ -321,17 +328,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let newY = screenFrame.origin.y + screenFrame.height - size.height let newY = screenFrame.origin.y + screenFrame.height - size.height
let targetFrame = NSRect(x: newX, y: newY, width: size.width, height: size.height) let targetFrame = NSRect(x: newX, y: newY, width: size.width, height: size.height)
if animated {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.25
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
context.allowsImplicitAnimation = true
window.animator().setFrame(targetFrame, display: true)
}
} else {
window.setFrame(targetFrame, display: true) window.setFrame(targetFrame, display: true)
} }
}
private func shouldAnimateResize(for newSize: CGSize) -> Bool { private func shouldAnimateResize(for newSize: CGSize) -> Bool {
if Defaults[.enableMinimalisticUI] && !ReminderLiveActivityManager.shared.activeWindowReminders.isEmpty { if Defaults[.enableMinimalisticUI] && !ReminderLiveActivityManager.shared.activeWindowReminders.isEmpty {
@ -370,9 +368,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Setup Privacy Indicator Manager (camera and microphone monitoring) // Setup Privacy Indicator Manager (camera and microphone monitoring)
PrivacyIndicatorManager.shared.startMonitoring() PrivacyIndicatorManager.shared.startMonitoring()
// Observe tab changes - use immediate updates to prevent clipping // Observe tab changes - use immediate resize to keep the notch pinned
coordinator.$currentView.sink { [weak self] newView in coordinator.$currentView.sink { [weak self] _ in
self?.updateWindowSizeIfNeeded() self?.updateWindowSizeForTabSwitch()
}.store(in: &cancellables) }.store(in: &cancellables)
coordinator.$notesLayoutState coordinator.$notesLayoutState

View file

@ -102,9 +102,6 @@ class DynamicIslandViewCoordinator: ObservableObject {
} }
@Published var statsSecondRowExpansion: CGFloat = 1 @Published var statsSecondRowExpansion: CGFloat = 1
private var statsSecondRowWorkItem: DispatchWorkItem?
private let statsSecondRowRevealDelay: TimeInterval = 0.5
private let statsSecondRowAnimationDuration: TimeInterval = 0.3
@Published var notesLayoutState: NotesLayoutState = .list @Published var notesLayoutState: NotesLayoutState = .list
@Published var selectedExtensionExperienceID: String? @Published var selectedExtensionExperienceID: String?
@ -206,22 +203,8 @@ class DynamicIslandViewCoordinator: ObservableObject {
private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) { private func handleStatsTabTransition(from oldValue: NotchViews, to newValue: NotchViews) {
guard oldValue != newValue else { return } guard oldValue != newValue else { return }
statsSecondRowWorkItem?.cancel()
if newValue == .stats && Defaults[.enableStatsFeature] { if newValue == .stats && Defaults[.enableStatsFeature] {
statsSecondRowExpansion = 0 statsSecondRowExpansion = 1
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
}
} }
} }

View file

@ -63,9 +63,6 @@ struct NotchStatsView: View {
@State private var isHoveringGPUPopover = false @State private var isHoveringGPUPopover = false
@State private var isHoveringNetworkPopover = false @State private var isHoveringNetworkPopover = false
@State private var isHoveringDiskPopover = false @State private var isHoveringDiskPopover = false
@State private var isResizingForStats = false
@State private var statsHoverGraceActive = false
@State private var statsHoverGraceWorkItem: DispatchWorkItem?
@EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ -149,8 +146,8 @@ struct NotchStatsView: View {
} }
} }
} else if graphCount == 4 { } else if graphCount == 4 {
// 4 graphs: second row scales from zero height on tab open // 4 graphs: two rows (2x2) without a collapsible expansion
VStack(spacing: 0) { VStack(spacing: statsGridSpacingHeight) {
LazyVGrid( LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
spacing: 12 spacing: 12
@ -159,11 +156,6 @@ struct NotchStatsView: View {
graphViewForIndex(index) graphViewForIndex(index)
} }
} }
StatsCollapsibleRow(
expansion: coordinator.statsSecondRowExpansion,
height: statsSecondRowContentHeight + statsGridSpacingHeight
) {
LazyVGrid( LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
spacing: 12 spacing: 12
@ -172,13 +164,10 @@ struct NotchStatsView: View {
graphViewForIndex(index) graphViewForIndex(index)
} }
} }
.padding(.top, statsGridSpacingHeight)
}
} }
} else { } else {
// 5 graphs: First row 3 graphs, second row 2 graphs (half-width each) // 5 graphs: First row 3 graphs, second row 2 graphs (half-width each)
VStack(spacing: 0) { VStack(spacing: statsGridSpacingHeight) {
// First row: 3 graphs
LazyVGrid( LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3),
spacing: 8 spacing: 8
@ -187,11 +176,6 @@ struct NotchStatsView: View {
graphViewForIndex(index) graphViewForIndex(index)
} }
} }
// Second row: 2 graphs (half-width each)
StatsCollapsibleRow(
expansion: coordinator.statsSecondRowExpansion,
height: statsSecondRowContentHeight + statsGridSpacingHeight
) {
LazyVGrid( LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
spacing: 8 spacing: 8
@ -200,8 +184,6 @@ struct NotchStatsView: View {
graphViewForIndex(index) graphViewForIndex(index)
} }
} }
.padding(.top, statsGridSpacingHeight)
}
} }
} }
} }
@ -382,68 +364,26 @@ struct NotchStatsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { .onAppear {
// Note: Smart monitoring will handle starting/stopping based on notch state and current view // Note: Smart monitoring will handle starting/stopping based on notch state and current view
// Protect against hover interference during view transition
isResizingForStats = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
isResizingForStats = false
}
} }
.onDisappear { .onDisappear {
statsHoverGraceWorkItem?.cancel()
statsHoverGraceWorkItem = nil
if statsHoverGraceActive {
statsHoverGraceActive = false
}
// Keep monitoring running when tab is not visible // Keep monitoring running when tab is not visible
updateStatsPopoverState() updateStatsPopoverState()
} }
.animation(.easeInOut(duration: 0.4), value: enableStatsFeature) .animation(.easeInOut(duration: 0.4), value: enableStatsFeature)
.animation(.easeInOut(duration: 0.4), value: availableGraphs.count) .animation(.easeInOut(duration: 0.4), value: availableGraphs.count)
.onChange(of: availableGraphs.count) { _, _ in
// Protect against hover interference during dynamic sizing
isResizingForStats = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
isResizingForStats = false
}
}
.onChange(of: showingCPUPopover) { _, newValue in .onChange(of: showingCPUPopover) { _, newValue in
if newValue {
activateStatsHoverGrace()
} else {
deactivateStatsHoverGrace()
}
updateStatsPopoverState() updateStatsPopoverState()
} }
.onChange(of: showingMemoryPopover) { _, newValue in .onChange(of: showingMemoryPopover) { _, newValue in
if newValue {
activateStatsHoverGrace()
} else {
deactivateStatsHoverGrace()
}
updateStatsPopoverState() updateStatsPopoverState()
} }
.onChange(of: showingGPUPopover) { _, newValue in .onChange(of: showingGPUPopover) { _, newValue in
if newValue {
activateStatsHoverGrace()
} else {
deactivateStatsHoverGrace()
}
updateStatsPopoverState() updateStatsPopoverState()
} }
.onChange(of: showingNetworkPopover) { _, newValue in .onChange(of: showingNetworkPopover) { _, newValue in
if newValue {
activateStatsHoverGrace()
} else {
deactivateStatsHoverGrace()
}
updateStatsPopoverState() updateStatsPopoverState()
} }
.onChange(of: showingDiskPopover) { _, newValue in .onChange(of: showingDiskPopover) { _, newValue in
if newValue {
activateStatsHoverGrace()
} else {
deactivateStatsHoverGrace()
}
updateStatsPopoverState() updateStatsPopoverState()
} }
.onChange(of: isHoveringCPUPopover) { _, _ in .onChange(of: isHoveringCPUPopover) { _, _ in
@ -461,76 +401,10 @@ struct NotchStatsView: View {
.onChange(of: isHoveringDiskPopover) { _, _ in .onChange(of: isHoveringDiskPopover) { _, _ in
updateStatsPopoverState() updateStatsPopoverState()
} }
.onChange(of: coordinator.statsSecondRowExpansion) { _, newValue in
let shouldHold = availableGraphs.count >= 4 && newValue < 0.999
if shouldHold {
isResizingForStats = true
} else {
DispatchQueue.main.async {
isResizingForStats = false
} }
}
}
}
private struct StatsCollapsibleRow<Content: View>: 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
let workItem = DispatchWorkItem {
statsHoverGraceActive = false
statsHoverGraceWorkItem = nil
updateStatsPopoverState()
}
statsHoverGraceWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: workItem)
}
private func deactivateStatsHoverGrace() {
statsHoverGraceWorkItem?.cancel()
statsHoverGraceWorkItem = nil
if statsHoverGraceActive {
statsHoverGraceActive = false
}
}
private func updateStatsPopoverState() { private func updateStatsPopoverState() {
let anyPopoverOpen = showingCPUPopover || showingMemoryPopover || showingGPUPopover || showingNetworkPopover || showingDiskPopover let anyPopoverOpen = showingCPUPopover || showingMemoryPopover || showingGPUPopover || showingNetworkPopover || showingDiskPopover
let newState = anyPopoverOpen || isResizingForStats || statsHoverGraceActive let newState = anyPopoverOpen
if vm.isStatsPopoverActive != newState { if vm.isStatsPopoverActive != newState {
vm.isStatsPopoverActive = newState vm.isStatsPopoverActive = newState
#if DEBUG #if DEBUG
@ -540,8 +414,6 @@ private struct StatsCollapsibleRow<Content: View>: View {
print(" GPU open=\(showingGPUPopover)") print(" GPU open=\(showingGPUPopover)")
print(" Network open=\(showingNetworkPopover)") print(" Network open=\(showingNetworkPopover)")
print(" Disk open=\(showingDiskPopover)") print(" Disk open=\(showingDiskPopover)")
print(" Resizing: \(isResizingForStats)")
print(" Hover grace: \(statsHoverGraceActive)")
#endif #endif
} }
} }