-
-
Notifications
You must be signed in to change notification settings - Fork 65
Layout system rewrite #278
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Conversation
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
33d1f41 to
66334b3
Compare
66334b3 to
10e3ee5
Compare
Some of the bad behaviour from SwiftUI's layout system can probably be mitigated, but other undesirable behaviour is probably the best we can do, cause some things are impossible to compute nicely without laying out child views more than once (a no go for performant layout).
Some backends (such as AppKitBackend) trigger window resize handlers before the underlying window's size gets updated. Our existing code assumed that it should set a window's size if the proposed window size didn't match the underlying window's size, but in this case that led to an infinite update loop.
10e3ee5 to
f56b479
Compare
This led to situations where the last accessed cached layout would replace the real current layout and then the layout system would commit a cached layout instead of the current layout, leading to strange results. I noticed this bug when testing StressTestExample. Open the app and then click Generate without interacting with the window in any way beforehand to reproduce the bug (resizing the window first seems to stop the bug). I only tried reproducing the bug on macOS with AppKitBackend.
The frame modifier had undesirable behaviour for ideal-size proposals. You'd end up with a child taking on its ideal size and being much smaller than the frame's clamped bounds, making it impossible to grow views in scroll views. ScrollView didn't grow to use up available space along scrolling axes. TextEditor didn't grow to use up available width. Used GeometryReader to grow the TextEditor to at least fill the ScrollView which used to (somewhat erroneously) be the default behaviour.
f56b479 to
fab1020
Compare
72390ec to
dd6dcd2
Compare
First unit test using DummyBackend!
stackotter
added a commit
that referenced
this pull request
Jan 2, 2026
#278 broke UIKitBackend's Text implementation when switching from UILabel to a custom text rendering UIView implementation. It didn't override its NSTextContainer's lineFragmentPadding, so the text content got rendered with a horizontal offset and ended up clipping even when there was available space.
This was referenced Jan 2, 2026
Merged
stackotter
added a commit
that referenced
this pull request
Jan 2, 2026
#278 broke UIKitBackend's Text implementation when switching from UILabel to a custom text rendering UIView implementation. It didn't override its NSTextContainer's lineFragmentPadding, so the text content got rendered with a horizontal offset and ended up clipping even when there was available space.
stackotter
added a commit
that referenced
this pull request
Jan 9, 2026
We weren't updating the button before measuring its naturalSize, and we were using the button's width instead of its height when positioning its popover.
stackotter
added a commit
that referenced
this pull request
Jan 9, 2026
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.
This PR addresses SwiftCrossUI's biggest layout performance issues. It contains quite a few structural changes that pave the way for future optimisations as well.
Optimisations
Splitting View.update into View.computeLayout and View.commit
Before this PR, View had a single update-related method,
View.update, which had two modes: dry run, and non dry run. Dry runs were used to probe the sizes of views without actually committing the layout to the underlying widgets. This let us cache layout results (because we didn't have to worry about keeping the widgets in sync during dry run updates). Then when the final layout was decided, a non dry run update would happen, during which the layout would be committed to the underlying widgets. This architecture was inefficient because the final non dry run update would effectively have to compute the entire layout again, leading to a lot of doubling up of work.My solution was to split
View.updateinto two separate requirements;View.computeLayoutandView.commit.View.computeLayoutcomputes layouts as usual without committing anything to the underlying widgets, and thenView.commitcan be called with the result ofView.computeLayoutto efficiently commit the last computed layout of the view and all of its children.This lead to a 7.7x improvement for the
gridbenchmark, and a 2.8x improvement for themessage listbenchmark.The main reason that this was so effective is that during the
commitphase, each view gets handed its last computed result which it can blindly trust, as opposed to each view having to recompute its desired size.Update layout algorithm to match SwiftUI
The layout system's biggest layout problem prior to this PR was that its stack layout algorithm required computing the layout of each child view multiple times. This led to exponentially worse layout performance as the amount of nesting increased. My caching system managed to curb the exponential growth in some situations, but it was easy to render the caching useless.
After much thinking, I discovered a few unavoidable approximations that we'd have to adopt if we were to avoid computing the layout of each child view multiple times per stack layout pass. I created some edge cases that would behave differently depending on whether or not SwiftUI used these approximations I had landed on. SwiftUI uses all of them.
In my opinion, these approximations lead to less desirable behaviour than SwiftCrossUI's previous layout system, but I don't think that we can handle all of the aforementioned edge cases well without keeping our serious performance issues. There are likely other sets of approximations that would lead to similar performance, but given that SwiftUI already uses these approximations, and that I arrived at these approximations independently, I decided that they're our best option.
Adopting said approximations went hand in hand with updating
ViewSizeto be a simple size type (rather than tracking minimum size, maximum size, ideal size, etc), and our proposed view sizes to be 2d vectors ofDouble?.Together, adopting the approximations and updating simplifying our
ViewSizetype allowed us to reduce our effective child layout passes per stack layout pass to 1. I say effective layout passes, because we still query the child multiple times for its minimum, maximum, and final sizes, but together those roughly equate to the same amount of work as a single layout pass would have if we still had our oldViewSizetype. The key to making it work out like so is that querying the minimum, maximum or ideal size of a stack layout now only requires computing the minimum, maximum or ideal sizes of its children respectively. This means that minimum, maximum and ideal size requests are linear in the number of views in the stack's view hierarchy. Additionally, our probing child layout requests enableenvironment.allowLayoutCaching, so any given view only ends up computing its minimum, maximum or ideal size once during a given update cycle. This means that even though our stack layout algorithm technically queries each child multiple times, the minimum and maximum size requests are free if any parent view has already computed them, meaning that we effectively only query each child once.This lead to a 4x improvement for the
gridbenchmark, but somehow made ourmessage listbenchmark twice as bad. I haven't properly investigated why that happened, but that'd be a good place to start for future performance work, as it would probably help us figure out exactly what became slower when introducing this new layout system.Layout system changes
Any
VStack(or height-specific) behaviours described here apply toHStacks as well. I'm just being lazy.commitstep. This isn't ideal, but it's what SwiftUI does, and it lets us keep the effective branching factor of our stack layout algorithm at 1.minHeightframe is proposed an unspecified height, it may end up with a different width to its child. The child gets proposed an unspecified height, then the frame clamps the resulting height and assumes that the child will keep its reported width. During commit, the frame will lay out the child again with the clamped height, and the child may end up growing or shrinking horizontally. This means that the unconstrained axis of a frame may end up not hugging its content even though it has no reason not to. This less than ideal behaviour is to keep our branching factor at 1.Using a custom Mirror replacement
I noticed that we were spending about half of each layout update in
Mirror-related code. I've had a Mirror optimisation idea in the back of my head for a while so I gave it a go, and it basically eliminated Mirror overhead for stateless views (1500x faster), and made updating dynamic properties (e.g.@Stateproperties) 5-10x faster for views with a <16 dynamic properties. The new system still usesMirrorduring ViewGraphNode creation, but it uses a custom technique to infer the offset of each dynamic property discovered by the mirror. We can then reuse the offsets for the rest of the ViewGraphNode's lifecycle, and we cache the offsets for each type in a global look up table to reduceMirrorusage even further.I've documented this system quite thoroughly in
Sources/SwiftCrossUI/State/DynamicPropertyUpdater.swift, so if you wanna know more about how it works, give that a read.This made both benchmarks (
gridandmessage list) twice as fast.Benchmarks
All benchmarking has been done on my M2 MacBook Air with 8gb of RAM.
The
gridbenchmark is now 61x faster.The
message listbenchmark is now 4x faster (it didn't benefit as much from our branching factor reductions because it has less nesting).@bbrk24's private DiscordBotGUI app now has 29x faster window resize handling, and a synthetic benchmark based of the core of its performance issues is 11.34x faster.
Future directions
ed24b0fa(splitting View.update into computeLayout and commit) ->e4daa213(adopting SwiftUI's layout approximations) made themessage listbenchmark twice as slow. It may help us figure out inefficiencies in the new layout system.TupleViewChildrendoes a bunch of work computing information related to the state snapshotting system, even when no snapshots are present.Tasks