Repository navigation
Only redraw the list rows that changed on iOS - #152
Merged
Merged
Conversation
A new frame made SwiftUI's List run every row's content again, so a frame cost the same whether one row changed or none did. Each row was a chain of modifiers carrying closures (swipe actions, onAppear), and those never compare equal to last frame's. A row's node now goes in an Equatable view built from plain values, so a row whose node object is the one from the last frame (mobile-air's frame diff keeps those) is skipped. The insets, background, separators and swipe actions go on outside it, because the List can't see them inside an EquatableView. A list without sections also stops wrapping each row in the section check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp
force-pushed
the
perf/ios-list-row-identity
branch
from
October 22, 2026 17:01
c1a2784 to
d07c73a
Compare
simonhamp
marked this pull request as ready for review
October 22, 2026 17:08
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.
On iOS, a new frame for a long list cost about the same whether one row changed or none of the ones on screen did. SwiftUI's List runs the ForEach's content for every row whenever the list draws, and each row was a chain of modifiers carrying closures (swipe actions, onAppear). Those never compare equal to last frame's, so every row SwiftUI had made got updated again. In dev/null's Log that's every frame PHP sends while you scroll.
Each row's node now goes in an
Equatableview built from plain values (the node, the end-reached callback, the list's id). mobile-air's frame diff already hands over the old node object for a subtree that didn't change, so an unchanged row compares equal and SwiftUI skips it. I counted row bodies in a bench: one changed row in a 443-row list now re-evaluates 1 row instead of 443.What to watch for
EquatableView, and the List doesn't seelistRowInsets,listRowBackground,listRowSeparatororswipeActionsset inside one. My first try put them inside and rows drew with default insets and a second hairline. So those now go on outside it, inListRowTraitsandListRowSwipeActions, built from plain values too. Swipe actions are only attached to rows that have some..equatable()keeps the traits working too, and SwiftUI still uses==by itself, but it measured slower: about 16 ms for one changed row and 16 to 18 ms for a window move, against 12 and 14 to 15 with it.list_sectioncheck.row_colornow sit inListRowTraitswith the other List settings, the minimum row height is passed into the row as a value, and the pager-header branch builds its rows the same way. Add a sticky header that pins to the top of a list #138 and Report a list's visible rows, and draw placeholders at a row's last height #145 still change the same row code; row-level List settings belong inListRowTraits, as plain values.Measured
A bench app on an iPhone 16 Pro simulator (iOS 27.0) built from mobile-air's NativeRender and this repo's iOS renderers, with the PHP bridge stubbed. It replays real Log frames, dumped from dev/null's own PHP test harness, through the steps a phone frame takes after the flat buffer is read: fresh node objects, mobile-air's diff against the last tree, then
currentTreeon main. It reports main-thread busy time in the 0.7 s after each publish, with the list scrolled about 200 rows down. The list has 443 rows, about 290 drawn and the rest #145 placeholders.dev/null's branch (main + #145 etc.), median ms of 5 runs:
A page append changes the list's child count, and mobile-air's diff gives up on those, so every row arrives as a new object and this PR can't skip any. mobile-air#575 matches children by id instead.
On current main (with mobile-air#575 merged), one changed row goes from 24.9 to 14.4 ms and a page append from 33 to 19.6 ms. Window moves aren't meaningful there, since main has no
list_placeholderrenderer.What's left is SwiftUI and UIKit work that still grows with the number of rows: marking the ForEach's items dirty, walking them, and looking up separator settings for each visible cell. With the same Log at 1,062 rows (scrolled to row 400), one changed row went from 25.9 to 15.7 ms and a window move from about 35 to about 20 ms, so the cost still grows with the list, at about half the rate.
During a scroll: the bench can also scroll the Log at a steady 1,500 pt/s while standing in for PHP, publishing the frame RowWindow would send for each visible-range report. Profiled over 14 publishes in a 1,062-row list, SwiftUI's update for each publish went from about 22 ms to about 11.5 ms. Most late frames in that run come from rows being built as they scroll in (about 1.5 s of a 12 s scroll either way), which this PR doesn't touch.
Checked
row_colorset on a grouped and a plain list.vendor/bin/pestpasses.On Simon's iPhone
A release build of dev/null with this (ported onto #145) and mobile-air#575, traced while flinging the Log, iPhone 16 Pro, iOS 27.2. Looking only at the SwiftUI update each new frame triggers, against the same build without these two, per frame:
On the phone that update was already small, and row content inside it was 0.1 to 0.3 ms with or without this PR. Most of the main-thread time right after a frame is rows being built as they scroll in. It lands there because the visible-range report that asks for a frame fires as rows come on screen. In the bench, a steady scroll with a new frame every 0.4 s costs the same main-thread time as the same scroll with no frames at all, with or without this PR. Row build cost is what NativePHP/mobile-air#576 goes after.
Android isn't touched.
🤖 Generated with Claude Code