@@ -6,7 +6,8 @@ struct Agents<VPN: VPNService>: View {
66 @State private var viewAll = false
77 @State private var expandedItem : VPNMenuItem . ID ?
88 @State private var hasToggledExpansion : Bool = false
9- private let defaultVisibleRows = 5
9+ @State private var scrollState = ScrollAffordanceState ( )
10+ private let defaultVisibleRows = Theme . defaultVisibleAgents
1011
1112 let inspection = Inspection < Self > ( )
1213
@@ -17,15 +18,29 @@ struct Agents<VPN: VPNService>: View {
1718 let items = vpn. menuState. sorted
1819 let visibleItems = viewAll ? items [ ... ] : items. prefix ( defaultVisibleRows)
1920 ScrollView ( showsIndicators: false ) {
20- ForEach ( visibleItems, id: \. id) { agent in
21- MenuItemView (
22- item: agent,
23- baseAccessURL: state. baseAccessURL!,
24- expandedItem: $expandedItem,
25- userInteracted: $hasToggledExpansion
26- )
27- . padding ( . horizontal, Theme . Size. trayMargin)
28- } . onChange ( of: visibleItems) {
21+ VStack ( spacing: 3 ) {
22+ ForEach ( visibleItems, id: \. id) { agent in
23+ MenuItemView (
24+ item: agent,
25+ baseAccessURL: state. baseAccessURL!,
26+ expandedItem: $expandedItem,
27+ userInteracted: $hasToggledExpansion
28+ )
29+ . padding ( . horizontal, Theme . Size. trayMargin)
30+ }
31+ }
32+ . background {
33+ GeometryReader { contentProxy in
34+ Color . clear. preference (
35+ key: ScrollMetricsPreferenceKey . self,
36+ value: ScrollMetrics (
37+ contentMinY: contentProxy. frame ( in: . named( " agentsScroll " ) ) . minY,
38+ contentHeight: contentProxy. size. height
39+ )
40+ )
41+ }
42+ }
43+ . onChange ( of: visibleItems) {
2944 // If no workspaces are online, we should expand the first one to come online
3045 if visibleItems. filter ( { $0. status != . off } ) . isEmpty {
3146 hasToggledExpansion = false
@@ -40,7 +55,31 @@ struct Agents<VPN: VPNService>: View {
4055 hasToggledExpansion = true
4156 }
4257 }
58+ . background {
59+ GeometryReader { containerProxy in
60+ Color . clear. preference (
61+ key: ScrollMetricsPreferenceKey . self,
62+ value: ScrollMetrics ( containerHeight: containerProxy. size. height)
63+ )
64+ }
65+ }
66+ . coordinateSpace ( name: " agentsScroll " )
67+ . mask {
68+ if viewAll {
69+ ScrollAffordanceMask ( state: scrollState)
70+ } else {
71+ Color . white
72+ }
73+ }
74+ . overlay {
75+ if viewAll, scrollState. showsTopAffordance || scrollState. showsBottomAffordance {
76+ ScrollAffordanceChevronOverlay ( state: scrollState)
77+ }
78+ }
4379 . scrollBounceBehavior ( . basedOnSize)
80+ . onPreferenceChange ( ScrollMetricsPreferenceKey . self) { metrics in
81+ scrollState = ScrollAffordanceState ( metrics: metrics)
82+ }
4483 . frame ( maxHeight: 400 )
4584 if items. count == 0 {
4685 Text ( " No workspaces! " )
@@ -49,17 +88,127 @@ struct Agents<VPN: VPNService>: View {
4988 . padding ( . horizontal, Theme . Size. trayInset)
5089 . padding ( . top, 2 )
5190 }
52- // Only show the toggle if there are more items to show
5391 if items. count > defaultVisibleRows {
54- Toggle ( isOn: $viewAll) {
55- Text ( viewAll ? " Show less " : " Show all " )
56- . font ( . headline)
57- . foregroundColor ( . secondary)
58- . padding ( . horizontal, Theme . Size. trayInset)
59- . padding ( . top, 2 )
60- } . toggleStyle ( . button) . buttonStyle ( . plain)
92+ Button {
93+ viewAll. toggle ( )
94+ } label: {
95+ ButtonRowView {
96+ HStack ( spacing: Theme . Size. trayPadding) {
97+ Text ( viewAll ? " Show less " : " Show all " )
98+ . font ( . subheadline)
99+ . fontWeight ( . bold)
100+ Spacer ( )
101+ Image ( systemName: viewAll ? " chevron.up " : " chevron.right " )
102+ . font ( . system( size: 10 , weight: . medium) )
103+ }
104+ . frame ( maxWidth: . infinity, alignment: . leading)
105+ }
106+ }
107+ . padding ( . horizontal, Theme . Size. trayMargin)
108+ . buttonStyle ( . plain)
61109 }
62110 }
63111 } . onReceive ( inspection. notice) { inspection. visit ( self , $0) } // ViewInspector
64112 }
65113}
114+
115+ private struct ScrollAffordanceState : Equatable {
116+ var showsTopAffordance = false
117+ var showsBottomAffordance = false
118+
119+ init ( ) { }
120+
121+ init ( metrics: ScrollMetrics ) {
122+ let contentHeight = metrics. contentHeight
123+ let containerHeight = metrics. containerHeight
124+ let offsetY = max ( - metrics. contentMinY, 0 )
125+ let canScroll = contentHeight > containerHeight + 1
126+
127+ guard canScroll else { return }
128+
129+ showsTopAffordance = offsetY > 1
130+ showsBottomAffordance = offsetY + containerHeight < contentHeight - 1
131+ }
132+ }
133+
134+ private struct ScrollMetrics : Equatable {
135+ var contentMinY : CGFloat = 0
136+ var contentHeight : CGFloat = 0
137+ var containerHeight : CGFloat = 0
138+ }
139+
140+ private struct ScrollMetricsPreferenceKey : PreferenceKey {
141+ static let defaultValue = ScrollMetrics ( )
142+
143+ static func reduce( value: inout ScrollMetrics , nextValue: ( ) -> ScrollMetrics ) {
144+ let next = nextValue ( )
145+ if next. contentHeight != 0 {
146+ value. contentMinY = next. contentMinY
147+ value. contentHeight = next. contentHeight
148+ }
149+ if next. containerHeight != 0 {
150+ value. containerHeight = next. containerHeight
151+ }
152+ }
153+ }
154+
155+ private struct ScrollAffordanceMask : View {
156+ let state : ScrollAffordanceState
157+
158+ var body : some View {
159+ VStack ( spacing: 0 ) {
160+ ScrollAffordanceMaskEdge ( direction: . top, isVisible: state. showsTopAffordance)
161+ Color . white
162+ ScrollAffordanceMaskEdge ( direction: . bottom, isVisible: state. showsBottomAffordance)
163+ }
164+ . allowsHitTesting ( false )
165+ }
166+ }
167+
168+ private struct ScrollAffordanceChevronOverlay : View {
169+ let state : ScrollAffordanceState
170+
171+ var body : some View {
172+ VStack ( spacing: 0 ) {
173+ if state. showsTopAffordance {
174+ Image ( systemName: " chevron.up " )
175+ . font ( . system( size: 10 , weight: . semibold) )
176+ . foregroundStyle ( . secondary)
177+ . padding ( . top, 2 )
178+ }
179+ Spacer ( minLength: 0 )
180+ if state. showsBottomAffordance {
181+ Image ( systemName: " chevron.down " )
182+ . font ( . system( size: 10 , weight: . semibold) )
183+ . foregroundStyle ( . secondary)
184+ . padding ( . bottom, 2 )
185+ }
186+ }
187+ . allowsHitTesting ( false )
188+ }
189+ }
190+
191+ private struct ScrollAffordanceMaskEdge : View {
192+ enum Direction {
193+ case top
194+ case bottom
195+ }
196+
197+ let direction : Direction
198+ let isVisible : Bool
199+
200+ var body : some View {
201+ Group {
202+ if isVisible {
203+ LinearGradient (
204+ colors: direction == . top ? [ . clear, . white] : [ . white, . clear] ,
205+ startPoint: . top,
206+ endPoint: . bottom
207+ )
208+ } else {
209+ Color . white
210+ }
211+ }
212+ . frame ( height: 16 )
213+ }
214+ }
0 commit comments