@@ -3,153 +3,120 @@ import SwiftUI
33struct DynamicIslandView : View {
44 @ObservedObject var vm : NotchViewModel
55
6- // The width of the actual physical notch
76 private var notchWidth : CGFloat { vm. geometry. notchSize. width }
8- // The height of the physical notch
97 private var notchHeight : CGFloat { vm. geometry. notchSize. height }
10-
11- // Animation for equalizer
12- @State private var phase : CGFloat = 0
138
149 var body : some View {
15- ZStack {
16- if vm. timer. state == . running {
17- timerPill
18- } else if vm. media. isPlaying {
19- mediaPill
20- } else if let weather = vm. weather. weather {
21- weatherPill ( weather)
10+ // Single right-side wing that merges with the notch edge.
11+ // By placing the pill inside a wider-than-notch frame and aligning it
12+ // to trailing, we guarantee it sits flush against the notch's right
13+ // edge regardless of how wide the notch is on a given Mac.
14+ HStack ( spacing: 0 ) {
15+ Spacer ( minLength: 0 )
16+
17+ if pillKind != nil {
18+ activePill
19+ . transition ( . asymmetric(
20+ insertion: . move( edge: . trailing) . combined ( with: . opacity) ,
21+ removal: . move( edge: . trailing) . combined ( with: . opacity)
22+ ) )
2223 }
2324 }
24- . frame ( width: notchWidth, height: notchHeight) // Center aligns with notch
25- . animation ( . spring( response: 0.4 , dampingFraction: 0.7 ) , value: vm . media . isPlaying || vm . timer . state == . running || vm . weather . weather != nil )
25+ . frame ( width: notchWidth + 120 , height: notchHeight)
26+ . animation ( . spring( response: 0.35 , dampingFraction: 0.8 ) , value: pillKind )
2627 }
2728
29+ // MARK: - Active Pill
30+
2831 @ViewBuilder
29- private var timerPill : some View {
30- if vm . geometry . isPhysical {
31- HStack {
32- Spacer ( )
32+ private var activePill : some View {
33+ switch pillKind {
34+ case . timer :
35+ pillBody {
3336 Image ( systemName: " timer " )
34- . font ( . system( size: 10 , weight: . medium ) )
37+ . font ( . system( size: 10 , weight: . semibold ) )
3538 . foregroundColor ( Theme . tertiary)
36- . padding ( . trailing, 10 )
37- }
38- . frame ( width: 44 , height: notchHeight)
39- . background ( Color . black)
40- . clipShape ( RoundedRectangle ( cornerRadius: notchHeight / 2 , style: . continuous) )
41- . offset ( x: - notchWidth / 2 - 22 + 8 )
42-
43- HStack {
4439 Text ( vm. timer. formattedTime)
45- . font ( . system( size: 10 , weight: . medium) . monospacedDigit ( ) )
46- . foregroundColor ( Color . white)
40+ . font ( . system( size: 11 , weight: . medium) . monospacedDigit ( ) )
41+ . foregroundColor ( . white)
4742 . lineLimit ( 1 )
4843 . fixedSize ( )
49- . padding ( . leading, 8 )
50- Spacer ( )
51- }
52- . frame ( width: 56 , height: notchHeight)
53- . background ( Color . black)
54- . clipShape ( RoundedRectangle ( cornerRadius: notchHeight / 2 , style: . continuous) )
55- . offset ( x: notchWidth / 2 + 28 - 8 )
56- } else {
57- HStack ( spacing: 8 ) {
58- Image ( systemName: " timer " )
59- . font ( . system( size: 10 , weight: . medium) )
60- . foregroundColor ( Theme . tertiary)
61- Text ( vm. timer. formattedTime)
62- . font ( . system( size: 10 , weight: . medium) . monospacedDigit ( ) )
63- . foregroundColor ( Color . white)
6444 }
65- . padding ( . horizontal, 12 )
66- . frame ( height: notchHeight)
67- . background ( Color . black)
68- . clipShape ( RoundedRectangle ( cornerRadius: notchHeight / 2 , style: . continuous) )
69- }
70- }
71-
72- @ViewBuilder
73- private var mediaPill : some View {
74- if vm. geometry. isPhysical {
75- // Left Wing (Equalizer / Icon)
76- HStack {
77- Spacer ( ) // push to right edge of the left wing
45+ case . media:
46+ pillBody {
7847 EqualizerBars ( isAnimating: true )
79- . padding ( . trailing, 10 )
80- }
81- . frame ( width: 44 , height: notchHeight)
82- . background ( Color . black)
83- . clipShape ( RoundedRectangle ( cornerRadius: notchHeight / 2 , style: . continuous) )
84- . offset ( x: - notchWidth / 2 - 22 + 8 ) // Overlap slightly to merge with notch
85-
86- // Right Wing (Timer / Source)
87- HStack {
8848 if vm. media. track != nil {
8949 Text ( formatTime ( vm. media. position) )
90- . font ( . system( size: 10 , weight: . medium) . monospacedDigit ( ) )
91- . foregroundColor ( Color . white)
92- . padding ( . leading, 10 )
50+ . font ( . system( size: 11 , weight: . medium) . monospacedDigit ( ) )
51+ . foregroundColor ( . white)
52+ . lineLimit ( 1 )
53+ . fixedSize ( )
9354 }
94- Spacer ( )
9555 }
96- . frame ( width: 44 , height: notchHeight)
97- . background ( Color . black)
98- . clipShape ( RoundedRectangle ( cornerRadius: notchHeight / 2 , style: . continuous) )
99- . offset ( x: notchWidth / 2 + 22 - 8 )
100- } else {
101- // Unified Pill for non-notched screens
102- HStack ( spacing: 8 ) {
103- EqualizerBars ( isAnimating: true )
104-
105- if vm. media. track != nil {
106- Text ( formatTime ( vm. media. position) )
107- . font ( . system( size: 10 , weight: . medium) . monospacedDigit ( ) )
108- . foregroundColor ( Color . white)
56+ case . weather:
57+ if let w = vm. weather. weather {
58+ pillBody {
59+ Image ( systemName: weatherSymbol ( for: w. condition) )
60+ . font ( . system( size: 10 , weight: . semibold) )
61+ . foregroundColor ( . white)
62+ Text ( String ( format: " %.0f° " , w. temperature) )
63+ . font ( . system( size: 11 , weight: . semibold) )
64+ . foregroundColor ( . white)
65+ . lineLimit ( 1 )
66+ . fixedSize ( )
10967 }
11068 }
111- . padding ( . horizontal, 12 )
112- . frame ( height: notchHeight)
113- . background ( Color . black)
114- . clipShape ( RoundedRectangle ( cornerRadius: notchHeight / 2 , style: . continuous) )
69+ case . none:
70+ EmptyView ( )
11571 }
11672 }
117-
73+
74+ // MARK: - Shared Pill Chrome
75+
76+ private func pillBody< Content: View > ( @ViewBuilder content: ( ) -> Content ) -> some View {
77+ HStack ( spacing: 6 ) {
78+ content ( )
79+ }
80+ . padding ( . horizontal, 10 )
81+ . frame ( height: notchHeight)
82+ . background ( Color . black)
83+ . clipShape ( RoundedRectangle ( cornerRadius: notchHeight / 2 , style: . continuous) )
84+ }
85+
86+ // MARK: - Pill Priority
87+
88+ private enum PillKind : Equatable {
89+ case timer, media, weather
90+ }
91+
92+ private var pillKind : PillKind ? {
93+ if vm. timer. state == . running { return . timer }
94+ if vm. media. isPlaying { return . media }
95+ if vm. weather. weather != nil { return . weather }
96+ return nil
97+ }
98+
99+ // MARK: - Helpers
100+
118101 private func formatTime( _ seconds: Double ) -> String {
119102 let mins = Int ( seconds) / 60
120103 let secs = Int ( seconds) % 60
121104 return String ( format: " %d:%02d " , mins, secs)
122105 }
123106
124- @ViewBuilder
125- private func weatherPill( _ weather: WeatherData ) -> some View {
126- if vm. geometry. isPhysical {
127- // Right Wing (Weather)
128- HStack {
129- Text ( String ( format: " %.0f° " , weather. temperature) )
130- . font ( . system( size: 11 , weight: . semibold) )
131- . foregroundColor ( Color . white)
132- . padding ( . leading, 12 )
133- Spacer ( )
134- }
135- . frame ( width: 44 , height: notchHeight)
136- . background ( Color . black)
137- . clipShape ( RoundedRectangle ( cornerRadius: notchHeight / 2 , style: . continuous) )
138- . offset ( x: notchWidth / 2 + 22 - 8 )
139- } else {
140- // Unified Pill
141- HStack ( spacing: 4 ) {
142- Image ( systemName: " cloud.sun.fill " )
143- . font ( . system( size: 10 , weight: . medium) )
144- . foregroundColor ( . white)
145- Text ( String ( format: " %.0f° " , weather. temperature) )
146- . font ( . system( size: 11 , weight: . semibold) )
147- . foregroundColor ( Color . white)
148- }
149- . padding ( . horizontal, 12 )
150- . frame ( height: notchHeight)
151- . background ( Color . black)
152- . clipShape ( RoundedRectangle ( cornerRadius: notchHeight / 2 , style: . continuous) )
107+ private func weatherSymbol( for code: Int ) -> String {
108+ switch code {
109+ case 0 : return " sun.max.fill "
110+ case 1 , 2 : return " cloud.sun.fill "
111+ case 3 : return " cloud.fill "
112+ case 45 , 48 : return " cloud.fog.fill "
113+ case 51 ... 57 : return " cloud.drizzle.fill "
114+ case 61 ... 67 : return " cloud.rain.fill "
115+ case 71 ... 77 : return " cloud.snow.fill "
116+ case 80 ... 82 : return " cloud.heavyrain.fill "
117+ case 85 , 86 : return " cloud.snow.fill "
118+ case 95 , 96 , 99 : return " cloud.bolt.fill "
119+ default : return " cloud.sun.fill "
153120 }
154121 }
155122}
0 commit comments