Skip to content

Commit 2bed845

Browse files
committed
fix: improve show all scrolling logic
1 parent b3d3bf0 commit 2bed845

3 files changed

Lines changed: 179 additions & 30 deletions

File tree

Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift

Lines changed: 167 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ struct VPNMenu<VPN: VPNService, FS: FileSyncDaemon>: View {
4343
}
4444
Divider()
4545
Text("Workspaces")
46-
.font(.headline)
46+
.font(.subheadline)
47+
.fontWeight(.bold)
4748
.foregroundColor(.secondary)
4849
VPNState<VPN>()
4950
}.padding([.horizontal, .top], Theme.Size.trayInset)

Coder-Desktop/Coder-DesktopTests/AgentsTests.swift

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,28 +67,27 @@ struct AgentsTests {
6767
}
6868

6969
@Test
70-
func showAllToggle() async throws {
70+
func showAllButton() async throws {
7171
vpn.state = .connected
7272
vpn.menuState = .init(agents: createMockAgents(count: 7))
7373

7474
try await ViewHosting.host(view) {
7575
try await sut.inspection.inspect { view in
76-
var toggle = try view.find(ViewType.Toggle.self)
76+
var button = try view.find(ViewType.Button.self)
7777
var forEach = try view.find(ViewType.ForEach.self)
7878
#expect(forEach.count == Theme.defaultVisibleAgents)
79-
#expect(try toggle.labelView().text().string() == "Show all")
80-
#expect(try !toggle.isOn())
79+
#expect(try button.labelView().find(text: "Show all").string() == "Show all")
8180

82-
try toggle.tap()
83-
toggle = try view.find(ViewType.Toggle.self)
81+
try button.tap()
82+
button = try view.find(ViewType.Button.self)
8483
forEach = try view.find(ViewType.ForEach.self)
8584
#expect(forEach.count == Theme.defaultVisibleAgents + 2)
86-
#expect(try toggle.labelView().text().string() == "Show less")
85+
#expect(try button.labelView().find(text: "Show less").string() == "Show less")
8786

88-
try toggle.tap()
89-
toggle = try view.find(ViewType.Toggle.self)
87+
try button.tap()
88+
button = try view.find(ViewType.Button.self)
9089
forEach = try view.find(ViewType.ForEach.self)
91-
#expect(try toggle.labelView().text().string() == "Show all")
90+
#expect(try button.labelView().find(text: "Show all").string() == "Show all")
9291
#expect(forEach.count == Theme.defaultVisibleAgents)
9392
}
9493
}
@@ -100,7 +99,7 @@ struct AgentsTests {
10099
vpn.menuState = .init(agents: createMockAgents(count: 3))
101100

102101
#expect(throws: (any Error).self) {
103-
_ = try view.inspect().find(ViewType.Toggle.self)
102+
_ = try view.inspect().find(ViewType.Button.self)
104103
}
105104
}
106105

0 commit comments

Comments
 (0)