Skip to content

Report a list's visible rows, and draw placeholders at a row's last height - #145

Merged
simonhamp merged 3 commits into
mainfrom
feat/list-visible-range-placeholders
Oct 22, 2026
Merged

simonhamp merged 3 commits into
mainfrom
feat/list-visible-range-placeholders

Conversation

@simonhamp

@simonhamp simonhamp commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

A long list can now send only the rows near the screen and placeholders for the rest, without anything on screen moving.

on-visible-range on <native:list> calls the method with the positions of the first and last rows on screen, each time either end has moved by visible-range-step rows (10 by default). Rows are counted the way end-reached counts them, with a section's rows in their place. It's registered with the virtual list's virtual_window callback kind and sends the same "first,last" payload, so the method gets two ints and core needs no change.

<native:list on-visible-range="showRows" :visible-range-step="20">

<native:list-placeholder native:key="..."> is an empty row drawn at the height the list last measured for the row with the same key. A node's id comes from its key and its parent's, so the placeholder takes the row's place in the list instead of being a new row. Lists measure their rows while they report their visible range. A row the list never measured takes estimated-height, or the average of the rows it has measured. Outside a list it draws nothing.

Why: the dev/null app's Log keeps every commit it loads. Scrolled to 1,520 commits on an iPhone 16 Pro, jetsam killed it at a 3.4 GB footprint. With this, the app draws the rows near the screen and placeholders for the rest (NativePHP/dev-null-app#51). Simulator, same canned API, #144 and NativePHP/mobile-air#561 in both. Before is main with #144:

commits footprint before after PHP before after quiet render before after
200 451 MB 421 MB 72.5 MB 72.5 MB 34 ms 28 ms
600 500 MB 452 MB 106.5 MB 80.5 MB 76 ms 34 ms
1,200 663 MB (at 1,440) 487 MB 154.5 MB 88.5 MB 213 ms (at 1,440) 33 ms
2,000 524 MB 104.5 MB 40 ms

The app holds 6,000 to 8,000 NativeUINodes from 200 to 2,000 commits. Frames stay small too: at 1,800 commits the app's frame is 5,813 nodes with placeholders and 61,741 without. On the phone, the frame without them reached 104,482 nodes at 1,800 commits and went over EDGE's 16 MB frame buffer. With #144 alone it held 37,679 at 1,200.

Things to know when reviewing:

  • iOS: when an off-screen row's content changes, SwiftUI's List drops its size to its 52pt estimate and doesn't measure it again until it's displayed. That already happens to any off-screen row whose content changes (a relative time ticking over does it), and placeholders make it happen to a few dozen rows each time the app moves its window. UIKit moves the content offset for the rows above, so nothing on screen moves: screenshots before and after moving the window by 40 rows are pixel-identical, light and dark, and frame-by-frame motion while scrolling back up through those rows matches the same list with no placeholders. The content height and scroll indicator do change. Keeping sizes exact would need a UIKit list.
  • iOS: every publish makes the rows on screen fire onDisappear and then onAppear, so the tracker reads the range 50ms after it moves (no more than every 150ms). Reporting straight away named a single row. It counts each row's views, because a row and its placeholder are different views and the new one can appear first.
  • iOS: a very fast fling skips rows altogether, so they're never laid out or measured. Their placeholders take the average.
  • A status-bar tap from deep in a long list animates through placeholders, so the list is blank for the moment it takes to reach the top.
  • Android: LazyColumn keeps its place by the first visible item, so off-screen rows changing size don't move anything, and the height cache only matters for a placeholder that's on screen. Compiled in the dev/null app, not run on a device or emulator.
  • Measuring is an onGeometryChange per row on iOS and an onSizeChanged box per row on Android, only while on-visible-range is set.
  • The new row closures (measuring, on screen, off screen) follow Stop iOS list rows from keeping old copies of the whole list #144: static helpers build them from the row's id and the list's height cache or tracker, never the list or its nodes. Neither object holds nodes.
  • Not part of this PR: most of what still grows natively is mobile-air's shadow thread autoreleasing parse objects into a pool it never drains. Wrapping each frame in autoreleasepool kept Swift strings flat (1.7k at 200 commits and 1.5k at 2,000, against 156k without it). That goes in its own mobile-air PR.

Tests: tests/ListTest.php covers the attributes, the callback kind, and a placeholder getting the id of the row whose key it carries.

🤖 Generated with Claude Code

@simonhamp
simonhamp added this pull request to stack #148 October 21, 2026 12:16
@simonhamp
simonhamp marked this pull request as ready for review October 21, 2026 14:57
Base automatically changed from fix/list-row-closures-retain-list to main October 21, 2026 14:58
@simonhamp
simonhamp force-pushed the feat/list-visible-range-placeholders branch from 718e85a to 9fd7ad6 Compare October 21, 2026 15:06
simonhamp added a commit that referenced this pull request Oct 21, 2026
…ull-app

# Conflicts:
#	nativephp.json
#	resources/android/ListRenderer.kt
#	resources/ios/NativeUIListRenderer.swift
simonhamp added a commit to NativePHP/dev-null-app that referenced this pull request Oct 21, 2026
Bumps nativephp/mobile-ui to combined/dev-null-app ebbc94b, which has
NativePHP/mobile-ui#145.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp added a commit to NativePHP/dev-null-app that referenced this pull request Oct 21, 2026
* Draw only the Log's rows near the screen

The Log keeps every commit it loads, and drew every one of them as a row.
Scrolled 1,500 commits down on an iPhone 16 Pro, PHP held 256 MB and the
app was killed by jetsam at a 3.4 GB footprint. Each load-more render cost
more than the last.

Now the log you're on draws the first ten rows and the rows near those on
screen: 150 the way you're scrolling, 100 the way you came. Every other
row goes as a <native:list-placeholder> with the row's own key, which the
phone draws empty at the height it last drew that row. The list reports
which rows are on screen as you scroll (on-visible-range, every 20 rows),
and RowWindow keeps that by commit, not by position, so commits arriving
above don't move it. RowCache already lets go of rows a frame doesn't draw.

Two more things grew with every commit: Timeline::feed worked out each
commit's day twice, and the timeline was laid out again on every render.
It's now once per commit, and only when the log's commits or the day change.

Needs NativePHP/mobile-ui's on-visible-range and list-placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Install the list that reports its visible rows and draws placeholders

Bumps nativephp/mobile-ui to combined/dev-null-app ebbc94b, which has
NativePHP/mobile-ui#145.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp and others added 3 commits October 22, 2026 19:33
…eight

A list with on-visible-range calls its method with the positions of the
first and last rows on screen, counted as end-reached counts them, each
time either end moves by visible-range-step rows (10 by default). It
reuses the virtual list's "from,to" payload and callback kind, so the
method gets two ints and core needs no change.

<native:list-placeholder> is an empty row the list draws at the height it
last measured for the row with the same key. Same key, same node id, so
an app can send only the rows near the screen and placeholders for the
rest. Rows are measured while the list reports its visible range. A row
it never measured takes estimated-height, or the list's average row.

On iOS a new frame takes every row on screen off it and puts it back, so
the range is read 50ms after it moves, and no more than every 150ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…first

A row and its placeholder are different views. When one replaces the
other on screen, SwiftUI can call onAppear for the new view before
onDisappear for the old one, and a set of row ids then lost the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The closures that measure a row and tell the tracker it's on screen now
come from static helpers given the row's id and the list's cache or
tracker, as endReachedAction does. SwiftUI keeps a row's closures until
the row draws again, so none of them should reach the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simonhamp
simonhamp force-pushed the feat/list-visible-range-placeholders branch from 9fd7ad6 to fe4fdc9 Compare October 22, 2026 18:39
@simonhamp
simonhamp merged commit 634ec04 into main Oct 22, 2026
4 checks passed
@simonhamp
simonhamp deleted the feat/list-visible-range-placeholders branch October 22, 2026 18:45
simonhamp added a commit that referenced this pull request Oct 22, 2026
Brings in #149 (with #151), #152 and the final #145, plus everything else
merged to main since #144. The four conflicts were all in files where
combined's own changes (#145, #149, custom icons, the pager header and the
sticky header) are now on main in their final form, so main's version wins.
Combined still carries text-outline (#142) and the Android sheet handle on
top of main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant