Skip to content

Bind custom Layout {} primitive (closes #144)#208

Open
jonathanpeppers wants to merge 1 commit into
mainfrom
jonathanpeppers/layout-primitive-144
Open

Bind custom Layout {} primitive (closes #144)#208
jonathanpeppers wants to merge 1 commit into
mainfrom
jonathanpeppers/layout-primitive-144

Conversation

@jonathanpeppers

@jonathanpeppers jonathanpeppers commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Closes #144.

Adds a hand-written facade for Compose's low-level androidx.compose.ui.layout.Layout so callers can measure children and place them at custom positions — unlocking adaptive layouts that re-balance across breakpoints (the JetNews InterestsAdaptiveContent case), and the building blocks for intrinsic-size / staircase / bubble-tail style policies.

Public surface

new Layout((scope, measurables, constraints) =>
{
    var placeables = new Placeable[measurables.Count];
    int totalHeight = 0;
    for (int i = 0; i < measurables.Count; i++)
    {
        placeables[i] = measurables[i].Measure(
            Constraints.Create(0, constraints.MaxWidth, 0, int.MaxValue));
        totalHeight += placeables[i].Height;
    }
    return scope.Layout(constraints.MaxWidth, totalHeight, placement =>
    {
        int y = 0;
        foreach (var p in placeables) { placement.Place(p, 0, y); y += p.Height; }
    });
}) { /* children… */ }

Constraints, Measurable, MeasureScope, MeasureResult, PlacementScope are all new public types; Placeable reuses the existing bound AndroidX.Compose.UI.Layout.Placeable.

Why Layout is a hand-written holdout

None of the existing [ComposeFacade] phases model a ctor that takes a user Func<MeasureScope, IReadOnlyList<Measurable>, Constraints, MeasureResult> delegate plus a JCW with a mutable Body re-bound on every recomposition. Adding a Phase 11 just for this single facade isn't worth the generator complexity. .github/copilot-instructions.md already lists Layout as a hand-written holdout in the appropriate section.

Why a Constraints struct (vs the bound type)

androidx.compose.ui.unit.Constraints is a Kotlin @JvmInline value class. The class itself is bound (AndroidX.Compose.UI.Unit.Constraints in Xamarin.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 for MinWidth/MaxWidth/HasBoundedWidth.

The wrapper ComposeNet.Constraints struct holds the packed long and bridges the stripped accessors via raw JNI — using Java.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 bound ConstraintsKt.Constraints(int, int, int, int) → long factory. Once dotnet/java-interop#1440 lands, the struct can collapse to direct calls.

Proxy.newProxyInstance Java helper

MeasurePolicy.measure-3p2s80s is the interface's only abstract method, and Kotlin's mangling means Java source can't @Override it (no legal Java identifier). So Java/MeasurePolicyFactory.java builds a java.lang.reflect.Proxy whose InvocationHandler forwards any method name starting with measure to the JCW Function3<MeasureScope, List<Measurable>, Long, MeasureResult>. The intrinsic-size defaults on MeasurePolicy (minIntrinsicWidth/maxIntrinsicHeight/etc.) are inherited from the interface's default methods.

The proxy + JCW lambda are cached via Compose.Remember so 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() for MeasureScope.layout's alignmentLines (Kotlin-checked-non-null).
  • Java.Util.IList.Size() / .Get(i) for unpacking the List<Measurable> parameter.
  • ((Java.Lang.Long)p2).LongValue() for unboxing the Constraints param.
  • MeasureResult derives from Java.Lang.Object so the JNI marshaller round-trips it as a native peer (no manual GetObject).
  • Java.Lang.Class.FromType(typeof(...)) for class lookup wherever the type is bound (Constraints, IMeasurable) — falls back to JNIEnv.FindClass only for MeasureScope / Placeable$PlacementScope, which aren't bound.

JetNews port

InterestsAdaptiveContentLayout switches from a single-column fallback to a real two-column adaptive layout at a 600 px breakpoint. A verticalScroll was 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:

  • Gallery CustomLayoutDemo (shortest-column flow): renders 4 columns portrait, 9 columns landscape.
  • JetNews Interests: 1 column portrait, 2 columns landscape with verticalScroll revealing 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.Tests112/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.md regenerated — Layout now [x] Layout(4) → type match.

Copilot AI review requested due to automatic review settings June 9, 2026 17:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Constraints struct (packed-long wrapper with JNI accessors), Measurable, MeasureScope, MeasureResult, PlacementScope, and the Layout facade — all with XML docs and public API tracking entries.
  • JNI bridges in ComposeBridges.cs for six Constraints accessors, MeasurableMeasure, MeasureScopeLayout, PlacementScopePlace/PlaceRelative, plus MeasurePolicyFactory.java using Proxy.newProxyInstance to sidestep the mangled interface method.
  • Gallery CustomLayoutDemo (shortest-column adaptive flow) and JetNews InterestsScreen upgrade from single-column fallback to a real two-column adaptive layout via the new Layout primitive.

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.

Comment thread src/ComposeNet.Compose/Layout.cs Outdated
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>
@jonathanpeppers jonathanpeppers force-pushed the jonathanpeppers/layout-primitive-144 branch 2 times, most recently from f91cf59 to f49da27 Compare June 10, 2026 00:22
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>
@jonathanpeppers jonathanpeppers force-pushed the jonathanpeppers/layout-primitive-144 branch from f49da27 to bc5409e Compare June 12, 2026 22:41
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.

Bind custom Layout {} primitive — Measurable / Placeable / MeasureScope

2 participants