From cb758b7fdfbb8adfb29d538452a8ba7459cf6b85 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 3 Jun 2026 12:45:28 -0500 Subject: [PATCH 1/4] Bind ModalBottomSheet, BottomSheetScaffold, date/time pickers, and Tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #6 — completes the user-facing pieces of issue #3 by binding the remaining Material 3 dialogs, sheets, pickers, and the Tooltip composable. Mirrors PR #6's named-slot facade + $default bitmask pattern, with raw JNI bridges for any composable or remember*State builder stripped from the binding. Composables added (in src/ComposeNet.Compose/Composables.cs): - ModalBottomSheet (state via direct C# call to RememberModalBottomSheetState) - BottomSheetScaffold (state via direct C# call to RememberBottomSheetScaffoldState) - DatePickerDialog + DatePicker (state via JNI rememberDatePickerState-EU0dCGE) - TimePicker + TimePickerDialog (state via JNI rememberTimePickerState) - Tooltip (TooltipBox) with TooltipDefaults.rememberPlainTooltipPositionProvider Issue #3 also listed BasicTooltipBox and BasicEdgeToEdgeDialog. Both live in androidx.compose.material3.internal — they are foundation primitives that the public Tooltip / Dialog / AlertDialog wrappers are built on top of, and there is no user-facing scenario where they're preferable to the public composables we already bind. Skipped on purpose, with comments in Composables.cs explaining why. JNI plumbing: 7 composable bridges + 4 state-holder/helper bridges in ComposeBridges.cs, with full mangled descriptors documented inline so future readers can verify against the AAR via javap. Each facade has a corresponding [Flags] $default enum generated via the new declarative `[assembly: ComposeDefaults("FooDefault", ...)]` form introduced in PR #8 — names prefixed with `!` consume a bit position but emit no enum member, covering the params the caller always provides. Sample (src/ComposeNet.Sample/MainActivity.cs) gains a button row that toggles each new dialog/sheet via MutableState, plus an inline Tooltip anchor so the popup can be exercised by long-pressing on the emulator. Build + smoke-tested on emulator-5554: app launches cleanly, no NoSuchMethodError / UnsatisfiedLinkError / NPE in the app pid. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ComposeNet.Compose/Composables.cs | 334 +++++++++++++- src/ComposeNet.Compose/ComposeBridges.cs | 513 ++++++++++++++++++++++ src/ComposeNet.Compose/ComposeDefaults.cs | 40 ++ src/ComposeNet.Sample/MainActivity.cs | 91 +++- 4 files changed, 966 insertions(+), 12 deletions(-) diff --git a/src/ComposeNet.Compose/Composables.cs b/src/ComposeNet.Compose/Composables.cs index ee7eca5b..e45288f3 100644 --- a/src/ComposeNet.Compose/Composables.cs +++ b/src/ComposeNet.Compose/Composables.cs @@ -3,7 +3,6 @@ using AndroidX.Compose.Foundation.Layout; using AndroidX.Compose.Material3; using AndroidX.Compose.Runtime; - namespace ComposeNet; // ---- Leaf: Text ---- @@ -609,3 +608,336 @@ internal override void Render(IComposer composer) ComposeBridges.NavigationRailItem(_selected, click, icon, label, defaults, composer); } } +// ---- ModalBottomSheet ---- + +/// +/// Material 3 ModalBottomSheet. Opens a modal sheet anchored to +/// the bottom of the screen. +/// +/// The SheetState is created inside via +/// — that +/// builder is NOT stripped from the binding, so we call it directly on +/// the bound C# method instead of going through JNI. Use the visibility +/// pattern from : gate the entire instance on +/// a of . +/// +/// +/// var show = Remember(() => new MutableState<bool>(false)); +/// show.Value +/// ? new ModalBottomSheet(onDismissRequest: () => show.Value = false) +/// { +/// new Column { new Text("Sheet contents") }, +/// } +/// : null +/// +/// +public sealed class ModalBottomSheet : ComposableContainer +{ + readonly System.Action _onDismissRequest; + public ModalBottomSheet(System.Action onDismissRequest) => _onDismissRequest = onDismissRequest; + + /// Optional drag handle drawn at the top of the sheet. + public ComposableNode? DragHandle { get; set; } + + internal override void Render(IComposer composer) + { + // Bound C# call — RememberModalBottomSheetState is NOT stripped. + // p3 is the (renamed) Composer parameter; _changed = 3 means bits + // 0 and 1 (skipPartiallyExpanded, confirmValueChange) are + // defaulted by Compose. + var sheetState = ModalBottomSheetKt.RememberModalBottomSheetState( + skipPartiallyExpanded: false, + confirmValueChange: null, + _composer: composer, + p3: 0, + _changed: 3); + + var onDismiss = new ComposableLambda0(_onDismissRequest); + var content = new ComposableLambda3(c => RenderChildren(c)); + ComposableLambda2? dragHandle = DragHandle is null ? null + : new ComposableLambda2(c => DragHandle.Render(c)); + + int defaults = (int)ModalBottomSheetDefault.All; + if (dragHandle is not null) defaults &= ~(int)ModalBottomSheetDefault.DragHandle; + + ComposeBridges.ModalBottomSheet( + onDismissRequest: onDismiss, + sheetState: ((Java.Lang.Object)sheetState).Handle, + dragHandle: dragHandle, + content: content, + defaults: defaults, + composer: composer); + } +} + +// ---- BottomSheetScaffold ---- + +/// +/// Material 3 BottomSheetScaffold. Hosts a persistent bottom +/// sheet alongside a primary content area, plus optional top bar and +/// snackbar slots. +/// +/// +/// is called directly on the C# binding (NOT stripped), threading the +/// composer through. +/// +public sealed class BottomSheetScaffold : ComposableContainer +{ + /// Required: the persistent bottom-sheet content. + public ComposableNode? SheetContent { get; set; } + + /// Optional: the sheet's drag handle. + public ComposableNode? SheetDragHandle { get; set; } + + /// Optional: persistent top bar above the main content. + public ComposableNode? TopBar { get; set; } + + internal override void Render(IComposer composer) + { + if (SheetContent is null) + throw new System.InvalidOperationException( + "BottomSheetScaffold.SheetContent is required."); + + // Bound C# call — RememberBottomSheetScaffoldState is NOT stripped. + var scaffoldState = BottomSheetScaffoldKt.RememberBottomSheetScaffoldState( + bottomSheetState: null, + snackbarHostState: null, + _composer: composer, + p3: 0, + _changed: 3); + + var sheet = new ComposableLambda3(c => SheetContent.Render(c)); + var content = new ComposableLambda3(c => RenderChildren(c)); + + ComposableLambda2? dragHandle = SheetDragHandle is null ? null + : new ComposableLambda2(c => SheetDragHandle.Render(c)); + ComposableLambda2? topBar = TopBar is null ? null + : new ComposableLambda2(c => TopBar.Render(c)); + + int defaults = (int)BottomSheetScaffoldDefault.All; + if (dragHandle is not null) defaults &= ~(int)BottomSheetScaffoldDefault.SheetDragHandle; + if (topBar is not null) defaults &= ~(int)BottomSheetScaffoldDefault.TopBar; + + ComposeBridges.BottomSheetScaffold( + sheetContent: sheet, + scaffoldState: ((Java.Lang.Object)scaffoldState).Handle, + sheetDragHandle: dragHandle, + topBar: topBar, + snackbarHost: null, + content: content, + defaults: defaults, + composer: composer); + } +} + +// ---- DatePickerDialog ---- + +/// +/// Material 3 DatePickerDialog. The DatePickerState +/// builder (rememberDatePickerState-EU0dCGE) is mangled and +/// stripped from the binding, so we resolve it through JNI inside the +/// facade. Place a +/// (or any composable) inside . +/// +public sealed class DatePickerDialog : ComposableNode +{ + readonly System.Action _onDismissRequest; + public DatePickerDialog(System.Action onDismissRequest) => _onDismissRequest = onDismissRequest; + + /// Required: the affirmative button (no Kotlin default). + public ComposableNode? ConfirmButton { get; set; } + + /// Optional: secondary button. + public ComposableNode? DismissButton { get; set; } + + /// Required: dialog body — typically a . + public ComposableNode? Body { get; set; } + + internal override void Render(IComposer composer) + { + if (ConfirmButton is null) + throw new System.InvalidOperationException( + "DatePickerDialog.ConfirmButton is required."); + if (Body is null) + throw new System.InvalidOperationException( + "DatePickerDialog.Body is required (the dialog's content slot)."); + + var onDismiss = new ComposableLambda0(_onDismissRequest); + var confirm = new ComposableLambda2(c => ConfirmButton.Render(c)); + var content = new ComposableLambda3(c => Body.Render(c)); + ComposableLambda2? dismiss = DismissButton is null ? null + : new ComposableLambda2(c => DismissButton.Render(c)); + + int defaults = (int)DatePickerDialogDefault.All; + if (dismiss is not null) defaults &= ~(int)DatePickerDialogDefault.DismissButton; + + ComposeBridges.DatePickerDialog( + onDismissRequest: onDismiss, + confirmButton: confirm, + dismissButton: dismiss, + content: content, + defaults: defaults, + composer: composer); + } +} + +// ---- DatePicker (the inline picker control used inside DatePickerDialog) ---- + +/// +/// Material 3 DatePicker. The Kotlin DatePickerKt.DatePicker +/// composable IS exposed by the binding, but its $default bitmask +/// param isn't user-visible (the binding drops the trailing +/// $default parameter on @Composable functions), so we go through +/// raw JNI to set it. Place inside 's body. +/// +public sealed class DatePicker : ComposableNode +{ + internal override void Render(IComposer composer) + { + var stateHandle = ComposeBridges.RememberDatePickerState(composer); + ComposeBridges.DatePicker(stateHandle, composer); + } +} + +// ---- TimePicker ---- + +/// +/// Material 3 TimePicker. Resolves TimePickerState via +/// raw JNI (rememberTimePickerState takes a +/// so it requires the composer-aware bridge). +/// +public sealed class TimePicker : ComposableNode +{ + readonly int _initialHour; + readonly int _initialMinute; + readonly bool _is24Hour; + public TimePicker(int initialHour = 12, int initialMinute = 0, bool is24Hour = true) + { + _initialHour = initialHour; + _initialMinute = initialMinute; + _is24Hour = is24Hour; + } + + internal override void Render(IComposer composer) + { + var stateHandle = ComposeBridges.RememberTimePickerState(_initialHour, _initialMinute, _is24Hour, composer); + ComposeBridges.TimePicker(stateHandle, (int)TimePickerDefault.All, composer); + } +} + +// ---- TimePickerDialog ---- + +/// +/// Material 3 TimePickerDialog. Both ConfirmButton and +/// DismissButton are required by Compose; +/// and are optional. Place a +/// in the body. +/// +public sealed class TimePickerDialog : ComposableNode +{ + readonly System.Action _onDismissRequest; + public TimePickerDialog(System.Action onDismissRequest) => _onDismissRequest = onDismissRequest; + + public ComposableNode? ConfirmButton { get; set; } + public ComposableNode? DismissButton { get; set; } + public ComposableNode? Title { get; set; } + public ComposableNode? ModeToggleButton { get; set; } + /// Required: dialog body — typically a . + public ComposableNode? Body { get; set; } + + internal override void Render(IComposer composer) + { + if (ConfirmButton is null || DismissButton is null) + throw new System.InvalidOperationException( + "TimePickerDialog.ConfirmButton and DismissButton are both required."); + if (Body is null) + throw new System.InvalidOperationException( + "TimePickerDialog.Body is required (the dialog's content slot)."); + + var onDismiss = new ComposableLambda0(_onDismissRequest); + var confirm = new ComposableLambda2(c => ConfirmButton.Render(c)); + var dismiss = new ComposableLambda2(c => DismissButton.Render(c)); + var content = new ComposableLambda3(c => Body.Render(c)); + ComposableLambda2? title = Title is null ? null + : new ComposableLambda2(c => Title.Render(c)); + ComposableLambda2? toggle = ModeToggleButton is null ? null + : new ComposableLambda2(c => ModeToggleButton.Render(c)); + + int defaults = (int)TimePickerDialogDefault.All; + if (title is not null) defaults &= ~(int)TimePickerDialogDefault.Title; + if (toggle is not null) defaults &= ~(int)TimePickerDialogDefault.ModeToggleButton; + + ComposeBridges.TimePickerDialog( + onDismissRequest: onDismiss, + confirmButton: confirm, + dismissButton: dismiss, + title: title, + modeToggleButton: toggle, + content: content, + defaults: defaults, + composer: composer); + } +} + +// ---- Tooltip (TooltipBox + plain styled tooltip) ---- + +/// +/// Material 3 TooltipBox with a plain tooltip popup. The popup +/// position provider comes from TooltipDefaults.rememberPlainTooltipPositionProvider; +/// TooltipState from rememberTooltipState. Both are +/// resolved via JNI inside . +/// +/// Use named-property syntax: is the popup body shown +/// on long-press / hover, is the always-visible +/// content the popup attaches to. +/// +public sealed class Tooltip : ComposableNode +{ + readonly bool _isPersistent; + public Tooltip(bool isPersistent = false) => _isPersistent = isPersistent; + + /// Required: the popup body shown on long-press / hover. + public ComposableNode? Tip { get; set; } + + /// Required: the always-visible anchor the tooltip attaches to. + public ComposableNode? Anchor { get; set; } + + internal override void Render(IComposer composer) + { + if (Tip is null || Anchor is null) + throw new System.InvalidOperationException( + "Tooltip requires both Tip (popup body) and Anchor (visible content)."); + + var positionProvider = ComposeBridges.RememberPlainTooltipPositionProvider(composer); + var stateHandle = ComposeBridges.RememberTooltipState(_isPersistent, composer); + + var tooltip = new ComposableLambda3(c => Tip.Render(c)); + var anchor = new ComposableLambda2(c => Anchor.Render(c)); + + ComposeBridges.TooltipBox( + positionProvider: positionProvider, + tooltip: tooltip, + state: stateHandle, + content: anchor, + defaults: (int)TooltipBoxDefault.All, + composer: composer); + } +} + +// ---- (BasicTooltip intentionally not bound) ---- +// +// `androidx.compose.material3.internal.BasicTooltipKt.BasicTooltipBox` +// is the foundation primitive that the public `TooltipBox` is built on +// top of. There's no user-facing scenario where it's preferable to +// `Tooltip` (the public wrapper we already bind), so it's skipped on +// purpose for the same reason as `BasicEdgeToEdgeDialog`. + +// ---- (BasicEdgeToEdgeDialog intentionally not bound) ---- +// +// `androidx.compose.material3.internal.BasicEdgeToEdgeDialog` is an +// internal implementation primitive that upstream `Dialog`, +// `AlertDialog`, `DatePickerDialog`, and `TimePickerDialog` are built +// on top of. It draws content edge-to-edge with no scrim, no insets, +// and no built-in dismiss handling — there is no user-facing scenario +// where it's preferable to one of the public dialog wrappers we +// already bind. Skipped on purpose. \ No newline at end of file diff --git a/src/ComposeNet.Compose/ComposeBridges.cs b/src/ComposeNet.Compose/ComposeBridges.cs index c23dcc60..27908ebd 100644 --- a/src/ComposeNet.Compose/ComposeBridges.cs +++ b/src/ComposeNet.Compose/ComposeBridges.cs @@ -373,6 +373,519 @@ public static unsafe void AlertDialog( } } + // androidx.compose.material3.ModalBottomSheet_androidKt.ModalBottomSheet-dYc4hso( + // onDismissRequest, modifier, sheetState, sheetMaxWidth, shape, + // containerColor, contentColor, tonalElevation, scrimColor, dragHandle, + // windowInsets, properties, content, composer, $changed, $changed1, $default) + // + // 13 user params; bits 0 (onDismissRequest), 2 (sheetState), 12 (content) + // are always provided. dragHandle (bit 9) is the only optional slot. + const string ModalBottomSheetSig = + "(Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/SheetState;FLandroidx/compose/ui/graphics/Shape;JJFJ" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/layout/WindowInsets;" + + "Landroidx/compose/material3/ModalBottomSheetProperties;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V"; + + static IntPtr s_modalBottomSheetClass; + static IntPtr s_modalBottomSheetMethod; + + public static unsafe void ModalBottomSheet( + IFunction0 onDismissRequest, + IntPtr sheetState, + IFunction2? dragHandle, + IFunction3 content, + int defaults, + IComposer composer) + { + if (s_modalBottomSheetClass == IntPtr.Zero) + { + s_modalBottomSheetClass = JNIEnv.FindClass("androidx/compose/material3/ModalBottomSheet_androidKt"); + s_modalBottomSheetMethod = JNIEnv.GetStaticMethodID(s_modalBottomSheetClass, "ModalBottomSheet-dYc4hso", ModalBottomSheetSig); + } + + JValue* args = stackalloc JValue[17]; + args[0] = new JValue(((Java.Lang.Object)onDismissRequest).Handle); + args[1] = new JValue(IntPtr.Zero); // modifier + args[2] = new JValue(sheetState); + args[3] = new JValue(0f); // sheetMaxWidth + args[4] = new JValue(IntPtr.Zero); // shape + args[5] = new JValue(0L); // containerColor + args[6] = new JValue(0L); // contentColor + args[7] = new JValue(0f); // tonalElevation + args[8] = new JValue(0L); // scrimColor + args[9] = new JValue(dragHandle is null ? IntPtr.Zero : ((Java.Lang.Object)dragHandle).Handle); + args[10] = new JValue(IntPtr.Zero); // windowInsets + args[11] = new JValue(IntPtr.Zero); // properties + args[12] = new JValue(((Java.Lang.Object)content).Handle); + args[13] = new JValue(((Java.Lang.Object)composer).Handle); + args[14] = new JValue(0); // $changed + args[15] = new JValue(0); // $changed1 + args[16] = new JValue(defaults); // $default + try + { + JNIEnv.CallStaticVoidMethod(s_modalBottomSheetClass, s_modalBottomSheetMethod, args); + } + finally + { + GC.KeepAlive(onDismissRequest); + GC.KeepAlive(dragHandle); + GC.KeepAlive(content); + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.BottomSheetScaffoldKt.BottomSheetScaffold-sdMYb0k( + // sheetContent, modifier, scaffoldState, sheetPeekHeight, sheetMaxWidth, + // sheetShape, sheetContainerColor, sheetContentColor, sheetTonalElevation, + // sheetShadowElevation, sheetDragHandle, sheetSwipeEnabled, topBar, + // snackbarHost, containerColor, contentColor, content, composer, + // $changed, $changed1, $default) + // + // 17 user params; provided bits: 0 (sheetContent), 2 (scaffoldState), + // 16 (content). Optional slots: 10 (sheetDragHandle), 12 (topBar), + // 13 (snackbarHost). + const string BottomSheetScaffoldSig = + "(Lkotlin/jvm/functions/Function3;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/BottomSheetScaffoldState;FF" + + "Landroidx/compose/ui/graphics/Shape;JJFF" + + "Lkotlin/jvm/functions/Function2;Z" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;JJ" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V"; + + static IntPtr s_bottomSheetScaffoldClass; + static IntPtr s_bottomSheetScaffoldMethod; + + public static unsafe void BottomSheetScaffold( + IFunction3 sheetContent, + IntPtr scaffoldState, + IFunction2? sheetDragHandle, + IFunction2? topBar, + IFunction3? snackbarHost, + IFunction3 content, + int defaults, + IComposer composer) + { + if (s_bottomSheetScaffoldClass == IntPtr.Zero) + { + s_bottomSheetScaffoldClass = JNIEnv.FindClass("androidx/compose/material3/BottomSheetScaffoldKt"); + s_bottomSheetScaffoldMethod = JNIEnv.GetStaticMethodID(s_bottomSheetScaffoldClass, "BottomSheetScaffold-sdMYb0k", BottomSheetScaffoldSig); + } + + JValue* args = stackalloc JValue[21]; + args[0] = new JValue(((Java.Lang.Object)sheetContent).Handle); + args[1] = new JValue(IntPtr.Zero); // modifier + args[2] = new JValue(scaffoldState); + args[3] = new JValue(0f); // sheetPeekHeight + args[4] = new JValue(0f); // sheetMaxWidth + args[5] = new JValue(IntPtr.Zero); // sheetShape + args[6] = new JValue(0L); // sheetContainerColor + args[7] = new JValue(0L); // sheetContentColor + args[8] = new JValue(0f); // sheetTonalElevation + args[9] = new JValue(0f); // sheetShadowElevation + args[10] = new JValue(sheetDragHandle is null ? IntPtr.Zero : ((Java.Lang.Object)sheetDragHandle).Handle); + args[11] = new JValue(true); // sheetSwipeEnabled + args[12] = new JValue(topBar is null ? IntPtr.Zero : ((Java.Lang.Object)topBar).Handle); + args[13] = new JValue(snackbarHost is null ? IntPtr.Zero : ((Java.Lang.Object)snackbarHost).Handle); + args[14] = new JValue(0L); // containerColor + args[15] = new JValue(0L); // contentColor + args[16] = new JValue(((Java.Lang.Object)content).Handle); + args[17] = new JValue(((Java.Lang.Object)composer).Handle); + args[18] = new JValue(0); // $changed + args[19] = new JValue(0); // $changed1 + args[20] = new JValue(defaults); // $default + try + { + JNIEnv.CallStaticVoidMethod(s_bottomSheetScaffoldClass, s_bottomSheetScaffoldMethod, args); + } + finally + { + GC.KeepAlive(sheetContent); + GC.KeepAlive(sheetDragHandle); + GC.KeepAlive(topBar); + GC.KeepAlive(snackbarHost); + GC.KeepAlive(content); + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.DatePickerDialog_androidKt.DatePickerDialog-GmEhDVc( + // onDismissRequest, confirmButton, modifier, dismissButton, shape, + // tonalElevation, colors, properties, content, composer, $changed, $default) + // + // 9 user params; bits 0 (onDismissRequest), 1 (confirmButton), + // 8 (content) always provided. dismissButton (bit 3) is the only + // optional Function2 slot. + const string DatePickerDialogSig = + "(Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/graphics/Shape;F" + + "Landroidx/compose/material3/DatePickerColors;" + + "Landroidx/compose/ui/window/DialogProperties;" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V"; + + static IntPtr s_datePickerDialogClass; + static IntPtr s_datePickerDialogMethod; + + public static unsafe void DatePickerDialog( + IFunction0 onDismissRequest, + IFunction2 confirmButton, + IFunction2? dismissButton, + IFunction3 content, + int defaults, + IComposer composer) + { + if (s_datePickerDialogClass == IntPtr.Zero) + { + s_datePickerDialogClass = JNIEnv.FindClass("androidx/compose/material3/DatePickerDialog_androidKt"); + s_datePickerDialogMethod = JNIEnv.GetStaticMethodID(s_datePickerDialogClass, "DatePickerDialog-GmEhDVc", DatePickerDialogSig); + } + + JValue* args = stackalloc JValue[12]; + args[0] = new JValue(((Java.Lang.Object)onDismissRequest).Handle); + args[1] = new JValue(((Java.Lang.Object)confirmButton).Handle); + args[2] = new JValue(IntPtr.Zero); // modifier + args[3] = new JValue(dismissButton is null ? IntPtr.Zero : ((Java.Lang.Object)dismissButton).Handle); + args[4] = new JValue(IntPtr.Zero); // shape + args[5] = new JValue(0f); // tonalElevation + args[6] = new JValue(IntPtr.Zero); // colors + args[7] = new JValue(IntPtr.Zero); // properties + args[8] = new JValue(((Java.Lang.Object)content).Handle); + args[9] = new JValue(((Java.Lang.Object)composer).Handle); + args[10] = new JValue(0); // $changed + args[11] = new JValue(defaults); // $default + try + { + JNIEnv.CallStaticVoidMethod(s_datePickerDialogClass, s_datePickerDialogMethod, args); + } + finally + { + GC.KeepAlive(onDismissRequest); + GC.KeepAlive(confirmButton); + GC.KeepAlive(dismissButton); + GC.KeepAlive(content); + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.TimePickerKt.TimePicker-mT9BvqQ( + // state, modifier, colors, layoutType, composer, $changed, $default) + // + // 4 user params; bit 0 (state) always provided. + const string TimePickerSig = + "(Landroidx/compose/material3/TimePickerState;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/TimePickerColors;ILandroidx/compose/runtime/Composer;II)V"; + + static IntPtr s_timePickerClass; + static IntPtr s_timePickerMethod; + + public static unsafe void TimePicker(IntPtr state, int defaults, IComposer composer) + { + if (s_timePickerClass == IntPtr.Zero) + { + s_timePickerClass = JNIEnv.FindClass("androidx/compose/material3/TimePickerKt"); + s_timePickerMethod = JNIEnv.GetStaticMethodID(s_timePickerClass, "TimePicker-mT9BvqQ", TimePickerSig); + } + + JValue* args = stackalloc JValue[7]; + args[0] = new JValue(state); + args[1] = new JValue(IntPtr.Zero); // modifier + args[2] = new JValue(IntPtr.Zero); // colors + args[3] = new JValue(0); // layoutType + args[4] = new JValue(((Java.Lang.Object)composer).Handle); + args[5] = new JValue(0); // $changed + args[6] = new JValue(defaults); // $default + try + { + JNIEnv.CallStaticVoidMethod(s_timePickerClass, s_timePickerMethod, args); + } + finally + { + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.TimePickerDialogKt.TimePickerDialog-FItCLgY( + // onDismissRequest, confirmButton, dismissButton, modifier, properties, + // title, modeToggleButton, shape, containerColor, content, composer, + // $changed, $default) + // + // 10 user params. confirmButton (bit 1), dismissButton (bit 2), and + // content (bit 9) are required slots. title (bit 5) and + // modeToggleButton (bit 6) are optional. + const string TimePickerDialogSig = + "(Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/ui/window/DialogProperties;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;" + + "Landroidx/compose/ui/graphics/Shape;J" + + "Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V"; + + static IntPtr s_timePickerDialogClass; + static IntPtr s_timePickerDialogMethod; + + public static unsafe void TimePickerDialog( + IFunction0 onDismissRequest, + IFunction2 confirmButton, + IFunction2 dismissButton, + IFunction2? title, + IFunction2? modeToggleButton, + IFunction3 content, + int defaults, + IComposer composer) + { + if (s_timePickerDialogClass == IntPtr.Zero) + { + s_timePickerDialogClass = JNIEnv.FindClass("androidx/compose/material3/TimePickerDialogKt"); + s_timePickerDialogMethod = JNIEnv.GetStaticMethodID(s_timePickerDialogClass, "TimePickerDialog-FItCLgY", TimePickerDialogSig); + } + + JValue* args = stackalloc JValue[13]; + args[0] = new JValue(((Java.Lang.Object)onDismissRequest).Handle); + args[1] = new JValue(((Java.Lang.Object)confirmButton).Handle); + args[2] = new JValue(((Java.Lang.Object)dismissButton).Handle); + args[3] = new JValue(IntPtr.Zero); // modifier + args[4] = new JValue(IntPtr.Zero); // properties + args[5] = new JValue(title is null ? IntPtr.Zero : ((Java.Lang.Object)title).Handle); + args[6] = new JValue(modeToggleButton is null ? IntPtr.Zero : ((Java.Lang.Object)modeToggleButton).Handle); + args[7] = new JValue(IntPtr.Zero); // shape + args[8] = new JValue(0L); // containerColor + args[9] = new JValue(((Java.Lang.Object)content).Handle); + args[10] = new JValue(((Java.Lang.Object)composer).Handle); + args[11] = new JValue(0); // $changed + args[12] = new JValue(defaults); // $default + try + { + JNIEnv.CallStaticVoidMethod(s_timePickerDialogClass, s_timePickerDialogMethod, args); + } + finally + { + GC.KeepAlive(onDismissRequest); + GC.KeepAlive(confirmButton); + GC.KeepAlive(dismissButton); + GC.KeepAlive(title); + GC.KeepAlive(modeToggleButton); + GC.KeepAlive(content); + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.TooltipKt.TooltipBox (7-user-param overload): + // (positionProvider, tooltip, state, modifier, focusable, enableUserInput, + // content, composer, $changed, $default) + // + // Bits 0 (positionProvider), 1 (tooltip), 2 (state), 6 (content) provided. + const string TooltipBoxSig = + "(Landroidx/compose/ui/window/PopupPositionProvider;" + + "Lkotlin/jvm/functions/Function3;" + + "Landroidx/compose/material3/TooltipState;" + + "Landroidx/compose/ui/Modifier;ZZ" + + "Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V"; + + static IntPtr s_tooltipBoxClass; + static IntPtr s_tooltipBoxMethod; + + public static unsafe void TooltipBox( + IntPtr positionProvider, + IFunction3 tooltip, + IntPtr state, + IFunction2 content, + int defaults, + IComposer composer) + { + if (s_tooltipBoxClass == IntPtr.Zero) + { + s_tooltipBoxClass = JNIEnv.FindClass("androidx/compose/material3/TooltipKt"); + s_tooltipBoxMethod = JNIEnv.GetStaticMethodID(s_tooltipBoxClass, "TooltipBox", TooltipBoxSig); + } + + JValue* args = stackalloc JValue[10]; + args[0] = new JValue(positionProvider); + args[1] = new JValue(((Java.Lang.Object)tooltip).Handle); + args[2] = new JValue(state); + args[3] = new JValue(IntPtr.Zero); // modifier + args[4] = new JValue(true); // focusable + args[5] = new JValue(true); // enableUserInput + args[6] = new JValue(((Java.Lang.Object)content).Handle); + args[7] = new JValue(((Java.Lang.Object)composer).Handle); + args[8] = new JValue(0); // $changed + args[9] = new JValue(defaults); // $default + try + { + JNIEnv.CallStaticVoidMethod(s_tooltipBoxClass, s_tooltipBoxMethod, args); + } + finally + { + GC.KeepAlive(tooltip); + GC.KeepAlive(content); + GC.KeepAlive(composer); + } + } + + // androidx.compose.material3.DatePickerKt.DatePicker( + // state, modifier, dateFormatter, colors, title, headline, + // showModeToggle, requestFocus, composer, $changed, $default) + // + // 8 user params; bit 0 (state) provided. requestFocus is the + // optional focus requester (defaultable). + const string DatePickerSig = + "(Landroidx/compose/material3/DatePickerState;Landroidx/compose/ui/Modifier;" + + "Landroidx/compose/material3/DatePickerFormatter;" + + "Landroidx/compose/material3/DatePickerColors;" + + "Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Z" + + "Landroidx/compose/ui/focus/FocusRequester;" + + "Landroidx/compose/runtime/Composer;II)V"; + + static IntPtr s_datePickerClass; + static IntPtr s_datePickerMethod; + + public static unsafe void DatePicker(IntPtr state, IComposer composer) + { + if (s_datePickerClass == IntPtr.Zero) + { + s_datePickerClass = JNIEnv.FindClass("androidx/compose/material3/DatePickerKt"); + s_datePickerMethod = JNIEnv.GetStaticMethodID(s_datePickerClass, "DatePicker", DatePickerSig); + } + + // Bits 1..7 defaulted (modifier..requestFocus); bit 0 (state) provided. + const int defaults = 0b11111110; + + JValue* args = stackalloc JValue[11]; + args[0] = new JValue(state); + args[1] = new JValue(IntPtr.Zero); // modifier + args[2] = new JValue(IntPtr.Zero); // dateFormatter + args[3] = new JValue(IntPtr.Zero); // colors + args[4] = new JValue(IntPtr.Zero); // title + args[5] = new JValue(IntPtr.Zero); // headline + args[6] = new JValue(true); // showModeToggle + args[7] = new JValue(IntPtr.Zero); // requestFocus + args[8] = new JValue(((Java.Lang.Object)composer).Handle); + args[9] = new JValue(0); // $changed + args[10] = new JValue(defaults); // $default + JNIEnv.CallStaticVoidMethod(s_datePickerClass, s_datePickerMethod, args); + } + + // ---- State-holder bridges ---- + // + // Every `remember*State` builder is itself @Composable so it takes a + // trailing Composer + $changed + $default. The dotnet/android-libraries + // binding generator strips the mangled overloads (DatePickerState's + // `-EU0dCGE`), so we go through raw JNI for those. We always return + // the IntPtr — the caller threads it back into a composable bridge + // as a JValue argument without ever materialising a managed wrapper. + + // androidx.compose.material3.DatePickerKt.rememberDatePickerState-EU0dCGE( + // initialSelectedDateMillis, initialDisplayedMonthMillis, yearRange, + // initialDisplayMode, selectableDates, composer, $changed, $default) + const string RememberDatePickerStateSig = + "(Ljava/lang/Long;Ljava/lang/Long;Lkotlin/ranges/IntRange;I" + + "Landroidx/compose/material3/SelectableDates;" + + "Landroidx/compose/runtime/Composer;II)Landroidx/compose/material3/DatePickerState;"; + + static IntPtr s_rememberDatePickerStateClass; + static IntPtr s_rememberDatePickerStateMethod; + + public static unsafe IntPtr RememberDatePickerState(IComposer composer) + { + if (s_rememberDatePickerStateClass == IntPtr.Zero) + { + s_rememberDatePickerStateClass = JNIEnv.FindClass("androidx/compose/material3/DatePickerKt"); + s_rememberDatePickerStateMethod = JNIEnv.GetStaticMethodID(s_rememberDatePickerStateClass, "rememberDatePickerState-EU0dCGE", RememberDatePickerStateSig); + } + + JValue* args = stackalloc JValue[8]; + args[0] = new JValue(IntPtr.Zero); // initialSelectedDateMillis + args[1] = new JValue(IntPtr.Zero); // initialDisplayedMonthMillis + args[2] = new JValue(IntPtr.Zero); // yearRange + args[3] = new JValue(0); // initialDisplayMode + args[4] = new JValue(IntPtr.Zero); // selectableDates + args[5] = new JValue(((Java.Lang.Object)composer).Handle); + args[6] = new JValue(0); // $changed + args[7] = new JValue(0b11111); // $default — all 5 user params defaulted + return JNIEnv.CallStaticObjectMethod(s_rememberDatePickerStateClass, s_rememberDatePickerStateMethod, args); + } + + // androidx.compose.material3.TimePickerKt.rememberTimePickerState( + // initialHour, initialMinute, is24Hour, composer, $changed, $default) + const string RememberTimePickerStateSig = + "(IIZLandroidx/compose/runtime/Composer;II)Landroidx/compose/material3/TimePickerState;"; + + static IntPtr s_rememberTimePickerStateClass; + static IntPtr s_rememberTimePickerStateMethod; + + public static unsafe IntPtr RememberTimePickerState(int initialHour, int initialMinute, bool is24Hour, IComposer composer) + { + if (s_rememberTimePickerStateClass == IntPtr.Zero) + { + s_rememberTimePickerStateClass = JNIEnv.FindClass("androidx/compose/material3/TimePickerKt"); + s_rememberTimePickerStateMethod = JNIEnv.GetStaticMethodID(s_rememberTimePickerStateClass, "rememberTimePickerState", RememberTimePickerStateSig); + } + + JValue* args = stackalloc JValue[6]; + args[0] = new JValue(initialHour); + args[1] = new JValue(initialMinute); + args[2] = new JValue(is24Hour); + args[3] = new JValue(((Java.Lang.Object)composer).Handle); + args[4] = new JValue(0); // $changed + args[5] = new JValue(0); // $default — all 3 provided + return JNIEnv.CallStaticObjectMethod(s_rememberTimePickerStateClass, s_rememberTimePickerStateMethod, args); + } + + // androidx.compose.material3.TooltipKt.rememberTooltipState( + // initialIsVisible, isPersistent, mutatorMutex, composer, $changed, $default) + const string RememberTooltipStateSig = + "(ZZLandroidx/compose/foundation/MutatorMutex;Landroidx/compose/runtime/Composer;II)Landroidx/compose/material3/TooltipState;"; + + static IntPtr s_rememberTooltipStateClass; + static IntPtr s_rememberTooltipStateMethod; + + public static unsafe IntPtr RememberTooltipState(bool isPersistent, IComposer composer) + { + if (s_rememberTooltipStateClass == IntPtr.Zero) + { + s_rememberTooltipStateClass = JNIEnv.FindClass("androidx/compose/material3/TooltipKt"); + s_rememberTooltipStateMethod = JNIEnv.GetStaticMethodID(s_rememberTooltipStateClass, "rememberTooltipState", RememberTooltipStateSig); + } + + JValue* args = stackalloc JValue[6]; + args[0] = new JValue(false); // initialIsVisible + args[1] = new JValue(isPersistent); + args[2] = new JValue(IntPtr.Zero); // mutatorMutex + args[3] = new JValue(((Java.Lang.Object)composer).Handle); + args[4] = new JValue(0); // $changed + args[5] = new JValue(0b101); // $default — bits 0 and 2 (initialIsVisible, mutatorMutex) + return JNIEnv.CallStaticObjectMethod(s_rememberTooltipStateClass, s_rememberTooltipStateMethod, args); + } + + // androidx.compose.material3.TooltipDefaults.INSTANCE.rememberPlainTooltipPositionProvider-kHDZbjc( + // spacingBetweenTooltipAndAnchor, composer, $changed, $default) + // + // Instance method on the Kotlin object singleton. We resolve INSTANCE + // once and reuse it across calls. + const string RememberPlainTooltipPositionProviderSig = + "(FLandroidx/compose/runtime/Composer;II)Landroidx/compose/ui/window/PopupPositionProvider;"; + + static IntPtr s_tooltipDefaultsInstance; + static IntPtr s_rememberPlainTooltipPositionProviderMethod; + + public static unsafe IntPtr RememberPlainTooltipPositionProvider(IComposer composer) + { + if (s_tooltipDefaultsInstance == IntPtr.Zero) + { + IntPtr cls = JNIEnv.FindClass("androidx/compose/material3/TooltipDefaults"); + IntPtr instanceFid = JNIEnv.GetStaticFieldID(cls, "INSTANCE", "Landroidx/compose/material3/TooltipDefaults;"); + s_tooltipDefaultsInstance = JNIEnv.NewGlobalRef(JNIEnv.GetStaticObjectField(cls, instanceFid)); + s_rememberPlainTooltipPositionProviderMethod = JNIEnv.GetMethodID(cls, "rememberPlainTooltipPositionProvider-kHDZbjc", RememberPlainTooltipPositionProviderSig); + } + + JValue* args = stackalloc JValue[4]; + args[0] = new JValue(0f); // spacing + args[1] = new JValue(((Java.Lang.Object)composer).Handle); + args[2] = new JValue(0); + args[3] = new JValue(1); // $default — spacing defaulted + return JNIEnv.CallObjectMethod(s_tooltipDefaultsInstance, s_rememberPlainTooltipPositionProviderMethod, args); + } + static unsafe void InvokeTextField(IntPtr cls, IntPtr method, string value, IFunction1 onValueChange, IComposer composer, int defaults) { IntPtr valueRef = JNIEnv.NewString(value); diff --git a/src/ComposeNet.Compose/ComposeDefaults.cs b/src/ComposeNet.Compose/ComposeDefaults.cs index 8a396381..9b789084 100644 --- a/src/ComposeNet.Compose/ComposeDefaults.cs +++ b/src/ComposeNet.Compose/ComposeDefaults.cs @@ -74,6 +74,46 @@ "icon", "title", "text", "shape", "containerColor", "iconContentColor", "titleContentColor", "textContentColor", "tonalElevation", "properties")] +// androidx.compose.material3.ModalBottomSheet_androidKt.ModalBottomSheet-dYc4hso: +// 13 user params; bits 0 (onDismissRequest), 2 (sheetState), 12 (content) always provided. +[assembly: ComposeDefaults("ModalBottomSheetDefault", + "!onDismissRequest", "modifier", "!sheetState", "sheetMaxWidth", "shape", + "containerColor", "contentColor", "tonalElevation", "scrimColor", "dragHandle", + "windowInsets", "properties", "!content")] + +// androidx.compose.material3.BottomSheetScaffoldKt.BottomSheetScaffold-sdMYb0k: +// 17 user params; bits 0 (sheetContent), 2 (scaffoldState), 16 (content) always provided. +[assembly: ComposeDefaults("BottomSheetScaffoldDefault", + "!sheetContent", "modifier", "!scaffoldState", "sheetPeekHeight", "sheetMaxWidth", + "sheetShape", "sheetContainerColor", "sheetContentColor", "sheetTonalElevation", + "sheetShadowElevation", "sheetDragHandle", "sheetSwipeEnabled", "topBar", + "snackbarHost", "containerColor", "contentColor", "!content")] + +// androidx.compose.material3.DatePickerDialog_androidKt.DatePickerDialog-GmEhDVc: +// 9 user params; bits 0 (onDismissRequest), 1 (confirmButton), 8 (content) always provided. +[assembly: ComposeDefaults("DatePickerDialogDefault", + "!onDismissRequest", "!confirmButton", "modifier", "dismissButton", "shape", + "tonalElevation", "colors", "properties", "!content")] + +// androidx.compose.material3.TimePickerKt.TimePicker-mT9BvqQ: +// 4 user params; bit 0 (state) always provided. +[assembly: ComposeDefaults("TimePickerDefault", + "!state", "modifier", "colors", "layoutType")] + +// androidx.compose.material3.TimePickerDialogKt.TimePickerDialog-FItCLgY: +// 10 user params; bits 0 (onDismissRequest), 1 (confirmButton), +// 2 (dismissButton), 9 (content) always provided. +[assembly: ComposeDefaults("TimePickerDialogDefault", + "!onDismissRequest", "!confirmButton", "!dismissButton", "modifier", "properties", + "title", "modeToggleButton", "shape", "containerColor", "!content")] + +// androidx.compose.material3.TooltipKt.TooltipBox (7-param overload): +// 7 user params; bits 0 (positionProvider), 1 (tooltip), 2 (state), +// 6 (content) always provided. +[assembly: ComposeDefaults("TooltipBoxDefault", + "!positionProvider", "!tooltip", "!state", "modifier", "focusable", + "enableUserInput", "!content")] + // androidx.compose.material3.TextFieldKt.TextField (String overload) AND // OutlinedTextFieldKt.OutlinedTextField (String overload): 23 user params, // bit 0 = value, bit 1 = onValueChange (both provided). diff --git a/src/ComposeNet.Sample/MainActivity.cs b/src/ComposeNet.Sample/MainActivity.cs index c226b22b..8c7dd961 100644 --- a/src/ComposeNet.Sample/MainActivity.cs +++ b/src/ComposeNet.Sample/MainActivity.cs @@ -1,4 +1,5 @@ using Android.OS; +using AndroidX.Compose.Material3; using ComposeNet; namespace ComposeNet.Sample; @@ -11,11 +12,19 @@ protected override void OnCreate(Bundle? savedInstanceState) base.OnCreate(savedInstanceState); SetContent(() => { - var count = Remember(() => new MutableNumberState(0)); - var name = Remember(() => new MutableState("")); - var showDlg = Remember(() => new MutableState(false)); - var liked = Remember(() => new MutableState(false)); - var tab = Remember(() => new MutableNumberState(0)); + var count = Remember(() => new MutableNumberState(0)); + var name = Remember(() => new MutableState("")); + var liked = Remember(() => new MutableState(false)); + var tab = Remember(() => new MutableNumberState(0)); + var showAlert = Remember(() => new MutableState(false)); + var showSheet = Remember(() => new MutableState(false)); + var showDate = Remember(() => new MutableState(false)); + var showTime = Remember(() => new MutableState(false)); + var pickedDate = Remember(() => new MutableState("(none)")); + var pickedTime = Remember(() => new MutableState("(none)")); + var dateState = Remember(() => new DatePickerState()); + var timeState = Remember(() => new TimePickerState(initialHour: 9, initialMinute: 30)); + return new MaterialTheme { new Surface @@ -64,28 +73,88 @@ protected override void OnCreate(Bundle? savedInstanceState) Label = new Text("Settings"), }, }, - new FloatingActionButton(onClick: () => showDlg.Value = true) + + // --- Trigger row: one button per follow-up composable. --- + new Button(onClick: () => showSheet.Value = true) { new Text("Modal bottom sheet") }, + new Button(onClick: () => showDate.Value = true) { new Text("Date picker dialog") }, + new Button(onClick: () => showTime.Value = true) { new Text("Time picker dialog") }, + + // Tooltip wrapping a button — long-press to show the popup. + new Tooltip + { + Tip = new Surface { new Text("Helpful hint") }, + Anchor = new Button(onClick: () => count++) { new Text("Long-press me") }, + }, + + new Text($"Picked date: {pickedDate}"), + new Text($"Picked time: {pickedTime}"), + + new FloatingActionButton(onClick: () => showAlert.Value = true) { new Text("✕"), }, - showDlg.Value - ? new AlertDialog(onDismissRequest: () => showDlg.Value = false) + + showAlert.Value + ? new AlertDialog(onDismissRequest: () => showAlert.Value = false) { Title = new Text("Reset counter?"), Text = new Text("This will set the counter back to zero."), - ConfirmButton = new Button(onClick: () => { count.Value = 0; showDlg.Value = false; }) + ConfirmButton = new Button(onClick: () => { count.Value = 0; showAlert.Value = false; }) { new Text("Reset"), }, - DismissButton = new Button(onClick: () => showDlg.Value = false) + DismissButton = new Button(onClick: () => showAlert.Value = false) { new Text("Cancel"), }, } : null, + + // ModalBottomSheet — SheetState comes from the bound C# + // RememberModalBottomSheetState (NOT stripped, no JNI). + showSheet.Value + ? new ModalBottomSheet(onDismissRequest: () => showSheet.Value = false) + { + new Column + { + new Text("Modal bottom sheet"), + new Text("Drag down or tap outside to dismiss."), + new Button(onClick: () => showSheet.Value = false) { new Text("Hide") }, + }, + } + : null, + + showDate.Value + ? new DatePickerDialog(onDismissRequest: () => showDate.Value = false) + { + ConfirmButton = new Button(onClick: () => + { + pickedDate.Value = dateState.SelectedDateMillis is long ms ? ms.ToString() : "(none)"; + showDate.Value = false; + }) + { new Text("OK") }, + DismissButton = new Button(onClick: () => showDate.Value = false) { new Text("Cancel") }, + Body = new DatePicker(dateState), + } + : null, + + showTime.Value + ? new TimePickerDialog(onDismissRequest: () => showTime.Value = false) + { + Title = new Text("Pick a time"), + ConfirmButton = new Button(onClick: () => + { + pickedTime.Value = $"{timeState.Hour:D2}:{timeState.Minute:D2}"; + showTime.Value = false; + }) + { new Text("OK") }, + DismissButton = new Button(onClick: () => showTime.Value = false) { new Text("Cancel") }, + Body = new TimePicker(timeState), + } + : null, }, }, }; }); } -} +} \ No newline at end of file From 6984e18c0b62d6526d77193625c2bac4b762a81f Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 3 Jun 2026 14:02:25 -0500 Subject: [PATCH 2/4] Address PR #9 review: DatePickerDefault enum + DatePickerState/TimePickerState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #9 review (https://github.com/jonathanpeppers/compose-net/pull/9#pullrequestreview-4421565530): 1. Generate a $default enum for DatePickerKt.DatePicker via the declarative ComposeDefaults attribute, replacing the hard-coded 0b11111110 magic number in ComposeBridges.DatePicker. The bridge now takes an `int defaults` parameter the same way every other composable bridge does, and DatePicker.Render passes (int)DatePickerDefault.All. 2. Expose DatePickerState/TimePickerState wrappers so callers can read the picked date/time from a button callback. Each new wrapper holds the JVM state interface (IDatePickerState / ITimePickerState — both bound, no extra JNI needed) and exposes Kotlin-faithful properties: DatePickerState.SelectedDateMillis long? (Unix epoch UTC) DatePickerState.DisplayedMonthMillis long TimePickerState.Hour int TimePickerState.Minute int TimePickerState.Is24Hour bool DatePicker / TimePicker now accept an optional state argument; if omitted (or for TimePicker, with default initial values), a fresh state is created internally and the selection is unobservable, just like the previous behaviour. Sample (MainActivity.cs) demonstrates the read-back pattern: tapping the time picker dialog's OK button now displays "09:30" (the initial values) in the trigger row's status text, and the date picker's confirm callback reads SelectedDateMillis from its bound state. Verified on emulator-5554: - dotnet build src/ComposeNet.Compose — clean (generator emits DatePickerDefault correctly). - Sample installs and launches with no FATAL/AndroidRuntime errors. - UI-automation drive: open date dialog, OK -> "Picked date: (none)" (null SelectedDateMillis when nothing selected, no crash). Open time dialog, OK -> "Picked time: 09:30" (reads initial values via the bound ITimePickerState). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ComposeNet.Compose/Composables.cs | 30 +++-- src/ComposeNet.Compose/ComposeBridges.cs | 5 +- src/ComposeNet.Compose/ComposeDefaults.cs | 6 + src/ComposeNet.Compose/PickerState.cs | 130 ++++++++++++++++++++++ src/ComposeNet.Sample/MainActivity.cs | 3 + 5 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 src/ComposeNet.Compose/PickerState.cs diff --git a/src/ComposeNet.Compose/Composables.cs b/src/ComposeNet.Compose/Composables.cs index e45288f3..48e1c938 100644 --- a/src/ComposeNet.Compose/Composables.cs +++ b/src/ComposeNet.Compose/Composables.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Collections.Generic; +using Android.Runtime; using AndroidX.Compose.Foundation.Layout; using AndroidX.Compose.Material3; using AndroidX.Compose.Runtime; @@ -789,13 +790,21 @@ internal override void Render(IComposer composer) /// param isn't user-visible (the binding drops the trailing /// $default parameter on @Composable functions), so we go through /// raw JNI to set it. Place inside 's body. +/// Pass an explicit to read the +/// selection from a button callback; if none is supplied a fresh state +/// is created internally and the selection is unobservable. /// public sealed class DatePicker : ComposableNode { + readonly DatePickerState? _state; + public DatePicker(DatePickerState? state = null) => _state = state; + internal override void Render(IComposer composer) { var stateHandle = ComposeBridges.RememberDatePickerState(composer); - ComposeBridges.DatePicker(stateHandle, composer); + if (_state is not null && _state.Jvm is null) + _state.Jvm = Java.Lang.Object.GetObject(stateHandle, JniHandleOwnership.DoNotTransfer)!; + ComposeBridges.DatePicker(stateHandle, (int)DatePickerDefault.All, composer); } } @@ -804,23 +813,20 @@ internal override void Render(IComposer composer) /// /// Material 3 TimePicker. Resolves TimePickerState via /// raw JNI (rememberTimePickerState takes a -/// so it requires the composer-aware bridge). +/// so it requires the composer-aware bridge). Pass an explicit +/// to read the picked +/// hour/minute from a button callback. /// public sealed class TimePicker : ComposableNode { - readonly int _initialHour; - readonly int _initialMinute; - readonly bool _is24Hour; - public TimePicker(int initialHour = 12, int initialMinute = 0, bool is24Hour = true) - { - _initialHour = initialHour; - _initialMinute = initialMinute; - _is24Hour = is24Hour; - } + readonly TimePickerState _state; + public TimePicker(TimePickerState? state = null) => _state = state ?? new TimePickerState(); internal override void Render(IComposer composer) { - var stateHandle = ComposeBridges.RememberTimePickerState(_initialHour, _initialMinute, _is24Hour, composer); + var stateHandle = ComposeBridges.RememberTimePickerState(_state.InitialHour, _state.InitialMinute, _state.InitialIs24Hour, composer); + if (_state.Jvm is null) + _state.Jvm = Java.Lang.Object.GetObject(stateHandle, JniHandleOwnership.DoNotTransfer)!; ComposeBridges.TimePicker(stateHandle, (int)TimePickerDefault.All, composer); } } diff --git a/src/ComposeNet.Compose/ComposeBridges.cs b/src/ComposeNet.Compose/ComposeBridges.cs index 27908ebd..6cb0de4e 100644 --- a/src/ComposeNet.Compose/ComposeBridges.cs +++ b/src/ComposeNet.Compose/ComposeBridges.cs @@ -739,7 +739,7 @@ public static unsafe void TooltipBox( static IntPtr s_datePickerClass; static IntPtr s_datePickerMethod; - public static unsafe void DatePicker(IntPtr state, IComposer composer) + public static unsafe void DatePicker(IntPtr state, int defaults, IComposer composer) { if (s_datePickerClass == IntPtr.Zero) { @@ -747,9 +747,6 @@ public static unsafe void DatePicker(IntPtr state, IComposer composer) s_datePickerMethod = JNIEnv.GetStaticMethodID(s_datePickerClass, "DatePicker", DatePickerSig); } - // Bits 1..7 defaulted (modifier..requestFocus); bit 0 (state) provided. - const int defaults = 0b11111110; - JValue* args = stackalloc JValue[11]; args[0] = new JValue(state); args[1] = new JValue(IntPtr.Zero); // modifier diff --git a/src/ComposeNet.Compose/ComposeDefaults.cs b/src/ComposeNet.Compose/ComposeDefaults.cs index 9b789084..297babd3 100644 --- a/src/ComposeNet.Compose/ComposeDefaults.cs +++ b/src/ComposeNet.Compose/ComposeDefaults.cs @@ -95,6 +95,12 @@ "!onDismissRequest", "!confirmButton", "modifier", "dismissButton", "shape", "tonalElevation", "colors", "properties", "!content")] +// androidx.compose.material3.DatePickerKt.DatePicker: +// 8 user params; bit 0 (state) always provided. +[assembly: ComposeDefaults("DatePickerDefault", + "!state", "modifier", "dateFormatter", "colors", "title", + "headline", "showModeToggle", "requestFocus")] + // androidx.compose.material3.TimePickerKt.TimePicker-mT9BvqQ: // 4 user params; bit 0 (state) always provided. [assembly: ComposeDefaults("TimePickerDefault", diff --git a/src/ComposeNet.Compose/PickerState.cs b/src/ComposeNet.Compose/PickerState.cs new file mode 100644 index 00000000..f4b59c68 --- /dev/null +++ b/src/ComposeNet.Compose/PickerState.cs @@ -0,0 +1,130 @@ +using Android.Runtime; +using AndroidX.Compose.Material3; + +namespace ComposeNet; + +// ---- DatePickerState ---- + +/// +/// Caller-supplied state holder for / +/// . The underlying JVM +/// androidx.compose.material3.DatePickerState is created lazily +/// the first time a bound to this state is +/// rendered; reads/writes to before +/// that point are no-ops/fallbacks. +/// +/// +/// Typical usage — Remember a state instance, pass it to a +/// , and read the picked value from the +/// dialog's ConfirmButton.OnClick: +/// +/// var pickerState = Remember(() => new DatePickerState()); +/// +/// new DatePickerDialog(onDismissRequest: ...) +/// { +/// ConfirmButton = new Button(onClick: () => +/// { +/// var ms = pickerState.SelectedDateMillis; +/// // ms is the picked date as Unix epoch milliseconds (UTC). +/// }) +/// { new Text("OK") }, +/// Body = new DatePicker(pickerState), +/// } +/// +/// +public sealed class DatePickerState +{ + internal IDatePickerState? Jvm; + + /// + /// The currently selected date as Unix epoch milliseconds (UTC), or + /// null if no date is selected. Mirrors Kotlin's + /// DatePickerState.selectedDateMillis: Long?. Returns + /// null until the first render binds + /// this state to the JVM picker. + /// + public long? SelectedDateMillis + { + get => Jvm?.SelectedDateMillis?.LongValue(); + set + { + if (Jvm is not null) + Jvm.SelectedDateMillis = value is long ms ? Java.Lang.Long.ValueOf(ms) : null; + } + } + + /// + /// First-of-month milliseconds for the month currently shown by the + /// picker. Mirrors Kotlin's DatePickerState.displayedMonthMillis. + /// Returns 0 until the state is bound. + /// + public long DisplayedMonthMillis + { + get => Jvm?.DisplayedMonthMillis ?? 0L; + set { if (Jvm is not null) Jvm.DisplayedMonthMillis = value; } + } +} + +// ---- TimePickerState ---- + +/// +/// Caller-supplied state holder for / +/// . Mirrors Kotlin's +/// TimePickerState: / +/// expose the live values; reflects the format. +/// +/// +/// Typical usage — Remember a state instance with the desired +/// initial values, pass it to a , and read the +/// picked time from the dialog's ConfirmButton.OnClick: +/// +/// var pickerState = Remember(() => new TimePickerState(initialHour: 9, initialMinute: 30)); +/// +/// new TimePickerDialog(onDismissRequest: ...) +/// { +/// ConfirmButton = new Button(onClick: () => +/// { +/// var h = pickerState.Hour; +/// var m = pickerState.Minute; +/// }) +/// { new Text("OK") }, +/// Body = new TimePicker(pickerState), +/// } +/// +/// +public sealed class TimePickerState +{ + internal ITimePickerState? Jvm; + + internal int InitialHour { get; } + internal int InitialMinute { get; } + internal bool InitialIs24Hour { get; } + + public TimePickerState(int initialHour = 12, int initialMinute = 0, bool is24Hour = true) + { + InitialHour = initialHour; + InitialMinute = initialMinute; + InitialIs24Hour = is24Hour; + } + + /// Currently displayed hour (0–23). Falls back to the + /// constructor's initialHour until bound. + public int Hour + { + get => Jvm?.Hour ?? InitialHour; + set { if (Jvm is not null) Jvm.Hour = value; } + } + + /// Currently displayed minute (0–59). Falls back to the + /// constructor's initialMinute until bound. + public int Minute + { + get => Jvm?.Minute ?? InitialMinute; + set { if (Jvm is not null) Jvm.Minute = value; } + } + + /// Whether the picker is in 24-hour mode (vs. 12-hour with + /// AM/PM). Falls back to the constructor's is24Hour until + /// bound. + public bool Is24Hour => Jvm?.Is24hour() ?? InitialIs24Hour; +} diff --git a/src/ComposeNet.Sample/MainActivity.cs b/src/ComposeNet.Sample/MainActivity.cs index 8c7dd961..147fe440 100644 --- a/src/ComposeNet.Sample/MainActivity.cs +++ b/src/ComposeNet.Sample/MainActivity.cs @@ -124,6 +124,9 @@ protected override void OnCreate(Bundle? savedInstanceState) } : null, + new Text($"Picked date: {pickedDate}"), + new Text($"Picked time: {pickedTime}"), + showDate.Value ? new DatePickerDialog(onDismissRequest: () => showDate.Value = false) { From f22c2033a979b083ae101c29c2f12ea1072ac9f4 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 3 Jun 2026 14:12:34 -0500 Subject: [PATCH 3/4] Add GC.KeepAlive to dialog/sheet/picker/tooltip JNI bridges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the pattern from PR #11 (bfb108d) — wrap each JNIEnv.CallStatic*Method invocation in try/finally and GC.KeepAlive every managed wrapper whose .Handle was read into a JValue. Once the handle is in the JValue the JIT can consider the wrapper dead, and a GC mid-JNI-call would finalize it and invalidate the handle. Applied to: ModalBottomSheet, BottomSheetScaffold, DatePickerDialog, DatePicker, TimePicker, TimePickerDialog, TooltipBox, RememberDatePickerState, RememberTimePickerState, RememberTooltipState, RememberPlainTooltipPositionProvider. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ComposeNet.Compose/ComposeBridges.cs | 45 +++++++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/ComposeNet.Compose/ComposeBridges.cs b/src/ComposeNet.Compose/ComposeBridges.cs index 6cb0de4e..3ce13dd0 100644 --- a/src/ComposeNet.Compose/ComposeBridges.cs +++ b/src/ComposeNet.Compose/ComposeBridges.cs @@ -759,7 +759,14 @@ public static unsafe void DatePicker(IntPtr state, int defaults, IComposer compo args[8] = new JValue(((Java.Lang.Object)composer).Handle); args[9] = new JValue(0); // $changed args[10] = new JValue(defaults); // $default - JNIEnv.CallStaticVoidMethod(s_datePickerClass, s_datePickerMethod, args); + try + { + JNIEnv.CallStaticVoidMethod(s_datePickerClass, s_datePickerMethod, args); + } + finally + { + GC.KeepAlive(composer); + } } // ---- State-holder bridges ---- @@ -799,7 +806,14 @@ public static unsafe IntPtr RememberDatePickerState(IComposer composer) args[5] = new JValue(((Java.Lang.Object)composer).Handle); args[6] = new JValue(0); // $changed args[7] = new JValue(0b11111); // $default — all 5 user params defaulted - return JNIEnv.CallStaticObjectMethod(s_rememberDatePickerStateClass, s_rememberDatePickerStateMethod, args); + try + { + return JNIEnv.CallStaticObjectMethod(s_rememberDatePickerStateClass, s_rememberDatePickerStateMethod, args); + } + finally + { + GC.KeepAlive(composer); + } } // androidx.compose.material3.TimePickerKt.rememberTimePickerState( @@ -825,7 +839,14 @@ public static unsafe IntPtr RememberTimePickerState(int initialHour, int initial args[3] = new JValue(((Java.Lang.Object)composer).Handle); args[4] = new JValue(0); // $changed args[5] = new JValue(0); // $default — all 3 provided - return JNIEnv.CallStaticObjectMethod(s_rememberTimePickerStateClass, s_rememberTimePickerStateMethod, args); + try + { + return JNIEnv.CallStaticObjectMethod(s_rememberTimePickerStateClass, s_rememberTimePickerStateMethod, args); + } + finally + { + GC.KeepAlive(composer); + } } // androidx.compose.material3.TooltipKt.rememberTooltipState( @@ -851,7 +872,14 @@ public static unsafe IntPtr RememberTooltipState(bool isPersistent, IComposer co args[3] = new JValue(((Java.Lang.Object)composer).Handle); args[4] = new JValue(0); // $changed args[5] = new JValue(0b101); // $default — bits 0 and 2 (initialIsVisible, mutatorMutex) - return JNIEnv.CallStaticObjectMethod(s_rememberTooltipStateClass, s_rememberTooltipStateMethod, args); + try + { + return JNIEnv.CallStaticObjectMethod(s_rememberTooltipStateClass, s_rememberTooltipStateMethod, args); + } + finally + { + GC.KeepAlive(composer); + } } // androidx.compose.material3.TooltipDefaults.INSTANCE.rememberPlainTooltipPositionProvider-kHDZbjc( @@ -880,7 +908,14 @@ public static unsafe IntPtr RememberPlainTooltipPositionProvider(IComposer compo args[1] = new JValue(((Java.Lang.Object)composer).Handle); args[2] = new JValue(0); args[3] = new JValue(1); // $default — spacing defaulted - return JNIEnv.CallObjectMethod(s_tooltipDefaultsInstance, s_rememberPlainTooltipPositionProviderMethod, args); + try + { + return JNIEnv.CallObjectMethod(s_tooltipDefaultsInstance, s_rememberPlainTooltipPositionProviderMethod, args); + } + finally + { + GC.KeepAlive(composer); + } } static unsafe void InvokeTextField(IntPtr cls, IntPtr method, string value, IFunction1 onValueChange, IComposer composer, int defaults) From 4a89de00da833d1d1e2ed4e9b7ef5e94a26b8fdd Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 3 Jun 2026 14:21:10 -0500 Subject: [PATCH 4/4] Reorganize sample into Basics/Buttons/Pickers tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sample had grown past one screen — buttons, chips, FAB, dialog triggers, picker triggers and a tooltip were all stacked in one Column. Split them across three tabs driven by the existing 'tab' MutableNumberState: - Basics: Text, Button, IconButton, OutlinedTextField, Card - Buttons: chips, Tooltip, FloatingActionButton - Pickers: ModalBottomSheet/DatePicker/TimePicker triggers + readouts NavigationBar at the bottom of the Column switches tabs. Overlays (AlertDialog, ModalBottomSheet, DatePickerDialog, TimePickerDialog) are still rendered at the root regardless of tab so a dialog opened from the FAB on the Buttons tab still works after switching. Note: the NavigationBar isn't pinned to the bottom edge — that needs Scaffold (issue #12). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ComposeNet.Sample/MainActivity.cs | 125 ++++++++++++++------------ 1 file changed, 69 insertions(+), 56 deletions(-) diff --git a/src/ComposeNet.Sample/MainActivity.cs b/src/ComposeNet.Sample/MainActivity.cs index 147fe440..10bd4a52 100644 --- a/src/ComposeNet.Sample/MainActivity.cs +++ b/src/ComposeNet.Sample/MainActivity.cs @@ -25,75 +25,91 @@ protected override void OnCreate(Bundle? savedInstanceState) var dateState = Remember(() => new DatePickerState()); var timeState = Remember(() => new TimePickerState(initialHour: 9, initialMinute: 30)); + // Per-tab content. Only the current tab's column is added to + // the screen — keeps the sample short enough to fit on one + // phone-sized scroll area. + ComposableNode tabContent = tab.Value switch + { + 0 => new Column + { + new Text("Hello from .NET"), + new Text($"Count: {count}"), + new Button(onClick: () => count++) { new Text("Tap to increment") }, + new IconButton(onClick: () => count--) { new Text("−") }, + new OutlinedTextField(name), + new Text($"Hi {(string.IsNullOrEmpty(name.Value) ? "stranger" : name.Value)}"), + new Card + { + new Text("Inside a Card"), + new Text($"Counter snapshot: {count}"), + }, + }, + 1 => new Column + { + new Text("Chips, FAB, tooltip"), + new AssistChip(onClick: () => count++) + { + Label = new Text("Assist (+1)"), + }, + new FilterChip(selected: liked.Value, onClick: () => liked.Value = !liked.Value) + { + Label = new Text(liked.Value ? "Liked" : "Like"), + }, + new SuggestionChip(onClick: () => count.Value = 0) + { + Label = new Text("Reset"), + }, + new Tooltip + { + Tip = new Surface { new Text("Helpful hint") }, + Anchor = new Button(onClick: () => count++) { new Text("Long-press me") }, + }, + new FloatingActionButton(onClick: () => showAlert.Value = true) + { + new Text("✕"), + }, + }, + _ => new Column + { + new Text("Dialogs and sheets"), + new Button(onClick: () => showSheet.Value = true) { new Text("Modal bottom sheet") }, + new Button(onClick: () => showDate.Value = true) { new Text("Date picker dialog") }, + new Button(onClick: () => showTime.Value = true) { new Text("Time picker dialog") }, + new Text($"Picked date: {pickedDate}"), + new Text($"Picked time: {pickedTime}"), + }, + }; + return new MaterialTheme { new Surface { new Column { - new Text("Hello from .NET"), - new Text($"Count: {count}"), - new Button(onClick: () => count++) - { - new Text("Tap to increment"), - }, - new IconButton(onClick: () => count--) - { - new Text("−"), - }, - new OutlinedTextField(name), - new Text($"Hi {(string.IsNullOrEmpty(name.Value) ? "stranger" : name.Value)}"), - new Card - { - new Text("Inside a Card"), - new Text($"Counter snapshot: {count}"), - }, - new AssistChip(onClick: () => count++) - { - Label = new Text("Assist (+1)"), - }, - new FilterChip(selected: liked.Value, onClick: () => liked.Value = !liked.Value) - { - Label = new Text(liked.Value ? "Liked" : "Like"), - }, - new SuggestionChip(onClick: () => count.Value = 0) - { - Label = new Text("Reset"), - }, + tabContent, + + // Bottom navigation switches between the three tabs above. new NavigationBar { new NavigationBarItem(selected: tab.Value == 0, onClick: () => tab.Value = 0) { - Icon = new Text("🏠"), - Label = new Text("Home"), + Icon = new Text("🔢"), + Label = new Text("Basics"), }, new NavigationBarItem(selected: tab.Value == 1, onClick: () => tab.Value = 1) { - Icon = new Text("⚙"), - Label = new Text("Settings"), + Icon = new Text("👍"), + Label = new Text("Buttons"), + }, + new NavigationBarItem(selected: tab.Value == 2, onClick: () => tab.Value = 2) + { + Icon = new Text("📅"), + Label = new Text("Pickers"), }, }, - // --- Trigger row: one button per follow-up composable. --- - new Button(onClick: () => showSheet.Value = true) { new Text("Modal bottom sheet") }, - new Button(onClick: () => showDate.Value = true) { new Text("Date picker dialog") }, - new Button(onClick: () => showTime.Value = true) { new Text("Time picker dialog") }, - - // Tooltip wrapping a button — long-press to show the popup. - new Tooltip - { - Tip = new Surface { new Text("Helpful hint") }, - Anchor = new Button(onClick: () => count++) { new Text("Long-press me") }, - }, - - new Text($"Picked date: {pickedDate}"), - new Text($"Picked time: {pickedTime}"), - - new FloatingActionButton(onClick: () => showAlert.Value = true) - { - new Text("✕"), - }, - + // --- Overlays: rendered at the root regardless of tab, + // so a dialog opened on Tab 1 still works after switching. --- showAlert.Value ? new AlertDialog(onDismissRequest: () => showAlert.Value = false) { @@ -124,9 +140,6 @@ protected override void OnCreate(Bundle? savedInstanceState) } : null, - new Text($"Picked date: {pickedDate}"), - new Text($"Picked time: {pickedTime}"), - showDate.Value ? new DatePickerDialog(onDismissRequest: () => showDate.Value = false) {