Bind custom Layout {} primitive (closes #144)#208
Open
jonathanpeppers wants to merge 1 commit into
Open
Conversation
There was a problem hiding this comment.
Pull request overview
This PR binds Compose's low-level Layout primitive, enabling custom measure-and-place logic from C#. It adds a hand-written Layout facade (justified as a holdout since no existing [ComposeFacade] phase models the custom delegate ctor / mutable-Body JCW pattern), six new public types to bridge the measure/place DSL, a java.lang.reflect.Proxy-based Java helper to work around Kotlin's mangled MeasurePolicy interface method, and a gallery demo + JetNews adaptive layout sample.
Changes:
- New public types:
Constraintsstruct (packed-long wrapper with JNI accessors),Measurable,MeasureScope,MeasureResult,PlacementScope, and theLayoutfacade — all with XML docs and public API tracking entries. - JNI bridges in
ComposeBridges.csfor six Constraints accessors,MeasurableMeasure,MeasureScopeLayout,PlacementScopePlace/PlaceRelative, plusMeasurePolicyFactory.javausingProxy.newProxyInstanceto sidestep the mangled interface method. - Gallery
CustomLayoutDemo(shortest-column adaptive flow) and JetNewsInterestsScreenupgrade from single-column fallback to a real two-column adaptive layout via the newLayoutprimitive.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
src/ComposeNet.Compose/Layout.cs |
Hand-written facade: ctor takes measure-policy delegate, Render caches proxy+lambda via Remember, delegates to bound LayoutKt.Layout. |
src/ComposeNet.Compose/Constraints.cs |
readonly struct wrapping Compose's packed long; accessor properties bridge stripped @JvmInline getters via JNI. |
src/ComposeNet.Compose/Measurable.cs |
Handle wrapper with Measure(Constraints) → Placeable via JNI bridge. |
src/ComposeNet.Compose/MeasureScope.cs |
Handle wrapper with Layout(w, h, placementBlock) → MeasureResult. |
src/ComposeNet.Compose/MeasureResult.cs |
Opaque Java.Lang.Object subclass returned from MeasureScope.Layout. |
src/ComposeNet.Compose/PlacementScope.cs |
Handle wrapper exposing Place and PlaceRelative via JNI bridges. |
src/ComposeNet.Compose/PlacementBlockLambda.cs |
JCW IFunction1 adapter wrapping user's Action<PlacementScope>. |
src/ComposeNet.Compose/MeasurePolicyLambda.cs |
JCW IFunction3 adapter with mutable Body; stable JNI identity across recompositions. |
src/ComposeNet.Compose/Java/MeasurePolicyFactory.java |
Proxy.newProxyInstance helper that dispatches mangled measure-3p2s80s to the Function3 block. |
src/ComposeNet.Compose/ComposeBridges.cs |
Hand-written JNI bridges for Constraints accessors, Measurable.measure, MeasureScope.layout, PlacementScope.place/placeRelative, and generated MeasurePolicyFactoryCreate. |
src/ComposeNet.Compose/ComposeDefaults.cs |
LayoutDefault declarative [ComposeDefaults] (content+measurePolicy required, modifier optional). |
src/ComposeNet.Compose/PublicAPI.Unshipped.txt |
All new public symbols registered. |
src/ComposeNet.Compose/ComposeNet.Compose.csproj |
MeasurePolicyFactory.java added as AndroidJavaSource. |
src/ComposeNet.Gallery/Demos/Containers/CustomLayoutDemo.cs |
Gallery demo: shortest-column adaptive multi-column card layout. |
src/ComposeNet.Gallery/Registry/Catalog.cs |
Demo registration under Containers. |
samples/JetNews/InterestsScreen.cs |
Topics tab upgraded to two-column adaptive layout via Layout; vertical scroll added. |
.github/copilot-instructions.md |
Layout added to hand-written holdout list with justification. |
docs/api-coverage.md |
Regenerated: coverage from 352→359 symbols; Layout marked covered. |
jonathanpeppers
added a commit
that referenced
this pull request
Jun 9, 2026
Two changes in one — the second is forced by the rebase against #213: 1. Drop redundant try/finally + GC.KeepAlive(holder) around LayoutKt.Layout (review comment on #208). Bound binding calls already emit GC.KeepAlive for every IJavaPeerable arg; holder.Policy is rooted by that, and the holder itself by the slot table. 2. Rebase fix-ups against the ComposeNet -> Microsoft.AndroidX.Compose rename (#213) + IDE0001/IDE0005 enforcement (#212): - namespace ComposeNet -> namespace Microsoft.AndroidX.Compose in all new files (Constraints, Layout, Measurable, MeasurePolicyLambda, MeasureResult, MeasureScope, PlacementBlockLambda, PlacementScope, CustomLayoutDemo). - Compose.Remember -> ComposeRuntime.Remember (the static class was renamed to disambiguate from the namespace). - using AndroidX.X -> using global::AndroidX.X (and same for Android.Runtime). Inside namespace Microsoft.AndroidX.Compose, an unqualified `AndroidX` resolves to the parent `Microsoft.AndroidX` itself, shadowing the global root. - scripts/api-comparison.cs: suppress IDE0005 since file-based programs don't honor implicit usings the same way as SDK-style project files, so the System.* usings are genuinely required. - docs/api-coverage.md regenerated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
f91cf59 to
f49da27
Compare
Adds an AndroidX.Compose facade over Compose's low-level Layout {} primitive — wraps content + a measurePolicy callback (Measurable list + Constraints → MeasureResult) so callers can measure children and place them at custom coordinates.
JetNews InterestsAdaptiveContentLayout uses this to bin topic rows into N columns based on parent width.
Includes a Gallery CustomLayoutDemo, hand-written JNI bridges in ComposeBridges.cs (with a tiny MeasurePolicyFactory.java helper for the Proxy.newProxyInstance side), and PublicAPI entries.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
f49da27 to
bc5409e
Compare
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.
Closes #144.
Adds a hand-written facade for Compose's low-level
androidx.compose.ui.layout.Layoutso callers can measure children and place them at custom positions — unlocking adaptive layouts that re-balance across breakpoints (the JetNewsInterestsAdaptiveContentcase), and the building blocks for intrinsic-size / staircase / bubble-tail style policies.Public surface
Constraints,Measurable,MeasureScope,MeasureResult,PlacementScopeare all new public types;Placeablereuses the existing boundAndroidX.Compose.UI.Layout.Placeable.Why
Layoutis a hand-written holdoutNone of the existing
[ComposeFacade]phases model a ctor that takes a userFunc<MeasureScope, IReadOnlyList<Measurable>, Constraints, MeasureResult>delegate plus a JCW with a mutableBodyre-bound on every recomposition. Adding a Phase 11 just for this single facade isn't worth the generator complexity..github/copilot-instructions.mdalready listsLayoutas a hand-written holdout in the appropriate section.Why a
Constraintsstruct (vs the bound type)androidx.compose.ui.unit.Constraintsis a Kotlin@JvmInline value class. The class itself is bound (AndroidX.Compose.UI.Unit.ConstraintsinXamarin.AndroidX.Compose.UI.Unit.Android.dll), but every instance accessor (getMinWidth-impl(J)I,getHasBoundedWidth-impl(J)Z, …) is mangled with a value-class hash suffix, which the binder strips. So callers can't ask the bound type forMinWidth/MaxWidth/HasBoundedWidth.The wrapper
ComposeNet.Constraintsstruct holds the packedlongand bridges the stripped accessors via raw JNI — usingJava.Lang.Class.FromType(typeof(AndroidX.Compose.UI.Unit.Constraints))for the class lookup so we still go through the binding's pre-cached peer registration. Construction goes through the boundConstraintsKt.Constraints(int, int, int, int) → longfactory. Once dotnet/java-interop#1440 lands, the struct can collapse to direct calls.Proxy.newProxyInstanceJava helperMeasurePolicy.measure-3p2s80sis the interface's only abstract method, and Kotlin's mangling means Java source can't@Overrideit (no legal Java identifier). SoJava/MeasurePolicyFactory.javabuilds ajava.lang.reflect.ProxywhoseInvocationHandlerforwards any method name starting withmeasureto the JCWFunction3<MeasureScope, List<Measurable>, Long, MeasureResult>. The intrinsic-size defaults onMeasurePolicy(minIntrinsicWidth/maxIntrinsicHeight/etc.) are inherited from the interface's default methods.The proxy + JCW lambda are cached via
Compose.Rememberso JNI identity stays stable across recompositions; Compose keys its measure cache on the policy instance, so a fresh proxy every pass would thrash that cache.Bound types used directly
Where possible the implementation uses bound types instead of raw JNI:
Java.Util.Collections.EmptyMap()forMeasureScope.layout's alignmentLines (Kotlin-checked-non-null).Java.Util.IList.Size()/.Get(i)for unpacking theList<Measurable>parameter.((Java.Lang.Long)p2).LongValue()for unboxing the Constraints param.MeasureResultderives fromJava.Lang.Objectso the JNI marshaller round-trips it as a native peer (no manualGetObject).Java.Lang.Class.FromType(typeof(...))for class lookup wherever the type is bound (Constraints, IMeasurable) — falls back toJNIEnv.FindClassonly forMeasureScope/Placeable$PlacementScope, which aren't bound.JetNews port
InterestsAdaptiveContentLayoutswitches from a single-column fallback to a real two-column adaptive layout at a 600 px breakpoint. AverticalScrollwas added on the topics column to match upstream's behaviour — landscape content overflows the viewport. This is a JetNews port detail (matching upstream Compose JetNews), not a Layout-binding requirement; the binding itself is independent of any scroll wrapping.Verification
Pixel 5, both orientations:
CustomLayoutDemo(shortest-column flow): renders 4 columns portrait, 9 columns landscape.verticalScrollrevealing all 4 sections (Android / .NET / Tooling / Architecture).Proxy.newProxyInstance+JavaCast<IMeasurePolicy>()exercised on every recomposition pass.Screenshots will be attached as a follow-up comment.
Build status
dotnet test src/ComposeNet.SourceGenerators.Tests— 112/112 pass.dotnet build src/ComposeNet.Compose— clean.dotnet build src/ComposeNet.Gallery— clean.dotnet build samples/JetNews -t:Install— clean, deployed.docs/api-coverage.mdregenerated —Layoutnow[x] Layout(4) → type match.