Skip to content

UIButton + UIMenu Picker for iOS/Mac Catalyst#35281

Open
ethanl21 wants to merge 2 commits intodotnet:mainfrom
ethanl21:catalyst-picker
Open

UIButton + UIMenu Picker for iOS/Mac Catalyst#35281
ethanl21 wants to merge 2 commits intodotnet:mainfrom
ethanl21:catalyst-picker

Conversation

@ethanl21
Copy link
Copy Markdown

@ethanl21 ethanl21 commented May 2, 2026

Description of Change

Replaced the iOS/Catalyst Picker implementation to use UIButton with UIMenu instead of UIPickerView, providing a more native-looking experience on both platforms.

Key changes:

  • PickerHandler.iOS.cs: Rewrote to use UIButton with ShowsMenuAsPrimaryAction = true and ChangesSelectionAsPrimaryAction = true
  • Updated IPickerHandler.cs and PickerHandler.cs to use UIButton as the PlatformView type
  • Added basic styling (background, border, corner radius) to match the original picker appearance
  • Added AccessibilityTraits = UIAccessibilityTrait.Button for VoiceOver support
  • Removed obsolete Mac Catalyst fallback code
  • Implemented attributed string support for Font, TextColor, TitleColor, and CharacterSpacing
  • Fixed TitleColor to apply to placeholder text when no item is selected

Additionally, 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 UIPickerView implementation was broken on Mac Catalyst when using the Mac idiom. The new UIButton + UIMenu approach works reliably on macOS and provides a native-ish macOS appearance.

Limitations

  • HorizontalTextAlignment / VerticalTextAlignment: Not supported by UIButton
  • TextTransform: Not implemented (requires additional attributed string work)
  • IsOpen/Opened/Closed: Not supported for UIButton + UIMenu on iOS/Mac Catalyst
  • UpdateMode: No longer implemented, likely not possible or necessary

Known Issue

The top two Pickers on the picker page of Maui.Controls.Sample that use SelectedIndex in 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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 2, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35281

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35281"

@ethanl21
Copy link
Copy Markdown
Author

ethanl21 commented May 2, 2026

@dotnet-policy-service agree

@ethanl21 ethanl21 changed the title Catalyst picker UIButton + UIMenu Picker for iOS/Mac Catalyst May 2, 2026
Copy link
Copy Markdown
Contributor

@kubaflo kubaflo left a comment

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

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

Expert Review — 15 findings

See inline comments for details.

@MauiBot MauiBot added s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) labels May 3, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

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

Expert Review — 15 findings

See inline comments for details.

@MauiBot MauiBot added s/agent-review-incomplete AI agent could not complete all phases (blocker, timeout, error) and removed s/agent-changes-requested AI agent recommends changes - found a better alternative or issues labels May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
kubaflo pushed a commit that referenced this pull request May 8, 2026
…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>
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@MauiBot
Copy link
Copy Markdown
Collaborator

MauiBot commented May 8, 2026

🤖 AI Summary

👋 @ethanl21 — new AI review results are available. Please review the latest session below.

📊 Review Session0e0ec39 · Allow styling picker button label · 2026-05-08 14:03 UTC
🚦 Gate — Test Before & After Fix

Gate Result: ⚠️ SKIPPED

No tests were detected in this PR.

Recommendation: Add tests to verify the fix using the write-tests-agent.


🧪 UI Tests — Category Detection

Detected UI test categories: Picker,ViewBaseTests

🧪 UI Test Execution Results

FAILED — 0 passed, 1 failed, 0 skipped (platform: catalyst)

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 a UIButton configured with ShowsMenuAsPrimaryAction = true and ChangesSelectionAsPrimaryAction = true, populated by a UIMenu of UIActions.
  • 🚨 Build-breaking on MacCatalyst. src/Core/src/Handlers/Picker/PickerHandler.cs:43 still wires [nameof(IPicker.Unfocus)] = MapUnfocus inside #elif MACCATALYST, but the new PickerHandler.iOS.cs deleted MapUnfocus along with _pickerController. There is no MapUnfocus symbol left on the iOS/MacCatalyst partial → CS0103 on Catalyst. The Android PickerHandler.Android.cs defines its own copy, so Android still builds.
  • 🚨 Author-acknowledged SelectedIndex-in-XAML bug. With ChangesSelectionAsPrimaryAction = true, UIButton derives its rendered title from the UIAction whose State == UIMenuElementState.On. The PR never sets State on any action and UpdateMenu() rebuilds the menu without preserving / applying the current SelectedIndex. MapSelectedIndex only calls UpdateSelectedText(), 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: SelectedIndex set declaratively in XAML before Items are bound (or any time the menu is rebuilt) is silently dropped.
  • ⚠️ Silent feature regressions. MapHorizontalTextAlignment and MapVerticalTextAlignment are now empty bodies — XAML that sets these will compile and run with zero effect (no diagnostic warning). MapIsOpen was also lost. The Controls-layer Picker mapper still pushes TextTransform/FontAttributes/FontAutoScalingEnabled through MapFont, so font scale/attributes still flow, but TextTransform is not applied to either the menu item titles or the button title (PR description acknowledges).
  • ⚠️ Public API binary break. IPickerHandler.PlatformView changed from Microsoft.Maui.Platform.MauiPicker! to UIKit.UIButton!. The PublicAPI.Unshipped.txt files document the change correctly with *REMOVED* lines, but any third-party effect/library that referenced handler.PlatformView as a MauiPicker (or as a UITextField-derived class for keyboard/border tweaks) will fail to compile against this build.
  • ⚠️ Lifecycle / retain risk. Each UIAction.Create("…", null, null, _ => OnMenuItemSelected(index)) captures this (the handler). The button retains its Menu, the menu retains its actions, the actions retain the closure, the closure retains the handler. The handler stays referenced by MauiContext and the virtual view, so this is probably not a leak per se, but DisconnectHandler should null PlatformView.Menu to break the cycle on view replacement / handler reuse. Currently DisconnectHandler only nulls _fontManager and forwards to base.
  • ⚠️ Lost behavior vs. the previous control:
    • IsFocused / IsOpen are no longer driven from the UI (the old OnStarted / OnEnded set them; the new code only resets IsFocused = false when an item is picked). Bindings to IsFocused will be stuck at false.
    • Accessibility focus notifications (PostAccessibilityFocusNotification) for VoiceOver popup focus are gone.
    • The control no longer renders as a UITextField with a "RoundedRect" border — replaced by a manual Layer.BorderWidth/Color/CornerRadius. Acceptable, but the styling is hard-coded (no Background, BackgroundColor, Border mapping).
    • ContentEdgeInsets is deprecated in iOS 15+; should use UIButton.Configuration (or guard with OperatingSystem.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) initial SelectedIndex from XAML, (b) Items binding update with retained selection, (c) Mac Catalyst rendering.
  • 💡 Cosmetic: missing newline at EOF in PickerHandler.iOS.cs and PickerExtensions.cs; _fontManager is cached at Connect but CreateAttributedString still falls back to ((IElementHandler)this).GetRequiredService<IFontManager>() — pick one pattern.
  • 💡 UpdateMenu rebuild cost. Every Items/SelectedIndex change rebuilds the entire UIMenu. For long lists this allocates an UIMenuElement[count] + count closures. 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 references MapUnfocus which no longer exists in PickerHandler.iOS.cs. Build break on MacCatalyst.
  • src/Core/src/Handlers/Picker/PickerHandler.iOS.cs:UpdateMenu / UpdateSelectedTextChangesSelectionAsPrimaryAction = true requires a UIAction to have State = UIMenuElementState.On for the displayed title to match SelectedIndex. The PR never sets State, 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 null PlatformView.Menu; UIAction closures keep capturing this.
  • ⚠️ src/Core/src/Handlers/Picker/PickerHandler.iOS.cs:CreatePlatformViewContentEdgeInsets is deprecated since iOS 15.
  • ⚠️ src/Core/src/Handlers/Picker/IPickerHandler.cs & PublicAPI/...Unshipped.txtPlatformView type change from MauiPickerUIButton is a binary break for third-party libraries.
  • ⚠️ IsFocused / IsOpen not driven from UIButton menu open/close — bindings will be stale.
  • ⚠️ Accessibility notifications (PostAccessibilityFocusNotification) lost — VoiceOver regressions.
  • 💡 UpdateImmediately field is now dead code.
  • 💡 Missing newline at EOF in PickerHandler.iOS.cs and PickerExtensions.cs.
  • 💡 Cache IFontManager consistently (either always via _fontManager or via GetRequiredService — 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) ⚠️ SKIPPED (no tests in PR) — likely build-broken on Catalyst 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 — Catalyst CommandMapper references MapUnfocus, which the new PickerHandler.iOS.cs deletes. MacCatalyst build break (CS0103).
  • src/Core/src/Handlers/Picker/PickerHandler.iOS.cs:UpdateMenu/UpdateSelectedTextChangesSelectionAsPrimaryAction = true makes UIButton render the title from the UIAction whose State == UIMenuElementState.On. The PR never sets State, so SetAttributedTitle is 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 null PlatformView.Menu. UIAction lambdas capture this, 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.txtIPickerHandler.PlatformView type change from Microsoft.Maui.Platform.MauiPicker! to UIKit.UIButton! is a binary-breaking change for any third-party effect/library.
  • src/Core/src/Handlers/Picker/PickerHandler.iOS.cs:CreatePlatformViewContentEdgeInsets is deprecated since iOS 15. Use UIButton.Configuration (UIButtonConfiguration.PlainButtonConfiguration) instead.
  • IsFocused / IsOpen are no longer driven from menu open/close. Bindings to these will be stuck.
  • PostAccessibilityFocusNotification calls 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 convert MapUpdateMode (in Controls/Picker.iOS.cs) to a logged no-op.
  • Cache IFontManager consistently (currently _fontManager is set in Connect but CreateAttributedString falls back to ((IElementHandler)this).GetRequiredService<IFontManager>() — pick one).
  • Missing newline at EOF in PickerHandler.iOS.cs and PickerExtensions.cs.
  • UpdateMenu rebuilds the entire UIMenu for every Items/SelectedIndex change. For long lists this allocates count closures; cache the action array and just toggle State.

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-ios and net-maccatalyst Picker only.
  • 11 *REMOVED* API entries; PickerSource removed from public API.
  • Cross-platform Mapper signatures unchanged → Android / Windows / Tizen unaffected.
  • Downstream community libraries that use handler.PlatformView as MauiPicker (UITextField) will see runtime InvalidCastException after 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 ⚠️ N/A — Gate SKIPPED (no tests) 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 ⚠️ N/A — Gate SKIPPED 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 ⚠️ N/A — Gate SKIPPED 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 ⚠️ N/A — Gate SKIPPED 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 ⚠️ N/A — Gate SKIPPED 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 ⚠️ N/A — Gate SKIPPED 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) ⚠️ N/A — Gate SKIPPED
pr-plus-reviewer (PR + A1–A5) ✅ (UIAction.State) ✅ (Menu/title cleared) Partial (HorizontalAlign + log-warn rest) ⚠️ N/A — Gate SKIPPED
try-fix-1 (correctness) ⚠️ N/A — Gate SKIPPED
try-fix-2 (public API) ✅ (preserves MapUnfocus path) partial (still needs A1) ✅ (UITextField cleanup) ⚠️ N/A — Gate SKIPPED
try-fix-3 (regression prevention) ⚠️ N/A — Gate SKIPPED
try-fix-4 (platform specifics) partial (needs A1) partial (accessibility) ⚠️ N/A — Gate SKIPPED

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.

  1. 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 of try-fix-1; folds in parts of try-fix-3 (log-warns) and try-fix-4 (UIButton.Configuration / accessibility).
  2. 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.
  3. try-fix-2 — strongest "no public API break" answer. Reasonable if the team rules the IPickerHandler.PlatformView change unacceptable; otherwise it adds layout complexity for limited extra value.
  4. try-fix-4 — best UIKit modernization story but doesn't fix Catalyst build alone.
  5. try-fix-3 — useful only as a layer on top of one of the above.
  6. 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:

  1. Re-introduce a MapUnfocus stub for MacCatalyst (or remove the CommandMapper line) — fixes the build break.
  2. Cache UIAction[] and toggle UIMenuElementState.On to fix the XAML SelectedIndex bug.
  3. Clear PlatformView.Menu and the cached actions in DisconnectHandler — fixes the retain cycle.
  4. Implement MapHorizontalTextAlignment via ContentHorizontalAlignment and either implement or log-warn the rest (IsOpen=true, UpdateMode, VerticalTextAlignment).
  5. Replace deprecated ContentEdgeInsets with UIButton.Configuration (UIButtonConfiguration.BorderedButtonConfiguration recommended).
  6. Add at least one DeviceTest that asserts picker.SelectedIndex = N set in XAML displays "Item N" — directly catches the author-flagged bug and prevents regression.
  7. Discuss the IPickerHandler.PlatformView type change with maintainers (binary-breaking; consider try-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.


Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

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

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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[critical] Public API Surface — binary breaking changeIPickerHandler.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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[major] Logic & CorrectnessUpdateSelectedText 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));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[major] Memory Leak Prevention — Each UIAction lambda captures index plus this (via OnMenuItemSelected). The retain chain is: HandlerPlatformView (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);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[major] Handler lifecycle — DisconnectHandler is incompleteDisconnectHandler 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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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>();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[minor] Redundant work — UpdateSelectedText called twice on tapOnMenuItemSelected 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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

s/agent-review-incomplete AI agent could not complete all phases (blocker, timeout, error) s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Picker on iOS/Catalyst should use UIButton with menu rather than UIPickerView Picker behavior on Mac Catalyst

3 participants