UIButton + UIMenu Picker for iOS/Mac Catalyst#35281
UIButton + UIMenu Picker for iOS/Mac Catalyst#35281ethanl21 wants to merge 2 commits intodotnet:mainfrom
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35281Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35281" |
|
@dotnet-policy-service agree |
kubaflo
left a comment
There was a problem hiding this comment.
I’m not sure this is the best approach given the limitations - it could introduce quite a few regressions. Perhaps a safer option would be to implement this as a new handler and let developers opt in, rather than replacing the existing behavior. Something similar to how the CollectionView transition was handled on iOS could work well here.
Before doing it though, can you pleas create a demo of how it works on iOS and MacOs and verify if accessibility (screen reader, keyboard navigation) works?
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 15 findings
See inline comments for details.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 15 findings
See inline comments for details.
…ory fails before tests
When a test category fails because the build or deploy crashed before any
test could run (e.g. CS0246 missing namespace, RS0016 PublicAPI errors),
the AI summary table previously showed '0/1 ✓' — the green-checkmark
'all passed' branch — because no per-test failures were parsed. That's
visually misleading: the row is FAILED but the cell looks healthy.
Two fixes:
1. Tests column distinguishes 'category failed AND no per-test failures
parsed' from 'all tests passed':
- 'build/deploy failed' (no tests at all)
- '0/1 — build/deploy failed before per-test results' (some discovered)
2. New optional 'build_tail' field captures the last 30 lines of stdout
when a category fails with zero per-test failures. The Failed test
details collapsible section then renders it in a code block so
reviewers see the actual compiler error / build crash inline,
instead of having to download the full CopilotLogs artifact.
This was discovered while running the regression-check pipeline against
PRs #35110 (142 RS0016 PublicAPI errors), #35281 (CS0246 NSAttributedString
missing for catalyst), and #35358 — all reported as '0/1 ✓' before the fix.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🤖 AI Summary
📊 Review Session —
|
| Category | Result | Tests | Duration | Notes |
|---|---|---|---|---|
ViewBaseTests |
❌ FAILED | 0/1 — build/deploy failed before per-test results | 22s | exit code 1 |
Failed test details
❌ ViewBaseTests — build/deploy failed (no per-test results)
Last 30 lines of build/test stdout:
Determining projects to restore...
Restored /Users/cloudtest/vss/_work/1/s/src/Graphics/src/Graphics/Graphics.csproj (in 576 ms).
Restored /Users/cloudtest/vss/_work/1/s/src/Essentials/src/Essentials.csproj (in 4.9 sec).
Restored /Users/cloudtest/vss/_work/1/s/src/Controls/src/Core/Controls.Core.csproj (in 5.5 sec).
Restored /Users/cloudtest/vss/_work/1/s/src/Core/src/Core.csproj (in 5.5 sec).
Restored /Users/cloudtest/vss/_work/1/s/src/Core/maps/src/Maps.csproj (in 5.5 sec).
Restored /Users/cloudtest/vss/_work/1/s/src/BlazorWebView/src/Maui/Microsoft.AspNetCore.Components.WebView.Maui.csproj (in 5.5 sec).
Restored /Users/cloudtest/vss/_work/1/s/src/Controls/Maps/src/Controls.Maps.csproj (in 5.5 sec).
Restored /Users/cloudtest/vss/_work/1/s/src/Controls/src/Xaml/Controls.Xaml.csproj (in 5.5 sec).
Restored /Users/cloudtest/vss/_work/1/s/src/Controls/Foldable/src/Controls.Foldable.csproj (in 4.93 sec).
Restored /Users/cloudtest/vss/_work/1/s/src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj (in 5.67 sec).
1 of 11 projects are up-to-date for restore.
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14048785
Graphics -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Graphics/Debug/net10.0-maccatalyst26.0/Microsoft.Maui.Graphics.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14048785
Essentials -> /Users/cloudtest/vss/_work/1/s/artifacts/bin/Essentials/Debug/net10.0-maccatalyst26.0/Microsoft.Maui.Essentials.dll
##vso[build.updatebuildnumber]10.0.70-ci+azdo.14048785
/Users/cloudtest/vss/_work/1/s/src/Core/src/Handlers/Picker/PickerHandler.iOS.cs(82,3): error CS0246: The type or namespace name 'NSAttributedString' could not be found (are you missing a using directive or an assembly reference?) [/Users/cloudtest/vss/_work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0-maccatalyst26.0]
Build FAILED.
/Users/cloudtest/vss/_work/1/s/src/Core/src/Handlers/Picker/PickerHandler.iOS.cs(82,3): error CS0246: The type or namespace name 'NSAttributedString' could not be found (are you missing a using directive or an assembly reference?) [/Users/cloudtest/vss/_work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0-maccatalyst26.0]
0 Warning(s)
1 Error(s)
Time Elapsed 00:00:14.01
Failures here are informational only — they do not block the gate or affect try-fix candidate scoring.
🔍 Regression Cross-Reference
🔍 Regression Cross-Reference
🟢 No regression risks detected. No labeled bug-fix PRs in the last 6 months touched the modified files.
🔍 Pre-Flight — Context & Validation
Issue: #23999 — Picker on iOS/Catalyst should use UIButton with menu rather than UIPickerView (also closes #10208 — broken Mac Catalyst Picker behavior)
PR: #35281 — UIButton + UIMenu Picker for iOS/Mac Catalyst
Platforms Affected: iOS, Mac Catalyst
Files Changed: 4 implementation, 0 test, 2 PublicAPI
Key Findings
- Substantial rewrite, not a small fix. Replaces
MauiPicker(UITextField + UIPickerView/UIAlertController) with aUIButtonconfigured withShowsMenuAsPrimaryAction = trueandChangesSelectionAsPrimaryAction = true, populated by aUIMenuofUIActions. - 🚨 Build-breaking on MacCatalyst.
src/Core/src/Handlers/Picker/PickerHandler.cs:43still wires[nameof(IPicker.Unfocus)] = MapUnfocusinside#elif MACCATALYST, but the newPickerHandler.iOS.csdeletedMapUnfocusalong with_pickerController. There is noMapUnfocussymbol left on the iOS/MacCatalyst partial → CS0103 on Catalyst. The AndroidPickerHandler.Android.csdefines its own copy, so Android still builds. - 🚨 Author-acknowledged SelectedIndex-in-XAML bug. With
ChangesSelectionAsPrimaryAction = true,UIButtonderives its rendered title from theUIActionwhoseState == UIMenuElementState.On. The PR never setsStateon any action andUpdateMenu()rebuilds the menu without preserving / applying the currentSelectedIndex.MapSelectedIndexonly callsUpdateSelectedText(), but the button's title is then immediately overridden by UIKit's auto-selection behavior (whichever action ends up.on, which defaults to none). Result:SelectedIndexset declaratively in XAML beforeItemsare bound (or any time the menu is rebuilt) is silently dropped. ⚠️ Silent feature regressions.MapHorizontalTextAlignmentandMapVerticalTextAlignmentare now empty bodies — XAML that sets these will compile and run with zero effect (no diagnostic warning).MapIsOpenwas also lost. The Controls-layerPickermapper still pushesTextTransform/FontAttributes/FontAutoScalingEnabledthroughMapFont, so font scale/attributes still flow, butTextTransformis not applied to either the menu item titles or the button title (PR description acknowledges).⚠️ Public API binary break.IPickerHandler.PlatformViewchanged fromMicrosoft.Maui.Platform.MauiPicker!toUIKit.UIButton!. ThePublicAPI.Unshipped.txtfiles document the change correctly with*REMOVED*lines, but any third-party effect/library that referencedhandler.PlatformViewas aMauiPicker(or as aUITextField-derived class for keyboard/border tweaks) will fail to compile against this build.⚠️ Lifecycle / retain risk. EachUIAction.Create("…", null, null, _ => OnMenuItemSelected(index))capturesthis(the handler). The button retains itsMenu, the menu retains its actions, the actions retain the closure, the closure retains the handler. The handler stays referenced byMauiContextand the virtual view, so this is probably not a leak per se, butDisconnectHandlershould nullPlatformView.Menuto break the cycle on view replacement / handler reuse. CurrentlyDisconnectHandleronly nulls_fontManagerand forwards tobase.⚠️ Lost behavior vs. the previous control:IsFocused/IsOpenare no longer driven from the UI (the oldOnStarted/OnEndedset them; the new code only resetsIsFocused = falsewhen an item is picked). Bindings toIsFocusedwill be stuck atfalse.- Accessibility focus notifications (
PostAccessibilityFocusNotification) for VoiceOver popup focus are gone. - The control no longer renders as a
UITextFieldwith a "RoundedRect" border — replaced by a manualLayer.BorderWidth/Color/CornerRadius. Acceptable, but the styling is hard-coded (noBackground,BackgroundColor,Bordermapping). ContentEdgeInsetsis deprecated in iOS 15+; should useUIButton.Configuration(or guard withOperatingSystem.IsIOSVersionAtLeast(15)) for forward compatibility.
⚠️ Dead code:internal bool UpdateImmediately { get; set; }is preserved but never read in the new path. Remove or wire it back in.⚠️ No tests added. Gate skipped — no UnitTests or DeviceTests / UITests cover the new Picker behavior. A control rewrite of this size should add at least a snapshot/UI test that exercises (a) initialSelectedIndexfrom XAML, (b)Itemsbinding update with retained selection, (c) Mac Catalyst rendering.- 💡 Cosmetic: missing newline at EOF in
PickerHandler.iOS.csandPickerExtensions.cs;_fontManageris cached at Connect butCreateAttributedStringstill falls back to((IElementHandler)this).GetRequiredService<IFontManager>()— pick one pattern. - 💡
UpdateMenurebuild cost. EveryItems/SelectedIndexchange rebuilds the entireUIMenu. For long lists this allocates anUIMenuElement[count]+countclosures. Acceptable for a Picker (typically small), but worth a comment.
Code Review Summary
Verdict: NEEDS_CHANGES
Confidence: high
Errors: 2 | Warnings: 6 | Suggestions: 3
Key code review findings:
- ❌
src/Core/src/Handlers/Picker/PickerHandler.cs:43— Catalyst CommandMapper referencesMapUnfocuswhich no longer exists inPickerHandler.iOS.cs. Build break on MacCatalyst. - ❌
src/Core/src/Handlers/Picker/PickerHandler.iOS.cs:UpdateMenu / UpdateSelectedText—ChangesSelectionAsPrimaryAction = truerequires aUIActionto haveState = UIMenuElementState.Onfor the displayed title to matchSelectedIndex. The PR never setsState, causing the author-acknowledged "SelectedIndex from XAML is ignored" bug. ⚠️ src/Core/src/Handlers/Picker/PickerHandler.iOS.cs:MapHorizontalTextAlignment/MapVerticalTextAlignment/MapIsOpen— silent no-ops; XAML setters compile and produce no behavior, no warning.⚠️ src/Core/src/Handlers/Picker/PickerHandler.iOS.cs:DisconnectHandler— does not nullPlatformView.Menu; UIAction closures keep capturingthis.⚠️ src/Core/src/Handlers/Picker/PickerHandler.iOS.cs:CreatePlatformView—ContentEdgeInsetsis deprecated since iOS 15.⚠️ src/Core/src/Handlers/Picker/IPickerHandler.cs&PublicAPI/...Unshipped.txt—PlatformViewtype change fromMauiPicker→UIButtonis a binary break for third-party libraries.⚠️ IsFocused/IsOpennot driven from UIButton menu open/close — bindings will be stale.⚠️ Accessibility notifications (PostAccessibilityFocusNotification) lost — VoiceOver regressions.- 💡
UpdateImmediatelyfield is now dead code. - 💡 Missing newline at EOF in
PickerHandler.iOS.csandPickerExtensions.cs. - 💡 Cache
IFontManagerconsistently (either always via_fontManageror viaGetRequiredService— not both).
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #35281 | Replace MauiPicker with UIButton+UIMenu; drop MAUI-only features (HorizontalTextAlignment, VerticalTextAlignment, IsOpen, TextTransform, UpdateMode) | 4 src + 2 PublicAPI | Author flags SelectedIndex-in-XAML known bug |
Impacted UI Test Categories
(See uitests/ai-categories.md.)
🔬 Code Review — Deep Analysis
Code Review — PR #35281
Mirror of the maui-expert-reviewer output for Pre-Flight Step 8. Full inline findings live in
inline-findings.json.
Verdict: NEEDS_CHANGES (confidence: high)
Two critical issues, four major regressions, and several minor concerns. The PR is the right idea (UIButton + UIMenu is the modern iOS pattern) but the implementation breaks the MacCatalyst build, breaks public API binary compat, leaks the handler, has an author-flagged "SelectedIndex from XAML doesn't display" bug, and silently drops 5 features.
❌ Errors
src/Core/src/Handlers/Picker/PickerHandler.cs:43— CatalystCommandMapperreferencesMapUnfocus, which the newPickerHandler.iOS.csdeletes. MacCatalyst build break (CS0103).src/Core/src/Handlers/Picker/PickerHandler.iOS.cs:UpdateMenu/UpdateSelectedText—ChangesSelectionAsPrimaryAction = truemakesUIButtonrender the title from theUIActionwhoseState == UIMenuElementState.On. The PR never setsState, soSetAttributedTitleis overridden by UIKit's auto-selection. This is the root cause of the author-acknowledged XAML SelectedIndex bug.
⚠️ Warnings
src/Core/src/Handlers/Picker/PickerHandler.iOS.cs:DisconnectHandler— does not nullPlatformView.Menu. UIAction lambdas capturethis, creating a retain cycle (handler → button → menu → action → closure → handler).src/Core/src/Handlers/Picker/PickerHandler.iOS.cs:MapHorizontalTextAlignment / MapVerticalTextAlignment / MapIsOpen— empty bodies. XAML setters for these compile and have zero effect; no diagnostic warning.src/Core/src/Handlers/Picker/IPickerHandler.cs&PublicAPI/...Unshipped.txt—IPickerHandler.PlatformViewtype change fromMicrosoft.Maui.Platform.MauiPicker!toUIKit.UIButton!is a binary-breaking change for any third-party effect/library.src/Core/src/Handlers/Picker/PickerHandler.iOS.cs:CreatePlatformView—ContentEdgeInsetsis deprecated since iOS 15. UseUIButton.Configuration(UIButtonConfiguration.PlainButtonConfiguration) instead.IsFocused/IsOpenare no longer driven from menu open/close. Bindings to these will be stuck.PostAccessibilityFocusNotificationcalls were removed — VoiceOver focus regressions when the menu opens/closes.
💡 Suggestions
internal bool UpdateImmediately { get; set; }is now write-only / dead code (the UIAction lambda always commits immediately). Remove it and convertMapUpdateMode(inControls/Picker.iOS.cs) to a logged no-op.- Cache
IFontManagerconsistently (currently_fontManageris set in Connect butCreateAttributedStringfalls back to((IElementHandler)this).GetRequiredService<IFontManager>()— pick one). - Missing newline at EOF in
PickerHandler.iOS.csandPickerExtensions.cs. UpdateMenurebuilds the entireUIMenufor everyItems/SelectedIndexchange. For long lists this allocatescountclosures; cache the action array and just toggleState.
Failure-mode probes
| Probe | Answer |
|---|---|
| Builds on net-maccatalyst? | No (CS0103 on MapUnfocus) |
picker.SelectedIndex = N from XAML displays "Item N"? |
No (UIAction.State not set) |
| Handler collected after navigation? | No (Menu retain cycle) |
picker.IsOpen = true opens menu? |
No (silent no-op) |
HorizontalTextAlignment="End" aligns text? |
No (silent no-op) |
iOSSpecific.SetUpdateMode(UpdateMode.WhenFinished) honored? |
No (UpdateImmediately is write-only) |
Focused event fires? |
No (OnMenuItemSelected only resets IsFocused = false) |
Blast radius
net-iosandnet-maccatalystPicker only.- 11
*REMOVED*API entries;PickerSourceremoved from public API. - Cross-platform Mapper signatures unchanged → Android / Windows / Tizen unaffected.
- Downstream community libraries that use
handler.PlatformViewasMauiPicker(UITextField) will see runtimeInvalidCastExceptionafter an update.
Inline findings
17 file:line findings in inline-findings.json.
🔧 Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #35281 | UIButton+UIMenu rewrite, drop unsupported features | 4 src + 2 PublicAPI | Build break on MacCatalyst; XAML SelectedIndex bug; retain cycle; 5 silently-dropped features | |
| pr-plus-reviewer | Branch A (expert eval) | PR + reviewer's A1–A5 sandbox patch | PR + ~3 more files | Fixes all 3 critical issues + restores HorizontalTextAlignment + replaces deprecated ContentEdgeInsets | |
| try-fix-1 | Logic & Correctness | Minimum-viable correctness: UIAction.State for SelectedIndex, MapUnfocus stub for Catalyst, null Menu in DisconnectHandler | PickerHandler.iOS.cs | Smallest delta; fixes 3 critical findings; doesn't restore dropped features | |
| try-fix-2 | Public API Surface | Keep PlatformView=MauiPicker, host UIButton internally | 4 reverts + 2 modifies | No binary break; preserves IsFocused/EditingDidBegin path; larger surface | |
| try-fix-3 | Regression Prevention | Restore HorizontalTextAlignment, IsOpen/IsFocused tracking, log-warn for UpdateMode/IsOpen=true; remove dead UpdateImmediately | 2 src files | Closes silent-regression surface; assumes try-fix-1 also applied; iOS 16+ guards needed | |
| try-fix-4 | iOS Platform Specifics + Accessibility | UIButtonConfiguration + TitleTextAttributesTransformer; restore PostAccessibilityFocusNotification | PickerHandler.iOS.cs | Modern API; transformer cooperates with ChangesSelectionAsPrimaryAction; doesn't fix Catalyst build alone |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| try-fix-1 (correctness) | 2 | No | Suggests its UIAction.State fix is necessary in every candidate that keeps UIButton — try-fix-3 and try-fix-4 must layer on top of it |
| try-fix-2 (public API) | 2 | No | Notes that if the team rejects the binary break, try-fix-2 is the only candidate; otherwise its work is moot |
| try-fix-3 (regression prevention) | 2 | Yes | Adds: also need a MapBackground mapping — currently the hard-coded UIColor.SystemBackground overrides any user Background setting on iOS |
| try-fix-4 (iOS platform specifics) | 2 | Yes | Adds: UIButtonConfiguration.TitleTextAttributesTransformer could subsume try-fix-1's UpdateSelectedText entirely — single source of truth for title styling |
After cross-pollination, the dominant insight is that try-fix-1 is a prerequisite (or its UIAction.State approach is) for any candidate that keeps the UIButton platform view. Layering try-fix-3 and try-fix-4 on top of try-fix-1 produces a strictly better fix than any single candidate, but is also closest to the pr-plus-reviewer sandbox patch, which already includes A1 (=try-fix-1), A4 (=try-fix-3 partial), and A5 (=try-fix-4 partial).
Exhausted: Yes (3 rounds, no further independent ideas).
Selected Fix: pr-plus-reviewer — see Phase 3 report. The expert reviewer's A1–A5 sandbox patch is a strict superset of try-fix-1 and incorporates parts of try-fix-3 and try-fix-4. try-fix-2's no-binary-break approach is recorded as the preferred alternative if the team decides the public API change is unacceptable.
📋 Report — Final Recommendation
Phase 3 — Comparative Analysis & Recommendation
Candidate scoreboard
| Candidate | Fixes Catalyst build break | Fixes XAML SelectedIndex bug | Fixes retain cycle | Restores dropped features | Avoids public API binary break | Tests added | Test result |
|---|---|---|---|---|---|---|---|
pr (PR #35281 as-is) |
❌ | ❌ (acknowledged) | ❌ | ❌ | ❌ | ❌ | |
pr-plus-reviewer (PR + A1–A5) |
✅ | ✅ (UIAction.State) | ✅ (Menu/title cleared) | Partial (HorizontalAlign + log-warn rest) | ❌ | ❌ | |
try-fix-1 (correctness) |
✅ | ✅ | ✅ | ❌ | ❌ | ❌ | |
try-fix-2 (public API) |
✅ (preserves MapUnfocus path) | partial (still needs A1) | ✅ (UITextField cleanup) | ❌ | ✅ | ❌ | |
try-fix-3 (regression prevention) |
❌ | ❌ | ❌ | ✅ | ❌ | ❌ | |
try-fix-4 (platform specifics) |
❌ | partial (needs A1) | ❌ | partial (accessibility) | ❌ | ❌ |
Ranking notes
The Gate phase was SKIPPED for all candidates because the PR ships zero automated tests for the new control. By the rule that candidates failing regression tests must rank lower than candidates passing them, every candidate is on equal footing on the empirical-test axis. The ranking below is therefore driven by static evidence of correctness: build viability on MacCatalyst, recognized UIKit usage, lifecycle hygiene, and behavioural surface preservation.
pr-plus-reviewer— only candidate that simultaneously closes all three critical findings (build break, SelectedIndex bug, retain cycle), restores the most-used dropped feature (HorizontalTextAlignment), and modernizes deprecated UIKit calls. Strict superset oftry-fix-1; folds in parts oftry-fix-3(log-warns) andtry-fix-4(UIButton.Configuration / accessibility).try-fix-1— minimum-viable correctness fix. Acceptable as a fallback if the team wants the smallest possible diff on top of the PR, but leaves the dropped-feature problem unaddressed.try-fix-2— strongest "no public API break" answer. Reasonable if the team rules theIPickerHandler.PlatformViewchange unacceptable; otherwise it adds layout complexity for limited extra value.try-fix-4— best UIKit modernization story but doesn't fix Catalyst build alone.try-fix-3— useful only as a layer on top of one of the above.pr— fails to compile on MacCatalyst (a target the PR explicitly claims to support); cannot ship.
Recommendation
Winner: pr-plus-reviewer.
Concretely, request the author to:
- Re-introduce a
MapUnfocusstub for MacCatalyst (or remove the CommandMapper line) — fixes the build break. - Cache
UIAction[]and toggleUIMenuElementState.Onto fix the XAML SelectedIndex bug. - Clear
PlatformView.Menuand the cached actions inDisconnectHandler— fixes the retain cycle. - Implement
MapHorizontalTextAlignmentviaContentHorizontalAlignmentand either implement or log-warn the rest (IsOpen=true,UpdateMode,VerticalTextAlignment). - Replace deprecated
ContentEdgeInsetswithUIButton.Configuration(UIButtonConfiguration.BorderedButtonConfigurationrecommended). - Add at least one DeviceTest that asserts
picker.SelectedIndex = Nset in XAML displays "Item N" — directly catches the author-flagged bug and prevents regression. - Discuss the
IPickerHandler.PlatformViewtype change with maintainers (binary-breaking; considertry-fix-2's "host UIButton in MauiPicker" approach if the API team objects).
If the author prefers a smaller diff, try-fix-1 is the recommended fallback and matches the minimum bar to ship a building, correct Catalyst Picker.
The PR's overall direction (UIButton + UIMenu) is correct and welcome; the implementation just needs the corrections above before it can land.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 17 findings
See inline comments for details.
| @@ -1,5 +1,5 @@ | |||
| #if __IOS__ || MACCATALYST | |||
| using PlatformView = Microsoft.Maui.Platform.MauiPicker; | |||
| using PlatformView = UIKit.UIButton; | |||
There was a problem hiding this comment.
[critical] Public API Surface — binary breaking change — IPickerHandler.PlatformView has changed type from Microsoft.Maui.Platform.MauiPicker! to UIKit.UIButton! on net-ios and net-maccatalyst (see PublicAPI.Unshipped.txt *REMOVED* lines). Any third-party effect, behavior, library, or sample doing ((IPickerHandler)handler).PlatformView.Text = … or casting to MauiPicker will throw InvalidCastException at runtime — and recompiles will fail. Even though the entries are in Unshipped, MauiPicker-typed PlatformView shipped to customers in earlier .NET MAUI releases. This deserves an explicit BC-break call-out in the PR description, design review sign-off, and ideally a migration note. Consider whether keeping MauiPicker as a thin UIView wrapper that hosts the UIButton would preserve binary compatibility.
| .FireAndForget(); | ||
| } | ||
| }); | ||
| PlatformView.Menu = UIMenu.Create("Picker Menu", menuElements); |
There was a problem hiding this comment.
[critical] Logic & Correctness — root cause of the SelectedIndex-from-XAML bug — With ChangesSelectionAsPrimaryAction = true, UIButton derives its displayed title from whichever UIAction in Menu has State == UIMenuElementState.On, and ignores SetAttributedTitle(...). This loop never sets .State on any action, so on first display UIKit sees a menu with all actions in .Off state and falls back to the button's intrinsic title (often the action[0] title or empty). The XAML-time MapSelectedIndex → UpdateSelectedText → SetAttributedTitle therefore has no visual effect. Runtime taps work because UIKit auto-flips the tapped action's State to .On. Fix: in UpdateMenu apply action.State = (i == VirtualView.SelectedIndex) ? UIMenuElementState.On : UIMenuElementState.Off while building the array, and in MapSelectedIndex either rebuild the menu or update State on the cached UIAction references (UIAction.State is mutable). Once that is in place, SetAttributedTitle becomes redundant for the selected-item display — keep it only for the title/empty fallback.
| bool isTitle = selectedIndex < 0 || selectedIndex >= VirtualView.GetCount(); | ||
| var text = isTitle ? (VirtualView.Title ?? string.Empty) : VirtualView.GetItem(selectedIndex); | ||
|
|
||
| PlatformView.SetAttributedTitle(CreateAttributedString(text, isTitle), UIControlState.Normal); |
There was a problem hiding this comment.
[major] Logic & Correctness — UpdateSelectedText calls SetAttributedTitle(... UIControlState.Normal), but with ChangesSelectionAsPrimaryAction = true this title is overwritten by UIKit any time the menu re-renders. Title/font/color/character-spacing changes from the cross-platform mappers therefore land only when there is no menu (count == 0). After fixing the State bug above, route font/color/character-spacing into either (a) a UIAction.Image/AttributedTitle per item — UIAction does not honour custom fonts well — or (b) PlatformView.Configuration (UIButtonConfiguration) which is the supported iOS 15+ way to style a menu-driven button. The current SetAttributedTitle path will silently no-op for the common case of "a Picker that already has items and a selection".
| popoverPresentation.SourceRect = uITextField.Bounds; | ||
| var index = i; | ||
| var title = VirtualView.GetItem(index); | ||
| var action = UIAction.Create(title, null, null, _ => OnMenuItemSelected(index)); |
There was a problem hiding this comment.
[major] Memory Leak Prevention — Each UIAction lambda captures index plus this (via OnMenuItemSelected). The retain chain is: Handler → PlatformView (UIButton, strong via base handler) → Menu (UIMenu) → UIAction[] → managed closure → Handler. Under iOS reference-counting GC this is a hard cycle and will leak the handler + virtual view. Fix options: (a) make OnMenuItemSelected static and pass the index via UIAction.Identifier, looking up the handler weakly through the sender; (b) use a WeakReference<PickerHandler> captured by the closure (matching the previous MauiPickerProxy pattern this PR removed); or (c) clear PlatformView.Menu = null in DisconnectHandler. At minimum (c) must be done — DisconnectHandler currently only nulls _fontManager.
| { | ||
| _fontManager = null; | ||
| base.DisconnectHandler(platformView); | ||
| } |
There was a problem hiding this comment.
[major] Handler lifecycle — DisconnectHandler is incomplete — DisconnectHandler only nulls _fontManager. It does not: (1) clear PlatformView.Menu = null (see retain-cycle finding); (2) clear PlatformView.Configuration / attributed title; (3) reverse any ConnectHandler side-effects. Add platformView.Menu = null; and platformView.SetAttributedTitle(null, UIControlState.Normal); before base.DisconnectHandler(platformView). Also note that Shell tab switching disconnects/reconnects handlers — re-ConnectHandler will rebuild the menu from scratch, which is fine, but only if disconnect actually drops the previous menu graph.
| .FireAndForget(); | ||
| } | ||
| }); | ||
| PlatformView.Menu = UIMenu.Create("Picker Menu", menuElements); |
There was a problem hiding this comment.
[minor] Hard-coded UIMenu title "Picker Menu" — UIMenu.Create("Picker Menu", menuElements) — on MacCatalyst this title can render as a section header in the popup. Pass string.Empty (the convention for inline menus), or pass VirtualView.Title ?? string.Empty if a heading is desired.
| { | ||
| if (_pickerView.Model != null) | ||
| var font = VirtualView.Font; | ||
| var fontManager = _fontManager ?? ((IElementHandler)this).GetRequiredService<IFontManager>(); |
There was a problem hiding this comment.
[minor] Inconsistent service-resolution pattern — _fontManager is cached in ConnectHandler (line 116), but CreateAttributedString (line 92) still falls back to ((IElementHandler)this).GetRequiredService<IFontManager>() if _fontManager is null. Either trust ConnectHandler and dereference the field directly (it cannot be null while VirtualView is non-null), or drop the field entirely and resolve once per call. Mixing both patterns is confusing and the fallback path implies the cache might miss — which would itself be a lifecycle bug worth fixing rather than papering over.
| return; | ||
|
|
||
| VirtualView.SelectedIndex = index; | ||
| UpdateSelectedText(); |
There was a problem hiding this comment.
[minor] Redundant work — UpdateSelectedText called twice on tap — OnMenuItemSelected sets VirtualView.SelectedIndex = index, which fires MapSelectedIndex (PickerHandler.iOS.cs:155) → UpdateSelectedText, then this method calls UpdateSelectedText directly again. With ChangesSelectionAsPrimaryAction = true UIKit also updates the title automatically. After fixing the State-based selection (see line 53 finding), drop the explicit UpdateSelectedText() call here and rely on the mapper.
| } | ||
| } | ||
| } | ||
| } No newline at end of file |
There was a problem hiding this comment.
[minor] Coding style — missing newline at EOF — File ends with } and no trailing newline (per \ No newline at end of file in the diff). MAUI repo convention is final newline. Same applies to src/Core/src/Platform/iOS/PickerExtensions.cs.
| new MauiPicker(null) { BorderStyle = UITextBorderStyle.RoundedRect }; | ||
|
|
||
| void DisplayAlert(MauiPicker uITextField, int selectedIndex) | ||
| void UpdateMenu() |
There was a problem hiding this comment.
[moderate] Regression Prevention & Test Coverage — This PR replaces the entire iOS/MacCatalyst Picker platform view and removes (a) the touch-dismiss gesture, (b) accessibility focus notifications (PostAccessibilityFocusNotification), (c) MacCatalyst popover positioning logic, (d) UIPickerView model/source, (e) Done-button accessory, and (f) MauiPickerProxy event hookup — yet no new device tests or UI tests are added in this PR. Given the author's known XAML-startup bug already, and the long tail of regressed features (alignment, IsOpen, UpdateMode, IsFocused, Opened/Closed events), this needs at minimum: a device test that asserts SetSelectedIndex(2) followed by Show() displays item 2; a regression test for Picker.IsOpen = true programmatically; a memory-leak test (Picker page → navigate away → assert WeakReference to handler is collected). Re-check git blame on the previous file for fixed-issue numbers (the deleted comments mention VoiceOver issues with EditingDidEnd and a macOS title-padding workaround) and add tests guarding those scenarios before merging.
Description of Change
Replaced the iOS/Catalyst Picker implementation to use
UIButtonwithUIMenuinstead ofUIPickerView, providing a more native-looking experience on both platforms.Key changes:
PickerHandler.iOS.cs: Rewrote to useUIButtonwithShowsMenuAsPrimaryAction = trueandChangesSelectionAsPrimaryAction = trueIPickerHandler.csandPickerHandler.csto useUIButtonas thePlatformViewtypeAccessibilityTraits = UIAccessibilityTrait.Buttonfor VoiceOver supportAdditionally, this version of Picker can be used in Mac Catalyst apps using the Mac idiom, unlike the current UIPickerView-based control.
Issues Fixed
The original
UIPickerViewimplementation was broken on Mac Catalyst when using the Mac idiom. The newUIButton+UIMenuapproach works reliably on macOS and provides a native-ish macOS appearance.Limitations
UIButtonUIButton+UIMenuon iOS/Mac CatalystKnown Issue
The top two Pickers on the picker page of
Maui.Controls.Samplethat useSelectedIndexin XAML do not appear to respect the set index (SelectedIndex), while the bottom two do. This is likely a bug in my implementation — any help tracking it down would be appreciated.