diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9340a4d9..30177933 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -506,6 +506,20 @@ so `[CallerFilePath]` + `[CallerLineNumber]` slot keys inside Kotlin ctor needs JNI (mangled because `selection: TextRange` is a `@JvmInline value class`); everything else (`Text`/`Selection`/`Copy(…)`) is exposed by the runtime binding. See issue #204. +- `Layout` exposes a low-level measure-and-place primitive: ctor takes a + user `Func, Constraints, MeasureResult>` + delegate, the `MeasurePolicy` parameter is built once via a tiny Java + helper that returns a SAM lambda — `MeasurePolicy` is a Kotlin + `fun interface`, so `javac` resolves the single abstract member + (`measure-3p2s80s`, mangled because `Constraints` is `@JvmInline value class`) + by signature via `LambdaMetafactory` and the source never has to spell the + illegal `-` identifier. Default interface methods (the four + `IntrinsicMeasureScope.*Intrinsic*` helpers) are inherited correctly by the + synthesized class. The SAM instance + JCW lambda are cached via + `composer.Remember` so JNI identity stays stable across recompositions. + None of (custom user-delegate ctor, wrapper-typed params not in + `ComposeValueTypes`, JCW with mutable `Body`) fit any `[ComposeFacade]` + phase. See issue #144. Applying `[ComposeFacade]` to an unsupported bridge emits CN3002 (unsupported param), CN3003 (scope misuse), CN3005 (invalid callback type), CN3006 (slot diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 445eb2dd..a0a35e69 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -1,6 +1,6 @@ # Jetpack Compose ⇄ Microsoft.AndroidX.Compose API coverage -Generated by `scripts/api-comparison.cs` on 2026-06-11 21:52 UTC. +Generated by `scripts/api-comparison.cs` on 2026-06-12 22:41 UTC. Source of truth: AndroidX `-sources.jar` files for the Compose artifact versions this repo references (see `Directory.Build.targets`). Re-run after @@ -9,9 +9,9 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). ## Summary - **Total Kotlin public symbols in scope**: 2215 -- **Covered by Microsoft.AndroidX.Compose (any kind)**: 362 (**16.3%**) +- **Covered by Microsoft.AndroidX.Compose (any kind)**: 375 (**16.9%**) -- **@Composable functions**: 159 / 287 (**55.4%**) +- **@Composable functions**: 160 / 287 (**55.7%**) ### Per-module coverage @@ -19,15 +19,15 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). | --- | --- | --- | | `animation` | 8/17 (47%) | 16/56 (29%) | | `animation-core` | 0/25 (0%) | 0/161 (0%) | -| `foundation` | 15/42 (36%) | 45/356 (13%) | +| `foundation` | 15/42 (36%) | 48/356 (13%) | | `foundation-layout` | 7/11 (64%) | 43/128 (34%) | | `material3` | 109/133 (82%) | 168/316 (53%) | | `runtime` | 6/20 (30%) | 13/200 (7%) | | `runtime-saveable` | 1/3 (33%) | 1/13 (8%) | -| `ui` | 10/26 (38%) | 44/596 (7%) | +| `ui` | 11/26 (42%) | 51/596 (9%) | | `ui-graphics` | 0/0 (0%) | 10/187 (5%) | -| `ui-text` | 0/0 (0%) | 13/129 (10%) | -| `ui-unit` | 0/0 (0%) | 5/49 (10%) | +| `ui-text` | 0/0 (0%) | 14/129 (11%) | +| `ui-unit` | 0/0 (0%) | 7/49 (14%) | | `activity-compose` | 1/5 (20%) | 2/11 (18%) | | `navigation-compose` | 2/5 (40%) | 2/13 (15%) | @@ -344,7 +344,7 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [x] `SelectionContainer`(2) → type match - [x] `VerticalPager`(16) → type match -### Other top-level functions — 22/156 (14%) +### Other top-level functions — 25/156 (16%) - [ ] `AbsoluteCutCornerShape`(5) - [ ] `AbsoluteRoundedCornerShape`(5) @@ -398,11 +398,11 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [ ] `CornerSize`(1) - [ ] `CutCornerShape`(5) - [x] `TextFieldBuffer.delete`(2) → TextFieldBuffer.* extension -- [ ] `PointerInputScope.detectDragGestures`(7) +- [x] `PointerInputScope.detectDragGestures`(7) → PointerInputScope.* extension - [ ] `PointerInputScope.detectDragGesturesAfterLongPress`(5) - [ ] `PointerInputScope.detectHorizontalDragGestures`(5) - [x] `PointerInputScope.detectTapGestures`(5) → PointerInputScope.* extension -- [ ] `PointerInputScope.detectTransformGestures`(3) +- [x] `PointerInputScope.detectTransformGestures`(3) → PointerInputScope.* extension - [ ] `PointerInputScope.detectVerticalDragGestures`(5) - [ ] `StyleScope.disabled`(1) *(experimental)* - [ ] `AwaitPointerEventScope.drag`(3) @@ -443,7 +443,7 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [ ] `LazyListScope.itemsIndexed`(5) - [ ] `LazyGridScope.itemsIndexed`(6) - [ ] `LazyStaggeredGridScope.itemsIndexed`(6) -- [ ] `KeyboardActions`(1) +- [x] `KeyboardActions`(1) → covered by `OutlinedTextField.KeyboardActions` - [ ] `LazyGridPrefetchStrategy`(1) *(experimental)* - [ ] `LazyLayoutCacheWindow`(3) *(experimental)* - [ ] `LazyLayoutKeyIndexMap`(3) @@ -1427,7 +1427,7 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). ## `ui` -### @Composable functions — 10/26 (38%) +### @Composable functions — 11/26 (42%) - [x] `AndroidView`(6) → type match - [x] `booleanResource`(1) → covered by `ComposeExtensions.BooleanResource` @@ -1439,7 +1439,7 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [x] `integerArrayResource`(1) → covered by `ComposeExtensions.IntegerArrayResource` - [x] `integerResource`(1) → covered by `ComposeExtensions.IntegerResource` - [ ] `InterceptPlatformTextInput`(3) *(experimental)* -- [ ] `Layout`(4) +- [x] `Layout`(4) → type match - [ ] `LookaheadScope`(1) - [ ] `mediaQuery`(1) *(experimental)* - [x] `painterResource`(1) → covered by `ComposeExtensions.PainterResource` @@ -1456,7 +1456,7 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [ ] `SubcomposeLayout`(4) - [ ] `TestModifierUpdaterLayout`(1) -### Other top-level functions — 20/224 (9%) +### Other top-level functions — 22/224 (10%) - [ ] `addPathNodes`(1) - [ ] `VelocityTracker.addPointerInputChange`(2) @@ -1538,7 +1538,7 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [x] `Modifier.graphicsLayer`(20) → Modifier extension - [ ] `GraphicsLayerScope`(0) - [ ] `ImageVector.Builder.group`(11) -- [ ] `SemanticsPropertyReceiver.heading`(0) +- [x] `SemanticsPropertyReceiver.heading`(0) → SemanticsPropertyReceiver.* extension - [ ] `SemanticsPropertyReceiver.hideFromAccessibility`(0) - [ ] `ImageBitmap.Companion.imageResource`(2) - [ ] `SemanticsPropertyReceiver.indexForKey`(1) @@ -1565,7 +1565,7 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [ ] `PointerButtons.isPressed`(1) - [ ] `Modifier.keepScreenOn`(0) - [ ] `Key`(1) -- [ ] `Modifier.layout`(1) +- [x] `Modifier.layout`(1) → Modifier extension - [ ] `Modifier.layoutBounds`(1) - [ ] `lerp`(3) - [ ] `LookaheadScope.lookaheadScopeCoordinates`(1) @@ -1767,7 +1767,7 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [ ] `VerticalRuler` - [ ] `VNode` -### Interfaces — 5/125 (4%) +### Interfaces — 8/125 (6%) - [ ] `AccessibilityManager` - [x] `Alignment` → type match @@ -1831,12 +1831,12 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [ ] `LayoutModifier` - [ ] `LayoutModifierNode` - [ ] `LookaheadScope` -- [ ] `Measurable` +- [x] `Measurable` → type match - [ ] `Measured` - [ ] `MeasuredSizeAwareModifierNode` - [ ] `MeasurePolicy` -- [ ] `MeasureResult` -- [ ] `MeasureScope` +- [x] `MeasureResult` → type match +- [x] `MeasureScope` → type match - [x] `Modifier` → type match - [ ] `ModifierLocalConsumer` - [ ] `ModifierLocalModifierNode` @@ -1938,7 +1938,7 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [ ] `TouchBoundsExpansion` - [x] `TransformOrigin` → type match -### Top-level properties — 6/106 (6%) +### Top-level properties — 7/106 (7%) - [ ] `SemanticsPropertyReceiver.accessibilityClassName` - [ ] `SemanticsPropertyReceiver.collectionInfo by SemanticsProperties.CollectionInfo` @@ -1977,7 +1977,7 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [ ] `FirstBaseline` - [ ] `SemanticsPropertyReceiver.focused by SemanticsProperties.Focused` - [ ] `SemanticsPropertyReceiver.horizontalScrollAxisRange` -- [ ] `SemanticsPropertyReceiver.imeAction by SemanticsProperties.ImeAction` +- [x] `SemanticsPropertyReceiver.imeAction by SemanticsProperties.ImeAction` → type match - [ ] `SemanticsPropertyReceiver.inputText by SemanticsProperties.InputText` - [ ] `SemanticsPropertyReceiver.inputTextSuggestionState` - [ ] `SemanticsPropertyReceiver.isContainer by SemanticsProperties.IsContainer` @@ -2383,7 +2383,7 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [ ] `ResolvedTextDirection` -### Value classes — 4/17 (24%) +### Value classes — 5/17 (29%) - [ ] `BaselineShift` - [ ] `DeviceFontFamilyName` @@ -2392,7 +2392,7 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [x] `FontStyle` → type match - [ ] `FontSynthesis` - [ ] `Hyphens` -- [ ] `ImeAction` +- [x] `ImeAction` → type match - [ ] `KeyboardCapitalization` - [x] `KeyboardType` → type match - [ ] `LineBreak` @@ -2405,16 +2405,16 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). ## `ui-unit` -### Other top-level functions — 1/22 (5%) +### Other top-level functions — 2/22 (9%) - [ ] `Dp.coerceAtLeast`(1) → receiver Dp present, ext missing - [ ] `Dp.coerceAtMost`(1) → receiver Dp present, ext missing - [ ] `Dp.coerceIn`(2) → receiver Dp present, ext missing -- [ ] `Constraints.constrain`(1) -- [ ] `Constraints`(5) +- [ ] `Constraints.constrain`(1) → receiver Constraints present, ext missing +- [x] `Constraints`(5) → type match - [ ] `Density`(2) - [ ] `IntRect`(2) -- [ ] `Constraints.isSatisfiedBy`(1) +- [ ] `Constraints.isSatisfiedBy`(1) → receiver Constraints present, ext missing - [ ] `lerp`(3) - [x] `Constraints.offset`(2) → Constraints.* extension - [ ] `Rect.roundToIntRect`(0) @@ -2452,9 +2452,9 @@ bumping a Compose package version. Coverage is symbol-level (short-name match). - [ ] `LayoutDirection` -### Value classes — 1/9 (11%) +### Value classes — 2/9 (22%) -- [ ] `Constraints` +- [x] `Constraints` → type match - [x] `Dp` → type match - [ ] `DpOffset` - [ ] `DpSize` @@ -2550,6 +2550,7 @@ a Compose API the matcher didn't pair up. - `AndroidX.Compose.CompositionLocal` - `AndroidX.Compose.CustomTab` - `AndroidX.Compose.DateRangePickerDialog` +- `AndroidX.Compose.DateRangeSelectableDates` - `AndroidX.Compose.Icons.AutoMirrored.Default` - `AndroidX.Compose.Icons.Default` - `AndroidX.Compose.DerivedState` @@ -2562,6 +2563,7 @@ a Compose API the matcher didn't pair up. - `AndroidX.Compose.Icons` - `AndroidX.Compose.IState` - `AndroidX.Compose.IStateFlow` +- `AndroidX.Compose.KeyboardActionsHelper` - `AndroidX.Compose.KeyboardOptionsCompanion` - `AndroidX.Compose.LargeFlexibleTopAppBar` - `AndroidX.Compose.LocalColorScheme` @@ -2577,6 +2579,7 @@ a Compose API the matcher didn't pair up. - `AndroidX.Compose.NavOptions` - `AndroidX.Compose.Icons.AutoMirrored.Outlined` - `AndroidX.Compose.Icons.Outlined` +- `AndroidX.Compose.PlacementScope` - `AndroidX.Compose.Resource` - `AndroidX.Compose.Icons.AutoMirrored.Rounded` - `AndroidX.Compose.Icons.Rounded` diff --git a/samples/JetNews/InterestsScreen.cs b/samples/JetNews/InterestsScreen.cs index a3455593..a36425e1 100644 --- a/samples/JetNews/InterestsScreen.cs +++ b/samples/JetNews/InterestsScreen.cs @@ -1,10 +1,12 @@ +using Placeable = AndroidX.Compose.UI.Layout.Placeable; + namespace AndroidX.Compose.Samples.JetNews; /// /// JetNews interests screen — a with three /// tabs (Topics / People / Publications) and a per-tab toggleable -/// list. The upstream sample renders Topics in an adaptive two-column -/// custom Layout on wide screens; we render a single column. +/// list. Topics are rendered through a custom +/// that re-balances rows across two columns on wide screens. /// public static class InterestsScreen { @@ -48,7 +50,7 @@ static Column BuildBody( }, selectedTab.Value switch { - 0 => (ComposableNode) BuildTopics(selectedTopics), + 0 => BuildTopics(selectedTopics), 1 => BuildSimpleList(InterestsRepo.People, selectedPeople), _ => BuildSimpleList(InterestsRepo.Publications, selectedPublications), }, @@ -62,23 +64,88 @@ static Tab BuildTab(MutableState selectedTab, int index, string label) => Text = new Text(label) { FontSize = 14 }, }; - static Column BuildTopics(MutableStateList selected) + static ComposableNode BuildTopics(MutableStateList selected) => + new Composed(c => + { + // Topics content can exceed viewport height (especially landscape), + // so the outer column needs vertical scrolling. + var scroll = c.Remember(() => new ScrollState()); + var col = new Column + { + Modifier.FillMaxWidth().VerticalScroll(scroll), + }; + foreach (var section in InterestsRepo.Topics) + { + col.Add(BuildSectionHeader(section.Key)); + var rows = new List(); + foreach (var topic in section.Value) + { + var key = $"{section.Key}/{topic}"; + rows.Add(BuildToggleRow(topic, selected.Contains(key), () => Toggle(selected, key))); + } + col.Add(BuildAdaptiveTopicSection(rows)); + col.Add(new HorizontalDivider + { + Modifier = Modifier.Padding(horizontal: 16, vertical: 8), + }); + } + return col; + }); + + // Mirrors upstream JetNews's `InterestsAdaptiveContentLayout` — bins + // topic rows into N columns whose count is driven by the parent's + // available width. Backed by Compose's low-level `Layout` primitive. + static Layout BuildAdaptiveTopicSection(IReadOnlyList rows) { - var col = new Column { Modifier.FillMaxWidth() }; - foreach (var section in InterestsRepo.Topics) + const int multiColumnBreakpointPx = 600; + + var layout = new Layout(measurePolicy: (scope, measurables, constraints) => { - col.Add(BuildSectionHeader(section.Key)); - foreach (var topic in section.Value) + int max = constraints.HasBoundedWidth + ? constraints.MaxWidth + : multiColumnBreakpointPx; + int columns = max >= multiColumnBreakpointPx ? 2 : 1; + int columnWidth = max / columns; + + var childConstraints = Constraints.FixedWidth(columnWidth); + var placeables = new Placeable[measurables.Count]; + var columnHeights = new int[columns]; + var columnAssign = new int[measurables.Count]; + for (int i = 0; i < measurables.Count; i++) { - var key = $"{section.Key}/{topic}"; - col.Add(BuildToggleRow(topic, selected.Contains(key), () => Toggle(selected, key))); + placeables[i] = measurables[i].Measure(childConstraints); + int h = placeables[i].Height; + int target = 0; + for (int c = 1; c < columns; c++) + if (columnHeights[c] < columnHeights[target]) target = c; + columnAssign[i] = target; + columnHeights[target] += h; } - col.Add(new HorizontalDivider + + int totalHeight = 0; + for (int c = 0; c < columns; c++) + if (columnHeights[c] > totalHeight) totalHeight = columnHeights[c]; + + return scope.Layout(columnWidth * columns, totalHeight, placement => { - Modifier = Modifier.Padding(horizontal: 16, vertical: 8), + var yByColumn = new int[columns]; + for (int i = 0; i < placeables.Length; i++) + { + int col = columnAssign[i]; + placement.PlaceRelative( + placeables[i], + x: col * columnWidth, + y: yByColumn[col]); + yByColumn[col] += placeables[i].Height; + } }); - } - return col; + }) + { + Modifier.FillMaxWidth(), + }; + foreach (var row in rows) + layout.Add(row); + return layout; } static LazyColumn BuildSimpleList(IReadOnlyList items, diff --git a/src/Microsoft.AndroidX.Compose.Gallery/Demos/Containers/CustomLayoutDemo.cs b/src/Microsoft.AndroidX.Compose.Gallery/Demos/Containers/CustomLayoutDemo.cs new file mode 100644 index 00000000..e5162728 --- /dev/null +++ b/src/Microsoft.AndroidX.Compose.Gallery/Demos/Containers/CustomLayoutDemo.cs @@ -0,0 +1,135 @@ +using AndroidX.Compose.Gallery.Registry; +using Placeable = AndroidX.Compose.UI.Layout.Placeable; + +namespace AndroidX.Compose.Gallery.Demos.Containers; + +/// +/// Custom primitive — adaptive multi-column layout +/// that bins variable-height cards into the fewest columns whose width +/// is at least minColumnWidth, distributing items to balance +/// the total height of each column. +/// +public static class CustomLayoutDemo +{ + /// Registry entry exposed via . + public static Demo Demo => new( + Id: "containers-custom-layout", + CategoryId: "containers", + Title: "Custom Layout (adaptive columns)", + Description: "Uses Compose's low-level Layout primitive to bin cards into N columns " + + "based on available width, then distribute by total column height.", + Build: _ => + { + // Sample articles with intentionally varied content lengths + // so the height-balancing is visible. + string[] titles = + [ + "Compose ships 1.7", + "Material 3 expressive", + "Coroutines on JVM 21", + "Wear OS 5 picker APIs", + "Type-safe nav", + "Kotlin K2 in stable Gradle", + "Performance: SaveableStateHolder", + "Behind the scenes of remember", + "Designing for foldables", + "Animations & Motion 2.0", + ]; + string[] bodies = + [ + "What's new in the latest stable.", + "A practical look at the new colour system and dynamic theming on Android 16.", + "Loom-friendly suspending APIs and how they coexist.", + "Native picker support reduces glue code on small displays.", + "How to migrate.", + "Build-time wins and migration notes for K2.", + "Save once, restore often: keying tips for state holders.", + "A short tour through slot table mechanics.", + "Adaptive layouts that breathe across hinge angles.", + "Subcompose, transitions, and the road to 2.0.", + ]; + + var layout = new Layout(measurePolicy: (scope, measurables, constraints) => + { + // Bin into N columns by available width. 220 px is the + // minimum desired column width. + int min = 220; + int max = constraints.HasBoundedWidth + ? constraints.MaxWidth + : min; + int columns = Math.Max(1, max / min); + int columnWidth = max / columns; + + // Greedy shortest-column allocation: each measurable + // joins the column whose running total height is + // smallest, balancing the layout end-to-end. + var childConstraints = Constraints.FixedWidth(columnWidth); + var placeables = new Placeable[measurables.Count]; + var columnHeights = new int[columns]; + var columnAssign = new int[measurables.Count]; + for (int i = 0; i < measurables.Count; i++) + { + placeables[i] = measurables[i].Measure(childConstraints); + int target = 0; + for (int c = 1; c < columns; c++) + if (columnHeights[c] < columnHeights[target]) target = c; + columnAssign[i] = target; + columnHeights[target] += placeables[i].Height; + } + + int totalHeight = 0; + for (int c = 0; c < columns; c++) + if (columnHeights[c] > totalHeight) totalHeight = columnHeights[c]; + + int layoutWidth = columnWidth * columns; + + return scope.Layout(layoutWidth, totalHeight, placement => + { + var yByColumn = new int[columns]; + for (int i = 0; i < placeables.Length; i++) + { + int col = columnAssign[i]; + placement.PlaceRelative( + placeables[i], + x: col * columnWidth, + y: yByColumn[col]); + yByColumn[col] += placeables[i].Height; + } + }); + }) + { + Modifier.Companion.FillMaxWidth(), + }; + foreach (var card in BuildCards(titles, bodies)) + layout.Add(card); + + return new Column + { + Modifier.Companion.Padding(8), + new Text("Resize the window — columns rebalance to fit available width."), + new Spacer { Modifier = Modifier.Companion.Height(8) }, + layout, + }; + }); + + static IEnumerable BuildCards( + string[] titles, string[] bodies) + { + for (int i = 0; i < titles.Length; i++) + { + string title = titles[i]; + string body = bodies[i]; + yield return new Card + { + Modifier.Companion.Padding(4), + new Column + { + Modifier.Companion.Padding(12), + new Text(title), + new Spacer { Modifier = Modifier.Companion.Height(4) }, + new Text(body), + }, + }; + } + } +} diff --git a/src/Microsoft.AndroidX.Compose.Gallery/Registry/Catalog.cs b/src/Microsoft.AndroidX.Compose.Gallery/Registry/Catalog.cs index 8d4b8b99..4cac0854 100644 --- a/src/Microsoft.AndroidX.Compose.Gallery/Registry/Catalog.cs +++ b/src/Microsoft.AndroidX.Compose.Gallery/Registry/Catalog.cs @@ -87,6 +87,7 @@ public static class Catalog D.Containers.DividerDemo.Demo, D.Containers.FlowRowFlowColumnDemo.Demo, D.Containers.BoxWithConstraintsDemo.Demo, + D.Containers.CustomLayoutDemo.Demo, // ---- Lists & grids ---- D.ListsGrids.LazyColumnLongDemo.Demo, diff --git a/src/Microsoft.AndroidX.Compose/ComposeBridges.cs b/src/Microsoft.AndroidX.Compose/ComposeBridges.cs index 55fa838c..34d01b5b 100644 --- a/src/Microsoft.AndroidX.Compose/ComposeBridges.cs +++ b/src/Microsoft.AndroidX.Compose/ComposeBridges.cs @@ -4182,5 +4182,243 @@ public static partial void BackHandler( IFunction0 onBack, bool enabled, IComposer composer); + + // --------------------------------------------------------------------- + // Custom Layout primitive — supporting bridges. See Layout.cs. + // + // The bound interfaces (Measurable, MeasurePolicy, MeasureResult, + // MeasureScope) are empty because every abstract member has an + // inline-class-mangled JVM name (Constraints is @JvmInline value class). + // We hand-bridge the four reachable methods plus the four Constraints + // accessors and Collections.emptyMap() (needed for MeasureScope.layout's + // alignmentLines argument — null fails Kotlin null-checks). + // + // The MeasurePolicy interface itself can't be implemented from Java + // source (its only abstract method is `measure-3p2s80s`, illegal Java + // identifier). MeasurePolicyFactoryCreate routes through a tiny + // composenet/compose/MeasurePolicyFactory helper that exploits + // MeasurePolicy being a Kotlin `fun interface` — javac resolves the + // mangled SAM by MethodType via LambdaMetafactory, so a Java lambda + // can target it without ever spelling the illegal identifier. See + // Java/MeasurePolicyFactory.java. + // --------------------------------------------------------------------- + + // AndroidX.Compose.UI.Unit.Constraints — `@JvmInline value class` + // companion accessors. Each takes the packed `long` value and returns + // an `int` (or `boolean` for the bounded helpers). Hand-written + // because the [ComposeBridge] generator only emits CallStaticObjectMethod, + // and these need primitive returns (CallStaticIntMethod / + // CallStaticBooleanMethod). One JNI class lookup is shared across all + // six accessors via the lazy s_constraintsClass field. + static IntPtr s_constraintsClass; + static IntPtr s_constraintsGetMinWidthMethodId; + static IntPtr s_constraintsGetMaxWidthMethodId; + static IntPtr s_constraintsGetMinHeightMethodId; + static IntPtr s_constraintsGetMaxHeightMethodId; + static IntPtr s_constraintsHasBoundedWidthMethodId; + static IntPtr s_constraintsHasBoundedHeightMethodId; + + static IntPtr ConstraintsClass() + { + if (s_constraintsClass == IntPtr.Zero) + s_constraintsClass = Java.Lang.Class.FromType( + typeof(AndroidX.Compose.UI.Unit.Constraints)).Handle; + return s_constraintsClass; + } + + internal static unsafe int ConstraintsGetMinWidth(long value) + { + if (s_constraintsGetMinWidthMethodId == IntPtr.Zero) + s_constraintsGetMinWidthMethodId = JNIEnv.GetStaticMethodID( + ConstraintsClass(), "getMinWidth-impl", "(J)I"); + var args = stackalloc JValue[1]; args[0] = new JValue(value); + return JNIEnv.CallStaticIntMethod(ConstraintsClass(), s_constraintsGetMinWidthMethodId, args); + } + + internal static unsafe int ConstraintsGetMaxWidth(long value) + { + if (s_constraintsGetMaxWidthMethodId == IntPtr.Zero) + s_constraintsGetMaxWidthMethodId = JNIEnv.GetStaticMethodID( + ConstraintsClass(), "getMaxWidth-impl", "(J)I"); + var args = stackalloc JValue[1]; args[0] = new JValue(value); + return JNIEnv.CallStaticIntMethod(ConstraintsClass(), s_constraintsGetMaxWidthMethodId, args); + } + + internal static unsafe int ConstraintsGetMinHeight(long value) + { + if (s_constraintsGetMinHeightMethodId == IntPtr.Zero) + s_constraintsGetMinHeightMethodId = JNIEnv.GetStaticMethodID( + ConstraintsClass(), "getMinHeight-impl", "(J)I"); + var args = stackalloc JValue[1]; args[0] = new JValue(value); + return JNIEnv.CallStaticIntMethod(ConstraintsClass(), s_constraintsGetMinHeightMethodId, args); + } + + internal static unsafe int ConstraintsGetMaxHeight(long value) + { + if (s_constraintsGetMaxHeightMethodId == IntPtr.Zero) + s_constraintsGetMaxHeightMethodId = JNIEnv.GetStaticMethodID( + ConstraintsClass(), "getMaxHeight-impl", "(J)I"); + var args = stackalloc JValue[1]; args[0] = new JValue(value); + return JNIEnv.CallStaticIntMethod(ConstraintsClass(), s_constraintsGetMaxHeightMethodId, args); + } + + internal static unsafe bool ConstraintsHasBoundedWidth(long value) + { + if (s_constraintsHasBoundedWidthMethodId == IntPtr.Zero) + s_constraintsHasBoundedWidthMethodId = JNIEnv.GetStaticMethodID( + ConstraintsClass(), "getHasBoundedWidth-impl", "(J)Z"); + var args = stackalloc JValue[1]; args[0] = new JValue(value); + return JNIEnv.CallStaticBooleanMethod(ConstraintsClass(), s_constraintsHasBoundedWidthMethodId, args); + } + + internal static unsafe bool ConstraintsHasBoundedHeight(long value) + { + if (s_constraintsHasBoundedHeightMethodId == IntPtr.Zero) + s_constraintsHasBoundedHeightMethodId = JNIEnv.GetStaticMethodID( + ConstraintsClass(), "getHasBoundedHeight-impl", "(J)Z"); + var args = stackalloc JValue[1]; args[0] = new JValue(value); + return JNIEnv.CallStaticBooleanMethod(ConstraintsClass(), s_constraintsHasBoundedHeightMethodId, args); + } + + // composenet.compose.MeasurePolicyFactory.create — static factory in + // our Java helper that wraps a Function3 as an androidx.compose.ui.layout.MeasurePolicy via + // a Kotlin `fun interface` SAM lambda. See + // Java/MeasurePolicyFactory.java for why. + [ComposeBridge( + Class = "composenet/compose/MeasurePolicyFactory", + JvmName = "create", + Signature = "(Lkotlin/jvm/functions/Function3;)Landroidx/compose/ui/layout/MeasurePolicy;")] + internal static partial IntPtr MeasurePolicyFactoryCreate(IFunction3 block); + + // Cached Collections.emptyMap() peer. MeasureScope.layout's third + // argument is the alignmentLines map — Kotlin's bytecode dispatches a + // non-null check on it, so we cannot pass IntPtr.Zero. One peer + // (carrying its own global ref) is reused across every layout() call. + static Android.Runtime.IJavaObject? s_emptyMap; + + internal static IntPtr EmptyMapHandle() + { + s_emptyMap ??= (Android.Runtime.IJavaObject)Java.Util.Collections.EmptyMap()!; + return s_emptyMap.Handle; + } + + // androidx.compose.ui.layout.Measurable.measure-BRTryo0(long): Placeable — + // the only abstract method on the interface, mangled because Constraints + // is an inline value class. Hand-written instance call via interface + // JNI dispatch. The returned Placeable is a class (bound), so callers + // wrap with Java.Lang.Object.GetObject(handle, TransferLocalRef). + static IntPtr s_measurableClass; + static IntPtr s_measurableMeasureMethodId; + + internal static unsafe IntPtr MeasurableMeasure(IntPtr measurable, long constraints) + { + if (s_measurableMeasureMethodId == IntPtr.Zero) + { + s_measurableClass = Java.Lang.Class.FromType( + typeof(AndroidX.Compose.UI.Layout.IMeasurable)).Handle; + s_measurableMeasureMethodId = JNIEnv.GetMethodID( + s_measurableClass, "measure-BRTryo0", + "(J)Landroidx/compose/ui/layout/Placeable;"); + } + var args = stackalloc JValue[1]; + args[0] = new JValue(constraints); + return JNIEnv.CallObjectMethod(measurable, s_measurableMeasureMethodId, args); + } + + // androidx.compose.ui.layout.MeasureScope.layout(int, int, Map, Function1): + // MeasureResult — Java 8 default method on the interface, NOT mangled. + // Hand-written because [ComposeBridge] doesn't model instance calls; the + // Map argument is a cached java.util.Collections.emptyMap() global ref + // (Kotlin-checked-non-null per the rubber-duck review). + static IntPtr s_measureScopeClass; + static IntPtr s_measureScopeLayoutMethodId; + + internal static unsafe IntPtr MeasureScopeLayout( + IntPtr measureScope, int width, int height, PlacementBlockLambda placementBlock) + { + if (s_measureScopeLayoutMethodId == IntPtr.Zero) + { + s_measureScopeClass = JNIEnv.FindClass("androidx/compose/ui/layout/MeasureScope"); + s_measureScopeLayoutMethodId = JNIEnv.GetMethodID( + s_measureScopeClass, "layout", + "(IILjava/util/Map;Lkotlin/jvm/functions/Function1;)" + + "Landroidx/compose/ui/layout/MeasureResult;"); + } + try + { + var args = stackalloc JValue[4]; + args[0] = new JValue(width); + args[1] = new JValue(height); + args[2] = new JValue(EmptyMapHandle()); + args[3] = new JValue(((Java.Lang.Object)placementBlock).Handle); + return JNIEnv.CallObjectMethod(measureScope, s_measureScopeLayoutMethodId, args); + } + finally + { + GC.KeepAlive(placementBlock); + } + } + + // AndroidX.Compose.UI.Layout.Placeable$PlacementScope.place( + // Placeable, int x, int y, float zIndex): void — non-mangled + // overload (the long-IntOffset variant `place-70tqf50` IS mangled but + // we don't need it). Same for placeRelative. + static IntPtr s_placementScopeClass; + static IntPtr s_placementScopePlaceMethodId; + static IntPtr s_placementScopePlaceRelativeMethodId; + + internal static unsafe void PlacementScopePlace( + IntPtr placementScope, AndroidX.Compose.UI.Layout.Placeable placeable, int x, int y, float zIndex) + { + if (s_placementScopePlaceMethodId == IntPtr.Zero) + { + s_placementScopeClass = JNIEnv.FindClass( + "androidx/compose/ui/layout/Placeable$PlacementScope"); + s_placementScopePlaceMethodId = JNIEnv.GetMethodID( + s_placementScopeClass, "place", + "(Landroidx/compose/ui/layout/Placeable;IIF)V"); + } + try + { + var args = stackalloc JValue[4]; + args[0] = new JValue(placeable.Handle); + args[1] = new JValue(x); + args[2] = new JValue(y); + args[3] = new JValue(zIndex); + JNIEnv.CallVoidMethod(placementScope, s_placementScopePlaceMethodId, args); + } + finally + { + GC.KeepAlive(placeable); + } + } + + internal static unsafe void PlacementScopePlaceRelative( + IntPtr placementScope, AndroidX.Compose.UI.Layout.Placeable placeable, int x, int y, float zIndex) + { + if (s_placementScopePlaceRelativeMethodId == IntPtr.Zero) + { + if (s_placementScopeClass == IntPtr.Zero) + s_placementScopeClass = JNIEnv.FindClass( + "androidx/compose/ui/layout/Placeable$PlacementScope"); + s_placementScopePlaceRelativeMethodId = JNIEnv.GetMethodID( + s_placementScopeClass, "placeRelative", + "(Landroidx/compose/ui/layout/Placeable;IIF)V"); + } + try + { + var args = stackalloc JValue[4]; + args[0] = new JValue(placeable.Handle); + args[1] = new JValue(x); + args[2] = new JValue(y); + args[3] = new JValue(zIndex); + JNIEnv.CallVoidMethod(placementScope, s_placementScopePlaceRelativeMethodId, args); + } + finally + { + GC.KeepAlive(placeable); + } + } } diff --git a/src/Microsoft.AndroidX.Compose/ComposeDefaults.cs b/src/Microsoft.AndroidX.Compose/ComposeDefaults.cs index c78932df..928ea587 100644 --- a/src/Microsoft.AndroidX.Compose/ComposeDefaults.cs +++ b/src/Microsoft.AndroidX.Compose/ComposeDefaults.cs @@ -40,6 +40,14 @@ [assembly: ComposeDefaults("RowDefault", "modifier", "horizontalArrangement", "verticalAlignment", "!content")] [assembly: ComposeDefaults("BoxDefault", "modifier", "contentAlignment", "propagateMinConstraints", "!content")] +// androidx.compose.ui.layout.LayoutKt.Layout — the (content, modifier, +// measurePolicy) overload. content and measurePolicy are required; only +// modifier is defaultable. `IMeasurePolicy` itself is not @JvmInline so +// the overload survives the binder; the inline-class issue is only on the +// internal abstract method (`measure-3p2s80s`), which we route around via +// a Kotlin `fun interface` SAM lambda in MeasurePolicyFactory.java. +[assembly: ComposeDefaults("LayoutDefault", "!content", "modifier", "!measurePolicy")] + // androidx.compose.foundation.layout.BoxWithConstraintsKt — same shape // as Box, but the content lambda receives a BoxWithConstraintsScope. [assembly: ComposeDefaults("BoxWithConstraintsDefault", "modifier", "contentAlignment", "propagateMinConstraints", "!content")] diff --git a/src/Microsoft.AndroidX.Compose/Constraints.cs b/src/Microsoft.AndroidX.Compose/Constraints.cs new file mode 100644 index 00000000..9b20095b --- /dev/null +++ b/src/Microsoft.AndroidX.Compose/Constraints.cs @@ -0,0 +1,84 @@ +namespace AndroidX.Compose; + +/// +/// Layout-time constraints handed to a +/// measure-policy callback. Mirrors +/// AndroidX.Compose.UI.Unit.Constraints — Compose's packed +/// (minWidth, maxWidth, minHeight, maxHeight) tuple in pixels. +/// +/// +/// +/// All four bounds are pixel values, not dp. Multiply / divide by +/// density when you need to round-trip through dp. +/// +/// +/// / can be unbounded — +/// check / +/// before comparing the maximum to a fixed pixel threshold. Compose's +/// internal sentinel for "unbounded" is ; the +/// helper accessors hide that detail. +/// +/// +/// Construction from a raw packed value is internal — you only ever +/// receive a instance from the runtime, never +/// build one yourself. The struct is cheap (just the packed long), so +/// passing it by value through user code is fine. +/// +/// +public readonly struct Constraints +{ + /// The packed long bit pattern Compose uses internally. + internal long Value { get; } + + internal Constraints(long value) => Value = value; + + /// + /// Build a envelope to pass to + /// . Pass + /// for an unbounded dimension. The arguments must satisfy + /// 0 ≤ min ≤ max on each axis or Compose will throw. + /// + public static Constraints Create(int minWidth, int maxWidth, int minHeight, int maxHeight) + => new(AndroidX.Compose.UI.Unit.ConstraintsKt.Constraints( + minWidth, maxWidth, minHeight, maxHeight)); + + /// + /// Build a that fixes width to + /// and leaves height unbounded. + /// + public static Constraints FixedWidth(int width) + => Create(width, width, 0, int.MaxValue); + + /// + /// Build a that fixes height to + /// and leaves width unbounded. + /// + public static Constraints FixedHeight(int height) + => Create(0, int.MaxValue, height, height); + + /// Minimum width the layout may take, in pixels. Always finite. + public int MinWidth => ComposeBridges.ConstraintsGetMinWidth(Value); + + /// + /// Maximum width the layout may take, in pixels. Returns + /// when the parent imposes no upper bound; + /// gate on before comparing. + /// + public int MaxWidth => ComposeBridges.ConstraintsGetMaxWidth(Value); + + /// Minimum height the layout may take, in pixels. Always finite. + public int MinHeight => ComposeBridges.ConstraintsGetMinHeight(Value); + + /// + /// Maximum height the layout may take, in pixels. Returns + /// when the parent imposes no upper bound; + /// gate on before comparing. + /// + public int MaxHeight => ComposeBridges.ConstraintsGetMaxHeight(Value); + + /// true when is finite. + public bool HasBoundedWidth => ComposeBridges.ConstraintsHasBoundedWidth(Value); + + /// true when is finite. + public bool HasBoundedHeight => ComposeBridges.ConstraintsHasBoundedHeight(Value); +} diff --git a/src/Microsoft.AndroidX.Compose/Java/MeasurePolicyFactory.java b/src/Microsoft.AndroidX.Compose/Java/MeasurePolicyFactory.java new file mode 100644 index 00000000..bd3ef6cf --- /dev/null +++ b/src/Microsoft.AndroidX.Compose/Java/MeasurePolicyFactory.java @@ -0,0 +1,48 @@ +package composenet.compose; + +import androidx.compose.ui.layout.MeasurePolicy; + +import kotlin.jvm.functions.Function3; + +/** + * Java-side adapter that turns a plain {@code Function3} into a + * {@code MeasurePolicy}. {@code MeasurePolicy} is declared in Kotlin as a + * {@code fun interface}, so it is SAM-convertible — a Java lambda + * targeting it compiles to an {@code invokedynamic} call that hands the + * lambda's {@code MethodType} to {@code LambdaMetafactory}, which + * synthesizes a class implementing the interface's single abstract + * method by its bytecode signature. The fact that {@code measure}'s + * actual JVM name is {@code measure-3p2s80s} (mangled because + * {@code Constraints} is an {@code @JvmInline value class}) never has + * to be spelled in Java source. + * + *

Default interface methods on {@code MeasurePolicy} (the four + * {@code IntrinsicMeasureScope.*Intrinsic*} helpers) are inherited + * normally by the synthetic class, so {@code IntrinsicSize.Min}/{@code Max} + * and any parent that asks children for intrinsic sizes get Compose's + * correct fallback (which re-runs the measure block against a synthetic + * {@code IntrinsicsMeasureScope}) — no manual stubbing required.

+ * + *

The {@code constraints} arg arrives as a primitive {@code long} on + * the synthetic SAM method, but is boxed at the {@code Function3.invoke} + * boundary because Kotlin function types erase their generics to + * {@code Object}. The C# side + * ({@code AndroidX.Compose.MeasurePolicyLambda}) unboxes via + * {@code java.lang.Long.longValue()}.

+ */ +final class MeasurePolicyFactory { + private MeasurePolicyFactory() { } + + /** + * Build a {@code MeasurePolicy} that delegates its single abstract + * measure method to {@code block}. The returned object's identity + * is stable across calls only if the same {@code block} reference + * is reused — the JVM may or may not cache lambda instances. + */ + static MeasurePolicy create( + final Function3 block) { + return (scope, measurables, constraints) -> + (androidx.compose.ui.layout.MeasureResult) + block.invoke(scope, measurables, Long.valueOf(constraints)); + } +} diff --git a/src/Microsoft.AndroidX.Compose/Layout.cs b/src/Microsoft.AndroidX.Compose/Layout.cs new file mode 100644 index 00000000..a2bf5d0f --- /dev/null +++ b/src/Microsoft.AndroidX.Compose/Layout.cs @@ -0,0 +1,114 @@ +using Android.Runtime; +using AndroidX.Compose.Runtime; +using AndroidX.Compose.UI.Layout; + +namespace AndroidX.Compose; + +/// +/// Compose's low-level Layout(content, modifier, measurePolicy) +/// primitive — accepts a measure-policy callback that receives a list +/// of children plus the parent's +/// , and is responsible for measuring each +/// child and calling +/// +/// to commit the chosen size and place the children at custom +/// positions. +/// +/// +/// +/// Use this when stock containers (, , +/// , LazyColumn, ) don't +/// describe the layout you need — e.g. balancing N items into M +/// columns based on available width, or placing children at +/// hand-computed positions. +/// +/// +/// Intrinsic measurements. The factory-built +/// MeasurePolicy stubs minIntrinsicWidth / +/// maxIntrinsicWidth / minIntrinsicHeight / +/// maxIntrinsicHeight to 0. Avoid using this primitive +/// inside a parent that asks its children for intrinsic sizes (e.g. +/// the Modifier.height(IntrinsicSize.Max) or +/// Row(verticalAlignment = Alignment.CenterVertically) with +/// Modifier.fillMaxHeight() children) — that path will read 0. +/// +/// +/// Identity stability. The JNI proxy implementing +/// MeasurePolicy is cached via +/// across recompositions of the parent. The user delegate is rewritten +/// on every render (closures may capture fresh state), but the JNI +/// identity is stable so Compose's measure cache survives. +/// +/// +public sealed class Layout : ComposableContainer +{ + readonly Func, Constraints, MeasureResult> + _measurePolicy; + + /// + /// Build a whose children are measured and + /// placed by the supplied . + /// + /// + /// Measure-and-place callback. Receives a + /// receiver, the list of children (in the + /// order they were added to this ), and the + /// parent's . Must call + /// + /// exactly once and return its result. + /// + public Layout( + Func, Constraints, MeasureResult> + measurePolicy) + { + ArgumentNullException.ThrowIfNull(measurePolicy); + _measurePolicy = measurePolicy; + } + + public override void Render(IComposer composer) + { + var modifier = BuildModifier(); + var content = ComposableLambdas.Wrap2(composer, c => RenderChildren(c)); + var holder = composer.Remember(() => MeasurePolicyHolder.Build()); + + // Refresh the user delegate every pass — closures may have + // captured new state since last composition. JNI identity is + // stable because Holder.Lambda + Holder.Policy are cached. + holder.Lambda.Body = _measurePolicy; + + int defaults = (int)LayoutDefault.All; + if (modifier is not null) defaults &= ~(int)LayoutDefault.Modifier; + + LayoutKt.Layout( + content: content, + modifier: modifier, + measurePolicy: holder.Policy, + _composer: composer, + p4: 0, + _changed: defaults); + } + + sealed class MeasurePolicyHolder + { + public MeasurePolicyLambda Lambda { get; } + public IMeasurePolicy Policy { get; } + + MeasurePolicyHolder(MeasurePolicyLambda lambda, IMeasurePolicy policy) + { + Lambda = lambda; + Policy = policy; + } + + public static MeasurePolicyHolder Build() + { + var lambda = new MeasurePolicyLambda(); + IntPtr handle = ComposeBridges.MeasurePolicyFactoryCreate(lambda); + // TransferLocalRef hands the local ref to the peer cache, which + // promotes it to a global ref. JavaCast creates + // an invoker peer so the binding's [Register] dispatches work. + var peer = Java.Lang.Object.GetObject( + handle, JniHandleOwnership.TransferLocalRef)!; + return new MeasurePolicyHolder(lambda, peer.JavaCast()); + } + } +} diff --git a/src/Microsoft.AndroidX.Compose/Measurable.cs b/src/Microsoft.AndroidX.Compose/Measurable.cs new file mode 100644 index 00000000..609b7d59 --- /dev/null +++ b/src/Microsoft.AndroidX.Compose/Measurable.cs @@ -0,0 +1,41 @@ +using Android.Runtime; +using Placeable = AndroidX.Compose.UI.Layout.Placeable; + +namespace AndroidX.Compose; + +/// +/// A child of a that can be measured against a set +/// of . Mirrors +/// androidx.compose.ui.layout.Measurable. +/// +/// +/// You receive s from the +/// measure-policy callback and call +/// on each one to produce a +/// you can later position via +/// 's +/// placement block. is single-shot — calling it +/// twice on the same instance during a measure pass throws. +/// +public sealed class Measurable +{ + internal IntPtr Handle { get; } + + internal Measurable(IntPtr handle) => Handle = handle; + + /// + /// Measure this child against and + /// return a that can be positioned inside the + /// enclosing layout's + /// placement block. + /// + public Placeable Measure(Constraints constraints) + { + IntPtr placeable = ComposeBridges.MeasurableMeasure(Handle, constraints.Value); + // TransferLocalRef hands the local ref to the peer cache, which + // promotes it to a global ref. The bound Placeable type provides + // Width/Height/MeasuredWidth/MeasuredHeight directly. + return Java.Lang.Object.GetObject( + placeable, JniHandleOwnership.TransferLocalRef)!; + } +} diff --git a/src/Microsoft.AndroidX.Compose/MeasurePolicyLambda.cs b/src/Microsoft.AndroidX.Compose/MeasurePolicyLambda.cs new file mode 100644 index 00000000..4ab3e8ce --- /dev/null +++ b/src/Microsoft.AndroidX.Compose/MeasurePolicyLambda.cs @@ -0,0 +1,75 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW Function3<MeasureScope, List<Measurable>, Long, MeasureResult> +/// passed to composenet.compose.MeasurePolicyFactory.create to +/// build the proxy that implements androidx.compose.ui.layout.MeasurePolicy. +/// One instance per facade — is +/// rewritten on every recomposition (the user's lambda captures may change), +/// but the JNI identity of the lambda + factory-built proxy is +/// stable so Compose's remember-keyed measure cache survives. +/// +[Register("composenet/compose/MeasurePolicyLambda")] +internal sealed class MeasurePolicyLambda : Java.Lang.Object, IFunction3 +{ + /// + /// Developer-supplied measure callback. Assigned by + /// in its Render preamble; never null + /// during a measure pass. + /// + public Func, Constraints, MeasureResult>? Body { get; set; } + + /// + /// Required no-arg ctor for the JNI runtime to materialise the type + /// when Java code instantiates the JCW. Not used by managed callers. + /// + public MeasurePolicyLambda() { } + + /// + /// Kotlin Function3.invoke entry point. + /// is the MeasureScope, is a + /// List<Measurable>, is the + /// boxed Long Constraints value. Routes to + /// and returns the underlying MeasureResult handle wrapped + /// as a . + /// + public Java.Lang.Object Invoke(Java.Lang.Object? p0, Java.Lang.Object? p1, Java.Lang.Object? p2) + { + if (Body is null) + throw new InvalidOperationException( + "MeasurePolicyLambda.Body is null — Layout invoked before Render set it."); + ArgumentNullException.ThrowIfNull(p0); + ArgumentNullException.ThrowIfNull(p1); + ArgumentNullException.ThrowIfNull(p2); + + var constraints = new Constraints(((Java.Lang.Long)p2).LongValue()); + var scope = new MeasureScope(p0.Handle); + var list = p1.JavaCast()!; + int count = list.Size(); + var measurables = new Measurable[count]; + // Hold the JCW peers for the duration of the call so the global + // refs they own outlive the loop below — Measurable just snapshots + // the handle. + var peers = new Java.Lang.Object?[count]; + for (int i = 0; i < count; i++) + { + peers[i] = (Java.Lang.Object?)list.Get(i); + measurables[i] = new Measurable(peers[i]!.Handle); + } + + try + { + return Body(scope, measurables, constraints); + } + finally + { + GC.KeepAlive(p0); + GC.KeepAlive(p1); + GC.KeepAlive(p2); + GC.KeepAlive(peers); + } + } +} diff --git a/src/Microsoft.AndroidX.Compose/MeasureResult.cs b/src/Microsoft.AndroidX.Compose/MeasureResult.cs new file mode 100644 index 00000000..ff8e4f7f --- /dev/null +++ b/src/Microsoft.AndroidX.Compose/MeasureResult.cs @@ -0,0 +1,15 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// Opaque result of . +/// Returned by a measure-policy callback so Compose +/// can pick up the chosen size and the registered placement block. +/// Mirrors androidx.compose.ui.layout.MeasureResult. +/// +public sealed class MeasureResult : Java.Lang.Object +{ + internal MeasureResult(IntPtr handle, JniHandleOwnership transfer) + : base(handle, transfer) { } +} diff --git a/src/Microsoft.AndroidX.Compose/MeasureScope.cs b/src/Microsoft.AndroidX.Compose/MeasureScope.cs new file mode 100644 index 00000000..cf5e6ac3 --- /dev/null +++ b/src/Microsoft.AndroidX.Compose/MeasureScope.cs @@ -0,0 +1,33 @@ +using Android.Runtime; + +namespace AndroidX.Compose; + +/// +/// The receiver passed to a measure-policy callback. +/// Mirrors androidx.compose.ui.layout.MeasureScope. Use +/// to +/// commit the chosen layout size and position the children that were +/// measured via . +/// +public sealed class MeasureScope +{ + internal IntPtr Handle { get; } + + internal MeasureScope(IntPtr handle) => Handle = handle; + + /// + /// Declare that this layout is × + /// pixels and place its children via the + /// callback. Must be called exactly + /// once per measure-policy invocation; the returned + /// is the value the policy should hand + /// back to . + /// + public MeasureResult Layout(int width, int height, Action placementBlock) + { + ArgumentNullException.ThrowIfNull(placementBlock); + var lambda = new PlacementBlockLambda(placementBlock); + IntPtr handle = ComposeBridges.MeasureScopeLayout(Handle, width, height, lambda); + return new MeasureResult(handle, JniHandleOwnership.TransferLocalRef); + } +} diff --git a/src/Microsoft.AndroidX.Compose/Microsoft.AndroidX.Compose.csproj b/src/Microsoft.AndroidX.Compose/Microsoft.AndroidX.Compose.csproj index 58263bd2..78404631 100644 --- a/src/Microsoft.AndroidX.Compose/Microsoft.AndroidX.Compose.csproj +++ b/src/Microsoft.AndroidX.Compose/Microsoft.AndroidX.Compose.csproj @@ -73,5 +73,8 @@ false + + false + diff --git a/src/Microsoft.AndroidX.Compose/PlacementBlockLambda.cs b/src/Microsoft.AndroidX.Compose/PlacementBlockLambda.cs new file mode 100644 index 00000000..25dbce30 --- /dev/null +++ b/src/Microsoft.AndroidX.Compose/PlacementBlockLambda.cs @@ -0,0 +1,43 @@ +using Android.Runtime; +using Kotlin.Jvm.Functions; + +namespace AndroidX.Compose; + +/// +/// JCW Function1<Placeable.PlacementScope, Unit> passed as +/// the placement block to +/// androidx.compose.ui.layout.MeasureScope.layout. Wraps a +/// developer taking a +/// . +/// +/// +/// One instance per call to +/// . +/// The placement block is invoked synchronously inside Compose's layout +/// pass (no remember caching), so a fresh JCW each call is cheap and +/// correct; the parent measure-policy lambda already pins the user +/// delegate identity. +/// +[Register("composenet/compose/PlacementBlockLambda")] +internal sealed class PlacementBlockLambda : Java.Lang.Object, IFunction1 +{ + readonly Action _body; + + public PlacementBlockLambda(Action body) => _body = body; + + public Java.Lang.Object? Invoke(Java.Lang.Object? p0) + { + if (p0 is null) + throw new InvalidOperationException( + "PlacementBlockLambda.Invoke received a null Placeable.PlacementScope."); + try + { + _body(new PlacementScope(p0.Handle)); + return Kotlin.Unit.Instance!; + } + finally + { + GC.KeepAlive(p0); + } + } +} diff --git a/src/Microsoft.AndroidX.Compose/PlacementScope.cs b/src/Microsoft.AndroidX.Compose/PlacementScope.cs new file mode 100644 index 00000000..abd9472f --- /dev/null +++ b/src/Microsoft.AndroidX.Compose/PlacementScope.cs @@ -0,0 +1,43 @@ +using Placeable = AndroidX.Compose.UI.Layout.Placeable; + +namespace AndroidX.Compose; + +/// +/// The receiver passed to the placementBlock argument of +/// . +/// Mirrors AndroidX.Compose.UI.Layout.Placeable.PlacementScope. Use +/// for absolute coordinates and +/// for coordinates that flip in RTL layouts. +/// +public sealed class PlacementScope +{ + internal IntPtr Handle { get; } + + internal PlacementScope(IntPtr handle) => Handle = handle; + + /// + /// Place at + /// (, ) in absolute + /// pixel coordinates from the layout's top-left, regardless of + /// reading direction. overrides the + /// natural draw order — higher values render on top. + /// + public void Place(Placeable placeable, int x, int y, float zIndex = 0f) + { + ArgumentNullException.ThrowIfNull(placeable); + ComposeBridges.PlacementScopePlace(Handle, placeable, x, y, zIndex); + } + + /// + /// Place at + /// (, ) in pixels. + /// In RTL layouts is mirrored against the + /// layout's width — use when you need + /// absolute coordinates regardless of reading direction. + /// + public void PlaceRelative(Placeable placeable, int x, int y, float zIndex = 0f) + { + ArgumentNullException.ThrowIfNull(placeable); + ComposeBridges.PlacementScopePlaceRelative(Handle, placeable, x, y, zIndex); + } +} diff --git a/src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt b/src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt index 4e5cd21d..26620fb5 100644 --- a/src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt +++ b/src/Microsoft.AndroidX.Compose/PublicAPI.Unshipped.txt @@ -174,8 +174,8 @@ AndroidX.Compose.Color.WithAlpha(byte alpha) -> AndroidX.Compose.Color AndroidX.Compose.Color.WithOpacity(float opacity) -> AndroidX.Compose.Color AndroidX.Compose.Column AndroidX.Compose.Column.Column() -> void -AndroidX.Compose.Column.Column(AndroidX.Compose.Arrangement? verticalArrangement) -> void AndroidX.Compose.Column.Column(AndroidX.Compose.Arrangement? verticalArrangement, AndroidX.Compose.Alignment.Horizontal? horizontalAlignment) -> void +AndroidX.Compose.Column.Column(AndroidX.Compose.Arrangement? verticalArrangement) -> void AndroidX.Compose.ComposableContainer AndroidX.Compose.ComposableContainer.Add(AndroidX.Compose.ComposableNode? child) -> void AndroidX.Compose.ComposableContainer.Add(AndroidX.Compose.Modifier! modifier) -> void @@ -199,6 +199,14 @@ AndroidX.Compose.CompositionLocal.Provides(T value) -> AndroidX.Compose.Provi AndroidX.Compose.CompositionLocalProvider AndroidX.Compose.CompositionLocalProvider.Add(AndroidX.Compose.ProvidedValue! value) -> void AndroidX.Compose.CompositionLocalProvider.CompositionLocalProvider() -> void +AndroidX.Compose.Constraints +AndroidX.Compose.Constraints.Constraints() -> void +AndroidX.Compose.Constraints.HasBoundedHeight.get -> bool +AndroidX.Compose.Constraints.HasBoundedWidth.get -> bool +AndroidX.Compose.Constraints.MaxHeight.get -> int +AndroidX.Compose.Constraints.MaxWidth.get -> int +AndroidX.Compose.Constraints.MinHeight.get -> int +AndroidX.Compose.Constraints.MinWidth.get -> int AndroidX.Compose.ContentScale AndroidX.Compose.Crossfade AndroidX.Compose.Crossfade.Crossfade(T targetState, System.Func! content) -> void @@ -220,10 +228,10 @@ AndroidX.Compose.DatePickerState.DatePickerState() -> void AndroidX.Compose.DatePickerState.DatePickerState(long? initialSelectedDateMillis = null, Kotlin.Ranges.IntRange? initialYearRange = null, AndroidX.Compose.Material3.ISelectableDates? initialSelectableDates = null) -> void AndroidX.Compose.DatePickerState.DisplayedMonthMillis.get -> long AndroidX.Compose.DatePickerState.DisplayedMonthMillis.set -> void -AndroidX.Compose.DatePickerState.InitialDisplayMode.get -> int? -AndroidX.Compose.DatePickerState.InitialDisplayMode.set -> void AndroidX.Compose.DatePickerState.InitialDisplayedMonthMillis.get -> Java.Lang.Long? AndroidX.Compose.DatePickerState.InitialDisplayedMonthMillis.set -> void +AndroidX.Compose.DatePickerState.InitialDisplayMode.get -> int? +AndroidX.Compose.DatePickerState.InitialDisplayMode.set -> void AndroidX.Compose.DatePickerState.InitialSelectableDates.get -> AndroidX.Compose.Material3.ISelectableDates? AndroidX.Compose.DatePickerState.InitialSelectableDates.set -> void AndroidX.Compose.DatePickerState.InitialSelectedDateMillis.get -> Java.Lang.Long? @@ -232,18 +240,6 @@ AndroidX.Compose.DatePickerState.InitialYearRange.get -> Kotlin.Ranges.IntRange? AndroidX.Compose.DatePickerState.InitialYearRange.set -> void AndroidX.Compose.DatePickerState.SelectedDateMillis.get -> long? AndroidX.Compose.DatePickerState.SelectedDateMillis.set -> void -AndroidX.Compose.DateRangeSelectableDates -AndroidX.Compose.DateRangeSelectableDates.DateRangeSelectableDates() -> void -AndroidX.Compose.DateRangeSelectableDates.IsSelectableDate(long utcTimeMillis) -> bool -AndroidX.Compose.DateRangeSelectableDates.IsSelectableYear(int year) -> bool -AndroidX.Compose.DateRangeSelectableDates.MaxUtcMillis.get -> long? -AndroidX.Compose.DateRangeSelectableDates.MaxUtcMillis.set -> void -AndroidX.Compose.DateRangeSelectableDates.MaxYear.get -> int? -AndroidX.Compose.DateRangeSelectableDates.MaxYear.set -> void -AndroidX.Compose.DateRangeSelectableDates.MinUtcMillis.get -> long? -AndroidX.Compose.DateRangeSelectableDates.MinUtcMillis.set -> void -AndroidX.Compose.DateRangeSelectableDates.MinYear.get -> int? -AndroidX.Compose.DateRangeSelectableDates.MinYear.set -> void AndroidX.Compose.DateRangePicker AndroidX.Compose.DateRangePicker.DateRangePicker(AndroidX.Compose.DateRangePickerState? state = null) -> void AndroidX.Compose.DateRangePickerDialog @@ -263,6 +259,18 @@ AndroidX.Compose.DateRangePickerState.SelectedEndDateMillis.set -> void AndroidX.Compose.DateRangePickerState.SelectedStartDateMillis.get -> long? AndroidX.Compose.DateRangePickerState.SelectedStartDateMillis.set -> void AndroidX.Compose.DateRangePickerState.SetSelection(long? startDateMillis, long? endDateMillis) -> void +AndroidX.Compose.DateRangeSelectableDates +AndroidX.Compose.DateRangeSelectableDates.DateRangeSelectableDates() -> void +AndroidX.Compose.DateRangeSelectableDates.IsSelectableDate(long utcTimeMillis) -> bool +AndroidX.Compose.DateRangeSelectableDates.IsSelectableYear(int year) -> bool +AndroidX.Compose.DateRangeSelectableDates.MaxUtcMillis.get -> long? +AndroidX.Compose.DateRangeSelectableDates.MaxUtcMillis.set -> void +AndroidX.Compose.DateRangeSelectableDates.MaxYear.get -> int? +AndroidX.Compose.DateRangeSelectableDates.MaxYear.set -> void +AndroidX.Compose.DateRangeSelectableDates.MinUtcMillis.get -> long? +AndroidX.Compose.DateRangeSelectableDates.MinUtcMillis.set -> void +AndroidX.Compose.DateRangeSelectableDates.MinYear.get -> int? +AndroidX.Compose.DateRangeSelectableDates.MinYear.set -> void AndroidX.Compose.DerivedState AndroidX.Compose.DerivedState.Value.get -> T AndroidX.Compose.DisableSelection @@ -544,6 +552,7 @@ AndroidX.Compose.Image.Image(AndroidX.Compose.UI.Graphics.Painter.Painter! paint AndroidX.Compose.Image.Image(AndroidX.Compose.UI.Graphics.Painter.Painter! painter) -> void AndroidX.Compose.Image.Image(int drawableResourceId, string? contentDescription) -> void AndroidX.Compose.Image.Image(int drawableResourceId) -> void +AndroidX.Compose.ImeAction AndroidX.Compose.InputChip AndroidX.Compose.InputChip.Avatar.get -> AndroidX.Compose.ComposableNode? AndroidX.Compose.InputChip.Avatar.set -> void @@ -559,9 +568,8 @@ AndroidX.Compose.InputChip.TrailingIcon.set -> void AndroidX.Compose.IState AndroidX.Compose.IState.Value.get -> T AndroidX.Compose.IStateFlow -AndroidX.Compose.ImeAction -AndroidX.Compose.KeyboardOptionsCompanion AndroidX.Compose.KeyboardActionsHelper +AndroidX.Compose.KeyboardOptionsCompanion AndroidX.Compose.KeyboardType AndroidX.Compose.LargeFlexibleTopAppBar AndroidX.Compose.LargeFlexibleTopAppBar.Actions.get -> AndroidX.Compose.ComposableNode? @@ -595,6 +603,8 @@ AndroidX.Compose.LaunchedEffect AndroidX.Compose.LaunchedEffect.LaunchedEffect(object? key1, object? key2, object? key3, System.Func! body) -> void AndroidX.Compose.LaunchedEffect.LaunchedEffect(object? key1, object? key2, System.Func! body) -> void AndroidX.Compose.LaunchedEffect.LaunchedEffect(object? key1, System.Func! body) -> void +AndroidX.Compose.Layout +AndroidX.Compose.Layout.Layout(System.Func!, AndroidX.Compose.Constraints, AndroidX.Compose.MeasureResult!>! measurePolicy) -> void AndroidX.Compose.LazyColumn AndroidX.Compose.LazyColumn.ContentPadding.get -> AndroidX.Compose.PaddingValues? AndroidX.Compose.LazyColumn.ContentPadding.set -> void @@ -691,6 +701,11 @@ AndroidX.Compose.MaterialTheme.Typography.get -> AndroidX.Compose.Material3.Typo AndroidX.Compose.MaterialTheme.Typography.set -> void AndroidX.Compose.MaterialTheme.UseDynamicColor.get -> bool AndroidX.Compose.MaterialTheme.UseDynamicColor.set -> void +AndroidX.Compose.Measurable +AndroidX.Compose.Measurable.Measure(AndroidX.Compose.Constraints constraints) -> AndroidX.Compose.UI.Layout.Placeable! +AndroidX.Compose.MeasureResult +AndroidX.Compose.MeasureScope +AndroidX.Compose.MeasureScope.Layout(int width, int height, System.Action! placementBlock) -> AndroidX.Compose.MeasureResult! AndroidX.Compose.MediumFlexibleTopAppBar AndroidX.Compose.MediumFlexibleTopAppBar.Actions.get -> AndroidX.Compose.ComposableNode? AndroidX.Compose.MediumFlexibleTopAppBar.Actions.set -> void @@ -965,6 +980,9 @@ AndroidX.Compose.PermanentNavigationDrawer.Content.set -> void AndroidX.Compose.PermanentNavigationDrawer.Drawer.get -> AndroidX.Compose.ComposableNode? AndroidX.Compose.PermanentNavigationDrawer.Drawer.set -> void AndroidX.Compose.PermanentNavigationDrawer.PermanentNavigationDrawer() -> void +AndroidX.Compose.PlacementScope +AndroidX.Compose.PlacementScope.Place(AndroidX.Compose.UI.Layout.Placeable! placeable, int x, int y, float zIndex = 0) -> void +AndroidX.Compose.PlacementScope.PlaceRelative(AndroidX.Compose.UI.Layout.Placeable! placeable, int x, int y, float zIndex = 0) -> void AndroidX.Compose.PrimaryScrollableTabRow AndroidX.Compose.PrimaryScrollableTabRow.PrimaryScrollableTabRow(int selectedTabIndex) -> void AndroidX.Compose.PrimaryTabRow @@ -1451,6 +1469,7 @@ override AndroidX.Compose.LargeFlexibleTopAppBar.Render(AndroidX.Compose.Runtime override AndroidX.Compose.LargeFloatingActionButton.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void override AndroidX.Compose.LargeTopAppBar.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void override AndroidX.Compose.LaunchedEffect.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void +override AndroidX.Compose.Layout.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void override AndroidX.Compose.LazyColumn.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void override AndroidX.Compose.LazyHorizontalGrid.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void override AndroidX.Compose.LazyHorizontalStaggeredGrid.Render(AndroidX.Compose.Runtime.IComposer! composer) -> void @@ -1662,6 +1681,9 @@ static AndroidX.Compose.CompositionLocal.Of(System.Func! defaultFactory) - static AndroidX.Compose.CompositionLocal.StaticOf(System.Func! defaultFactory) -> AndroidX.Compose.CompositionLocal! static AndroidX.Compose.CompositionLocal.Of(System.Func! defaultFactory) -> AndroidX.Compose.CompositionLocal! static AndroidX.Compose.CompositionLocal.StaticOf(System.Func! defaultFactory) -> AndroidX.Compose.CompositionLocal! +static AndroidX.Compose.Constraints.Create(int minWidth, int maxWidth, int minHeight, int maxHeight) -> AndroidX.Compose.Constraints +static AndroidX.Compose.Constraints.FixedHeight(int height) -> AndroidX.Compose.Constraints +static AndroidX.Compose.Constraints.FixedWidth(int width) -> AndroidX.Compose.Constraints static AndroidX.Compose.ContentScale.Crop.get -> AndroidX.Compose.ContentScale! static AndroidX.Compose.ContentScale.FillBounds.get -> AndroidX.Compose.ContentScale! static AndroidX.Compose.ContentScale.FillHeight.get -> AndroidX.Compose.ContentScale! @@ -2009,9 +2031,9 @@ static AndroidX.Compose.ImeAction.Previous.get -> int static AndroidX.Compose.ImeAction.Search.get -> int static AndroidX.Compose.ImeAction.Send.get -> int static AndroidX.Compose.ImeAction.Unspecified.get -> int +static AndroidX.Compose.KeyboardActionsHelper.Create(System.Action? onDone = null, System.Action? onGo = null, System.Action? onNext = null, System.Action? onPrevious = null, System.Action? onSearch = null, System.Action? onSend = null) -> AndroidX.Compose.Foundation.Text.KeyboardActions! static AndroidX.Compose.KeyboardOptionsCompanion.Default.get -> AndroidX.Compose.Foundation.Text.KeyboardOptions! static AndroidX.Compose.KeyboardOptionsCompanion.Get() -> AndroidX.Compose.Foundation.Text.KeyboardOptions.Companion! -static AndroidX.Compose.KeyboardActionsHelper.Create(System.Action? onDone = null, System.Action? onGo = null, System.Action? onNext = null, System.Action? onPrevious = null, System.Action? onSearch = null, System.Action? onSend = null) -> AndroidX.Compose.Foundation.Text.KeyboardActions! static AndroidX.Compose.KeyboardType.Ascii.get -> int static AndroidX.Compose.KeyboardType.Decimal.get -> int static AndroidX.Compose.KeyboardType.Email.get -> int