Repository navigation
Report a list's visible rows, and draw placeholders at a row's last height - #145
Merged
Merged
Conversation
simonhamp
marked this pull request as ready for review
October 21, 2026 14:57
simonhamp
force-pushed
the
feat/list-visible-range-placeholders
branch
from
October 21, 2026 15:06
718e85a to
9fd7ad6
Compare
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>
This was referenced Oct 21, 2026
…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
force-pushed
the
feat/list-visible-range-placeholders
branch
from
October 22, 2026 18:39
9fd7ad6 to
fe4fdc9
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A long list can now send only the rows near the screen and placeholders for the rest, without anything on screen moving.
on-visible-rangeon<native:list>calls the method with the positions of the first and last rows on screen, each time either end has moved byvisible-range-steprows (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'svirtual_windowcallback kind and sends the same"first,last"payload, so the method gets two ints and core needs no change.<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 takesestimated-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:
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:
Listdrops 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.onDisappearand thenonAppear, 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.LazyColumnkeeps 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.onGeometryChangeper row on iOS and anonSizeChangedbox per row on Android, only whileon-visible-rangeis set.autoreleasepoolkept 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.phpcovers the attributes, the callback kind, and a placeholder getting the id of the row whose key it carries.🤖 Generated with Claude Code