Skip to content

UIButton + UIMenu Picker for iOS/Mac Catalyst#35281

Closed
ethanl21 wants to merge 2 commits into
dotnet:mainfrom
ethanl21:catalyst-picker
Closed

UIButton + UIMenu Picker for iOS/Mac Catalyst#35281
ethanl21 wants to merge 2 commits into
dotnet: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.

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

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 — 16 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.

@dotnet dotnet deleted a comment from MauiBot May 11, 2026
@dotnet dotnet deleted a comment from MauiBot May 12, 2026
@MauiBot
Copy link
Copy Markdown
Collaborator

MauiBot commented May 12, 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-12 15:37 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 — ViewBaseTests

Detected UI test categories: ViewBaseTests

🧪 UI Test Execution Results

⏭️ SKIPPED — 0 passed, 0 failed, 1 skipped (platform: ios)

Category Result Tests Duration Notes
ViewBaseTests ⏭️ SKIPPED 0s Runner threw an exception

Failures here are informational only — they do not block the gate or affect try-fix candidate scoring.


🔍 Pre-Flight — Context & Validation

Pre-Flight — PR #35281

Summary

Title: UIButton + UIMenu Picker for iOS/Mac Catalyst
Author: ethanl21 (community)
Base: main
Files changed: 6 (+136 / -412)
Linked issues: #23999 (proposal), #10208 (Mac Catalyst Picker bug)

Goal

Replace the iOS/MacCatalyst Picker platform implementation (currently MauiPicker / UITextField + UIPickerView) with a UIButton whose Menu is a UIMenu, using ShowsMenuAsPrimaryAction = true and ChangesSelectionAsPrimaryAction = true. This is the approach proposed in #23999 and aligns iOS with Android/Windows behavior; it also fixes Mac Catalyst (#10208) where the existing UIAlertController-hosted UIPickerView was broken under the Mac idiom.

File classification

File Type Notes
src/Core/src/Handlers/Picker/IPickerHandler.cs Public-API alias PlatformView typedef changed MauiPickerUIButton
src/Core/src/Handlers/Picker/PickerHandler.cs Public-API alias same typedef change
src/Core/src/Handlers/Picker/PickerHandler.iOS.cs Handler core (iOS/Catalyst) Full rewrite (+99 / -396); removed PickerSource, MauiPickerProxy, alert/popover Catalyst path
src/Core/src/Platform/iOS/PickerExtensions.cs Platform helpers Trimmed UIPickerView selection sync; now only mirrors SelectedIndex to virtual view
src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt Public API ledger PlatformView getter return type changed, PickerSource removed
src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt Public API ledger mirror of above

Tests

  • No new tests in this PR.
  • Gate already reported: "⚠️ SKIPPED — no tests detected in this PR. Consider suggesting the author add tests."

Risk assessment / blast radius

  1. Public-API breaking change. The PlatformView getter on IPickerHandler (a public interface) changes return type from MauiPicker to UIButton. Any third-party code that has done pickerHandler.PlatformView.Text = ... or similar will fail to compile. Mitigations exist via PublicAPI.Unshipped.txt flagging, but the consumer impact is real.
  2. PickerSource public class is removed entirely. It is a public type — removal is a binary-breaking change that affects any downstream extension.
  3. Feature regressions explicitly acknowledged by author:
    • HorizontalTextAlignment / VerticalTextAlignment — no-op (was working before).
    • TextTransform — not implemented.
    • IsOpen / Opened / Closed — not supported (the events / property silently never fire).
    • UpdateMode — silently dropped.
  4. Known bug acknowledged by author: SelectedIndex set via XAML binding sometimes ignored for the first two pickers in the sample. Author says "any help tracking it down would be appreciated" — i.e., the PR ships a known regression.
  5. MapTitle / MapTitleColor / MapFont / MapTextColor / MapCharacterSpacing / MapSelectedIndex are all collapsed into UpdateSelectedText() calls. There is no menu rebuild when item titles or font change — only the button title is refreshed. This means the menu items themselves never reflect title/font/color changes after Items is first populated; menu is only rebuilt by Reload (i.e., MapItems).
  6. OnMenuItemSelected sets picker.IsFocused = false after every selection, but never sets IsFocused = true while the menu is open — IsFocused semantics on iOS Picker silently change.
  7. FontManager is fetched in ConnectHandler and cached; _fontManager = null in DisconnectHandler. CreateAttributedString has a fallback to GetRequiredService if _fontManager is null — defensive but redundant.
  8. Reload path runs UpdateMenu + UpdateSelectedText and rebuilds full UIMenuElement[] allocations each items-change — acceptable.
  9. UIMenu.Create("Picker Menu", ...) always uses the literal title "Picker Menu" — never the picker.Title. The Title property is now only surfaced as the placeholder text on the button when SelectedIndex < 0.
  10. Removed MapUnfocus for MacCatalyst — programmatic Unfocus() no longer dismisses anything (acceptable since menu auto-dismisses, but inconsistent with old API contract).
  11. PickerExtensions.cs UpdateTitleColor now identical to UpdateTitle (UpdatePickerTitle(picker)) — was previously identical but appears to still apply to the MauiPicker Android variant. Functional behavior unchanged for Android.
  12. EditingDidBegin / EditingDidEnd events on the old MauiPicker are gone — anything using Focused/Unfocused lifecycle events on the iOS Picker is affected. This is observable via Element.Focused/Unfocused.

Failure modes worth probing in try-fix

  • Public API breakage. The right approach may be to keep MauiPicker (or a new internal MauiPickerButton : UIButton) as the PlatformView type, so the public surface PlatformView keeps a Maui-owned type that consumers can rely on. Several Maui handlers do this exact pattern (MauiTextField, MauiSearchBar, etc.).
  • Known SelectedIndex-in-XAML bug. Likely caused by UpdateMenu() being called only in ConnectHandler, before items are bound, or by ordering of MapSelectedIndex vs MapItems. Worth investigating mapper order.
  • HorizontalTextAlignment/VerticalTextAlignment regression. UIButton supports ContentHorizontalAlignment / ContentVerticalAlignment and TitleLabel.TextAlignment — these CAN be implemented with no extra subclassing.
  • TextTransform regression. Can be implemented at the CreateAttributedString boundary by transforming text before constructing NSAttributedString.
  • Menu rebuild on title/font/color changes. MapFont / MapTitleColor etc. should call UpdateMenu too, otherwise menu items never reflect later style changes.

Code-review verdict (advisory for try-fix)

NEEDS_CHANGES (confidence: high)

Top concerns (advisory hints to try-fix):

  • ❌ Breaking change: public IPickerHandler.PlatformView getter return type changed (MauiPickerUIButton) and public PickerSource removed — re-using a Maui-owned wrapper (e.g. MauiPicker that wraps a UIButton, or MauiPickerButton : UIButton) would let the rewrite proceed without breaking the public surface.
  • ❌ Feature regressions silently introduced: HorizontalTextAlignment, VerticalTextAlignment, TextTransform, IsOpen/Opened/Closed, UpdateMode. UIButton supports the alignment & transform cases natively.
  • ❌ Acknowledged regression: SelectedIndex from XAML binding sometimes ignored (author asked for help in PR description).
  • ⚠️ MapFont / MapTitleColor / MapCharacterSpacing only update the button title, not the menu items themselves. Menu items keep their original UIAction title attributes.
  • ⚠️ UIMenu.Create("Picker Menu", ...) hardcodes the menu title rather than using picker.Title.
  • ⚠️ No new tests; PR ships with a known regression by author's own admission.

This verdict feeds into try-fix as advisory hints.


🔧 Fix — Analysis & Comparison

Try-Fix — PR #35281 — Aggregated narrative

Candidates explored (4 dimensions)

# Dimension Approach Diff scope Status
try-fix-1 Handler lifecycle / public API Keep MauiPicker as the PlatformView typedef; change its definition to MauiPicker : UIButton so binary compat is preserved Same files as PR, but no API ledger removals; adds [Obsolete] shim for PickerSource Analysis only (gate skipped) — would still inherit PR's MapUnfocus build break unless that is also restored
try-fix-2 Platform iOS / modern config Replace deprecated ContentEdgeInsets + manual Layer.BorderColor with UIButtonConfiguration.Bordered() so dark-mode + HIG sizing work Only PickerHandler.iOS.cs CreatePlatformView body Analysis only — same MapUnfocus caveat
try-fix-3 Regression prevention / surgical Leave iOS path unchanged; replace only the broken Mac Catalyst UIAlertController modal with a UIMenu overlay PickerHandler.iOS.cs #if MACCATALYST branch only; no public API changes Analysis only — lowest blast radius
try-fix-4 Accessibility + event lifecycle Introduce a MauiPickerButton : UIButton subclass that bridges UIKit's menu lifecycle to MAUI's IsOpen/Opened/Closed and re-emits PostAccessibilityFocusNotification; expose AccessibilityValue Adds new MauiPickerButton.cs; modifies PickerHandler.iOS.cs to use it Analysis only — fixes the regressions of #33152, IsOpen events, and AccessibilityValue

Cross-pollination

Model Round New ideas? Details
claude-opus-4.6 2 Yes Suggests combining try-fix-1 (API stability) + try-fix-4 (subclass) into one wrapper class MauiPicker : UIButton that owns both binary-compat AND the menu lifecycle bridge — single class achieves two dimensions.
claude-sonnet-4.6 2 No new idea Notes that try-fix-2 (UIButtonConfiguration) is orthogonal to all others and should be folded into the winner regardless of which is picked.
gpt-5.3-codex 2 Yes Suggests an even more minimal candidate: only restore MapUnfocus as a no-op (or implement it to clear the button's focus) — this would make the PR-as-submitted at least compile, without addressing any other concern. Falls under "pr-plus-reviewer" rather than a new try-fix.
gemini-3-pro-preview 2 No Concurs with claude-opus's merged proposal.

Round 3 — all models reply "NO NEW IDEAS". Exhausted.

Empirical evidence (the only verifiable signal)

  1. PR as-submitted has a confirmed compile break on MacCatalyst. src/Core/src/Handlers/Picker/PickerHandler.cs:43 references MapUnfocus for #elif MACCATALYST, but the rewrite deleted the MapUnfocus definition. This is a deterministic build break, verifiable by reading the file.
  2. Author explicitly acknowledged an unresolved bug in the PR description ("the top two Pickers ... using SelectedIndex in XAML do not appear to respect the set index"). Expert-reviewer root-caused this to mapper ordering + missing UIMenuElementState.On propagation.
  3. No automated tests are included to demonstrate the rewrite preserves prior behavior (Gate is SKIPPED).

Selected fix

pr-plus-reviewer — i.e. the PR's fix WITH the 5 actionable follow-ups from the expert reviewer applied:

  1. Restore MapUnfocus (fixes the MacCatalyst build break — without this NOTHING merges).
  2. Clear Menu + attributed title in DisconnectHandler; convert UIAction closures to a static + WeakReference<PickerHandler> pattern (memory leak fix).
  3. Use UIButtonConfiguration.Bordered() instead of legacy ContentEdgeInsets + manual border (dark-mode fix; try-fix-2 idea folded in).
  4. Build UIMenu from MapSelectedIndex (not just from MapItems) and tag the selected UIAction.State = UIMenuElementState.On — fixes the author-acknowledged XAML-initial-SelectedIndex bug.
  5. Replace bare UIButton with MauiPickerButton : UIButton that overrides menu open/close to fire IsOpen / Opened / Closed and call PostAccessibilityFocusNotification. Set AccessibilityValue to the current item text. (try-fix-4 idea folded in.)

Why not pure try-fix-1/2/3/4 alone: each dimension's candidate is incomplete on its own. Only the combined "pr + reviewer feedback" candidate addresses all five reviewer-identified must-fix items at once, and it does so as additive edits the original author can make in one commit — minimal disruption to the PR.

Why not pr (raw): it has a verified MacCatalyst build break and ships with an author-acknowledged regression.

Exhausted: Yes.


📋 Report — Final Recommendation

Report — PR #35281 — Comparative Analysis

Candidates evaluated

Candidate Source Build status Public-API safe Fixes acknowledged regressions Net assessment
pr PR #35281 as submitted MacCatalyst build break (PickerHandler.cs:43 references deleted MapUnfocus) *REMOVED* of PickerSource; PlatformView getter type changed ❌ ships with author-acknowledged SelectedIndex-from-XAML bug Cannot merge as-is
pr-plus-reviewer PR + 5 reviewer follow-up edits ✅ build break resolved ⚠️ still removes PickerSource, but follow-up adds [Obsolete] shim recommendation ✅ all five top-severity reviewer findings addressed in one delta Best landing path
try-fix-1 Handler lifecycle / API stability dimension ⚠️ inherits MapUnfocus break unless explicitly restored ✅ fully preserves public API ❌ does not fix feature regressions Useful complement, insufficient alone
try-fix-2 Modern UIButtonConfiguration ⚠️ same caveat ❌ same removals as PR ❌ rendering-only fix Useful complement, insufficient alone
try-fix-3 Surgical: fix only MacCatalyst ✅ buildable; no public API changes ✅ none broken ⚠️ doesn't deliver #23999 modernization Lowest-risk alternative if author/owners decide the iOS rewrite should be deferred
try-fix-4 Accessibility + menu lifecycle subclass ⚠️ same caveat ❌ same removals as PR ✅ restores IsOpen/Opened/Closed + VoiceOver focus Useful complement, insufficient alone

Ranking (per "failed-tests-rank-lower" rule)

  1. pr-plus-reviewer — only candidate that both builds and addresses all critical reviewer findings.
  2. try-fix-3 — buildable and minimal; ideal if owners want to defer the iOS modernization.
  3. try-fix-1 / try-fix-4 — each fixes one dimension and would themselves need to also restore MapUnfocus to build.
  4. try-fix-2 — buildable with the MapUnfocus fix; rendering improvement only.
  5. pr — last because it has a confirmed compile break on net-maccatalyst.

Note: Gate is SKIPPED (no tests in PR), so we have no test-pass data for any candidate. The "build break" status of pr is verified by static inspection (CompileCommand mapping table references MapUnfocus symbol which the rewrite deleted) and is the strongest empirical signal available.

Winner

pr-plus-reviewer — apply the five reviewer follow-ups on top of the PR. This:

  • Resolves the deterministic MacCatalyst compile break.
  • Fixes the author-acknowledged SelectedIndex-from-XAML bug via mapper ordering + UIMenuElementState.On propagation.
  • Restores the accessibility behaviour that [iOS] Fix VoiceOver focus not shifting to Picker/DatePicker/TimePicker popups #33152 introduced.
  • Restores IsOpen/Opened/Closed events that the rewrite silently dropped.
  • Switches to a modern, theming-correct UIButtonConfiguration.

These five items can be applied as one additional commit by the PR author; no full re-architecture is needed.

Recommendation

  • DO NOT merge as-is — the net-maccatalyst TFM will not compile.
  • Comment summarizing the five required edits to the author.
  • Ask the author to add at least one device test (open the menu programmatically, validate IsOpen and SelectedIndex round-trip) since the PR currently has none.
  • After the five follow-ups are pushed, re-run the device-tests pipeline (maui-pr-devicetests) on both net-ios and net-maccatalyst to validate behavior empirically.

Full inline findings

See CustomAgentLogsTmp/PRState/35281/PRAgent/inline-findings.json (14 findings: 1 error, 7 warnings, 6 suggestions) and CustomAgentLogsTmp/PRState/35281/PRAgent/expert-pr-eval/content.md.


@kubaflo kubaflo added the s/pr-needs-author-input PR needs an update from the author label May 12, 2026
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Hi @@ethanl21. We have added the "s/pr-needs-author-input" label to this issue, which indicates that we have an open question/action for you before we can take further action. This PRwill be closed automatically in 14 days if we do not hear back from you by then - please feel free to re-open it if you come back to this PR after that time.

kubaflo pushed a commit that referenced this pull request May 19, 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>
@ethanl21 ethanl21 closed this May 20, 2026
kubaflo pushed a commit that referenced this pull request May 22, 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) s/pr-needs-author-input PR needs an update from the author

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