|
237 | 237 | withExtendedLifetime((zero, window)) {} |
238 | 238 | } |
239 | 239 |
|
| 240 | + final class States { |
| 241 | + var byRow: [String: Set<UUID>] = [:] |
| 242 | + } |
| 243 | + |
| 244 | + /// A row that records the state it's drawn with: one whose view starts |
| 245 | + /// anew has a new one. |
| 246 | + struct StatefulRow: View { |
| 247 | + let id: String |
| 248 | + let states: States |
| 249 | + @State private var state = UUID() |
| 250 | + |
| 251 | + var body: some View { |
| 252 | + let _ = states.byRow[id, default: []].insert(state) |
| 253 | + Text(id).frame(height: 30) |
| 254 | + } |
| 255 | + } |
| 256 | + |
| 257 | + @Test func aKeptRowComesBackWithItsState() async throws { |
| 258 | + let (zero, list) = try makeList() |
| 259 | + let states = States() |
| 260 | + let window = show( |
| 261 | + VirtualList(list) { StatefulRow(id: $0.row.id, states: states) }.maxKeptRows(300)) |
| 262 | + let top = (0..<10).map(Self.id) |
| 263 | + try await run(30) { top.allSatisfy { states.byRow[$0] != nil } } |
| 264 | + list.scrollToItem(Self.id(250), align: .end) |
| 265 | + try await run(30) { list.lastVisibleItem()?.key == Self.id(250) } |
| 266 | + try await run(0.5) { false } |
| 267 | + #expect(!list.snapshot.items.contains { $0.key == Self.id(0) }) |
| 268 | + list.scrollToItem(Self.id(0), align: .start) |
| 269 | + try await run(30) { list.firstVisibleItem()?.key == Self.id(0) } |
| 270 | + try await run(0.5) { false } |
| 271 | + #expect(top.map { states.byRow[$0]?.count } == top.map { _ in 1 }) |
| 272 | + withExtendedLifetime((zero, window)) {} |
| 273 | + } |
| 274 | + |
| 275 | + /// Row views that record what the layout asks of them: plain views, |
| 276 | + /// 30 points high. |
| 277 | + @MainActor final class CountingRowViews: RowViews { |
| 278 | + /// Each row shown in a view, and its state. |
| 279 | + var shown: [(key: AnyHashable, state: RowState)] = [] |
| 280 | + var measured = 0 |
| 281 | + |
| 282 | + func view( |
| 283 | + for item: RowsItem, _ state: RowState, reusing view: PlatformView?, in layout: RowsLayout |
| 284 | + ) -> PlatformView { |
| 285 | + shown.append((item.key, state)) |
| 286 | + return view ?? PlatformView(frame: .zero) |
| 287 | + } |
| 288 | + |
| 289 | + func height(of view: PlatformView, width: Double) -> Double { |
| 290 | + measured += 1 |
| 291 | + return 30 |
| 292 | + } |
| 293 | + |
| 294 | + func discard(_ view: PlatformView) {} |
| 295 | + } |
| 296 | + |
| 297 | + /// A layout of 30-point rows, with no frames after its passes: what a |
| 298 | + /// pass leaves for the next frame doesn't run. |
| 299 | + func countingLayout(keeping maxKeptRows: Int) -> (RowsLayout, CountingRowViews) { |
| 300 | + let views = CountingRowViews() |
| 301 | + let rows = RowsLayout(views: views) |
| 302 | + rows.host.estimatedRowHeight = { 30 } |
| 303 | + rows.nextFrame = { _ in } |
| 304 | + rows.maxKeptRows = maxKeptRows |
| 305 | + return (rows, views) |
| 306 | + } |
| 307 | + |
| 308 | + /// A pass with rows `range` scrolled to at `width`, with `changed` |
| 309 | + /// rows' data changed: the rows it showed in a view. |
| 310 | + @discardableResult |
| 311 | + func pass( |
| 312 | + _ range: Range<Int>, in rows: RowsLayout, _ views: CountingRowViews, width: Double = 300, |
| 313 | + changed: Set<Int> = [] |
| 314 | + ) -> [AnyHashable] { |
| 315 | + views.shown = [] |
| 316 | + views.measured = 0 |
| 317 | + rows.update( |
| 318 | + RowsSource( |
| 319 | + items: range.map { |
| 320 | + RowsItem( |
| 321 | + key: AnyHashable(Self.id($0)), index: $0, row: changed.contains($0) ? -1 : $0, |
| 322 | + item: $0) |
| 323 | + }, spaceBefore: Double(range.lowerBound) * 30)) |
| 324 | + _ = rows.layout(width: width, offset: Double(range.lowerBound) * 30, viewport: 400) |
| 325 | + return views.shown.map(\.key) |
| 326 | + } |
| 327 | + |
| 328 | + // Rows that page out keep their views, up to `maxKeptRows`, and come |
| 329 | + // back in them, neither shown nor measured again; 0 keeps none. |
| 330 | + @Test(arguments: [0, 5, 100]) |
| 331 | + func keepsTheViewsOfRowsThatLeave(maxKeptRows: Int) { |
| 332 | + let (rows, views) = countingLayout(keeping: maxKeptRows) |
| 333 | + let first = pass(0..<20, in: rows, views) |
| 334 | + pass(100..<120, in: rows, views) |
| 335 | + let back = pass(0..<20, in: rows, views) |
| 336 | + let anew = first.count - min(maxKeptRows, first.count) |
| 337 | + #expect(first.filter(back.contains).count == anew) |
| 338 | + #expect(views.measured == anew) |
| 339 | + } |
| 340 | + |
| 341 | + // A kept row comes back as it is now: with its new data, in its |
| 342 | + // selection, and measured at the list's width, all changed while it was |
| 343 | + // kept. |
| 344 | + @Test func aKeptRowComesBackAsItIsNow() throws { |
| 345 | + let (rows, views) = countingLayout(keeping: 100) |
| 346 | + let first = pass(0..<20, in: rows, views) |
| 347 | + pass(100..<120, in: rows, views) |
| 348 | + rows.selected = AnyHashable(Self.id(3)) |
| 349 | + pass(100..<120, in: rows, views, width: 250) |
| 350 | + let back = pass(0..<20, in: rows, views, width: 250, changed: [1]) |
| 351 | + #expect(Set(back) == [AnyHashable(Self.id(1)), AnyHashable(Self.id(3))]) |
| 352 | + let selected = try #require(views.shown.first { $0.key == AnyHashable(Self.id(3)) }) |
| 353 | + #expect(selected.state.selected) |
| 354 | + #expect(views.measured == first.count) |
| 355 | + } |
| 356 | + |
240 | 357 | @Test func scrollsToARowFarBelow() async throws { |
241 | 358 | let (zero, list) = try makeList() |
242 | 359 | let window = show(VirtualList(list) { Text($0.row.text).frame(height: 30) }) |
|
0 commit comments