Skip to content

Conversation

@Nitin-100
Copy link
Contributor

@Nitin-100 Nitin-100 commented Jan 21, 2026

Fix XAML popup positioning and light dismiss in ScrollView (#15557)

Description

This PR fixes Issue #15557 - "Pop-ups of Xaml controls need positioning and dismissal"

When XAML controls with popups (like ComboBox, DatePicker, TimePicker) are hosted inside a React Native ScrollView via ContentIslandComponentView, two bugs occur:

  1. Bug 1 (Position): After scrolling, opening the popup shows it at the wrong position (offset by the scroll amount)
  2. Bug 2 (Light Dismiss): Opening a popup and then scrolling leaves the popup floating at its original position instead of dismissing

Root Cause Analysis

Bug 1: Popup Position

  • ContentIslandComponentView uses ChildSiteLink.LocalToParentTransformMatrix for popup positioning
  • Issue 1: getClientRect() returns values in physical pixels (scaled by pointScaleFactor), but LocalToParentTransformMatrix expects logical pixels (DIPs)
  • Issue 2: Transform was updated asynchronously via UIDispatcher().Post(), causing race conditions where popup opened with stale transform values
  • Issue 3: Initial transform wasn't set before Connect(), causing wrong position even without scrolling

Bug 2: Light Dismiss on Scroll

  • XAML's native light dismiss behavior wasn't being triggered when scroll begins in React Native

Solution

Bug 1 Fix: Popup Position After Scroll

  1. Convert to DIPs: Divide getClientRect() values by pointScaleFactor before setting LocalToParentTransformMatrix
  2. Synchronous updates: Remove async UIDispatcher().Post() wrapper - update transform immediately
  3. Initial transform: Set LocalToParentTransformMatrix in OnMounted() before Connect()
  4. Scroll notification: ScrollViewComponentView fires LayoutMetricsChanged event when scroll position changes

Bug 2 Fix: Light Dismiss on Scroll (Optimized)

  • Added DismissPopupsRequest event to ContentIslandComponentView
  • Added ScrollBeginDrag event to ScrollViewComponentView
  • Optimized registration: During OnMounted(), ContentIslandComponentView walks up the tree once and registers for ScrollBeginDrag on all parent ScrollViewComponentView instances
  • When scroll begins, ScrollViewComponentView fires its ScrollBeginDrag event, which notifies only the registered ContentIslandComponentView instances
  • No tree walking on every scroll - registration happens once during mount
  • 3rd party XAML components subscribe to DismissPopupsRequest and dismiss their own popups
  • Core remains XAML-agnostic - no XAML-specific code in the framework

Files Changed

Core Fix (vnext/Microsoft.ReactNative/)

  • Fabric/Composition/ContentIslandComponentView.cpp - DIP conversion, sync updates, register for parent ScrollView events during mount
  • Fabric/Composition/ContentIslandComponentView.h - Store scroll subscriptions
  • Fabric/Composition/ScrollViewComponentView.cpp - Fire LayoutMetricsChanged on scroll, expose ScrollBeginDrag event
  • Fabric/Composition/ScrollViewComponentView.h - ScrollBeginDrag event
  • CompositionComponentView.idl - Added DismissPopupsRequest event to ContentIslandComponentView, ScrollBeginDrag event to ScrollViewComponentView

Testing

  1. Run the XamlPopupRepro sample in Playground
  2. Scroll down in the ScrollView
  3. Click a ComboBox to open dropdown - should appear at correct position (right at the button)
  4. Open a ComboBox dropdown, then scroll - dropdown should close automatically

##Screenshot

testingFix.mp4

…#15557)

- Add SetXamlRoot() API on ContentIslandComponentView for 3rd party XAML components
- Add DismissPopups() using VisualTreeHelper.GetOpenPopupsForXamlRoot()
- Fire LayoutMetricsChanged on scroll to update popup positions
- Dismiss child ContentIsland popups when scroll begins
- Add ComboBox sample component demonstrating the pattern
- Add xamlPopupBug test sample for playground-composition
@Nitin-100 Nitin-100 requested review from a team as code owners January 21, 2026 07:49
Copy link
Contributor

@sundaramramaswamy sundaramramaswamy left a comment

Choose a reason for hiding this comment

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

Please address comments.

@microsoft-github-policy-service microsoft-github-policy-service bot added the Needs: Author Feedback The issue/PR needs activity from its author (label drives bot activity) label Jan 23, 2026
…#15557)

- Set LocalToParentTransformMatrix synchronously before Connect() to fix initial position
- Fire LayoutMetricsChanged on scroll to update position after scrolling
- Add DismissPopupsRequest event for 3P components to implement light dismiss
- Update ComboBox sample to use event-based approach (core remains XAML-agnostic)
@microsoft-github-policy-service microsoft-github-policy-service bot removed the Needs: Author Feedback The issue/PR needs activity from its author (label drives bot activity) label Jan 25, 2026
Nitin Chaudhary and others added 3 commits January 25, 2026 19:08
Issue microsoft#15557: Fix popup positioning for XAML controls in ScrollView

Bug 1 (Position Fix):
- Convert getClientRect() physical pixels to DIPs by dividing by pointScaleFactor
- LocalToParentTransformMatrix expects logical pixels (DIPs), not physical pixels
- Update transform synchronously instead of async to avoid race conditions
- Set initial transform in OnMounted() before Connect()

Bug 2 (Light Dismiss Fix):
- Add DismissPopupsRequest event for 3P components to handle popup dismissal
- ScrollView fires event when scroll begins via DismissChildContentIslandPopups()
- Core remains XAML-agnostic - 3P components handle their own popups
@Nitin-100 Nitin-100 self-assigned this Jan 27, 2026
Copy link
Contributor

@sundaramramaswamy sundaramramaswamy left a comment

Choose a reason for hiding this comment

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

Please revert formatting changes.

Copy link
Contributor

@sundaramramaswamy sundaramramaswamy Jan 27, 2026

Choose a reason for hiding this comment

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

I still see a huge red blob of formatting changes. It's hard to review with formatting changes.

@microsoft-github-policy-service microsoft-github-policy-service bot added the Needs: Author Feedback The issue/PR needs activity from its author (label drives bot activity) label Jan 27, 2026
@microsoft-github-policy-service microsoft-github-policy-service bot removed the Needs: Author Feedback The issue/PR needs activity from its author (label drives bot activity) label Jan 27, 2026
@Nitin-100 Nitin-100 force-pushed the nitinc/fix-xaml-popup-positioning-15557 branch from f0d7eb5 to e8cfe99 Compare January 27, 2026 14:27
runtimeclass ContentIslandComponentView : ViewComponentView {
void Connect(Microsoft.UI.Content.ContentIsland contentIsland);

// Issue #15557: Event fired when a parent ScrollView starts scrolling.
Copy link
Contributor

Choose a reason for hiding this comment

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

We should bring this up with the ContentIsland folks. This should be handled by a shared popup manager of some kind. ContentIslands should not need to talk to any RNW specific APIs to properly implement controls.

Copy link
Contributor

Choose a reason for hiding this comment

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

Possibly InputLightDismissAction should be extended to provide this functionality.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes sure, we can connect for long term strategy, let me know with whom I can connect to follow up.

float x = static_cast<float>(clientRect.left) / scaleFactor;
float y = static_cast<float>(clientRect.top) / scaleFactor;

m_childSiteLink.LocalToParentTransformMatrix(
Copy link
Contributor

Choose a reason for hiding this comment

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

The reason for the post before was to batch notifications. During a layout pass it's likely that a bunch of the parent layouts will change. So, this will end up getting called a bunch of times. Is it pretty cheap call?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, LocalToParentTransformMatrix is a cheap call - it just sets a 4x4 matrix value.
The async approach caused race conditions where the popup would open with stale transform values (before the async update executed). Synchronous updates ensure the transform is always correct when the popup opens.
Additionally:

  • During initial mount, ParentLayoutChanged() is only called once at the end
  • During scroll, events are already throttled by the scroll visual
  • Added a comment in the code explaining why sync is required

// This notifies 3P components to dismiss their own popups - implementing light dismiss behavior.
void ScrollViewComponentView::DismissChildContentIslandPopups() noexcept {
// Helper lambda to recursively find ContentIslandComponentView children and fire the event
std::function<void(const winrt::Microsoft::ReactNative::ComponentView &)> fireEventRecursively =
Copy link
Contributor

Choose a reason for hiding this comment

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

This looks like potentially quite expensive work to be doing continuously on scroll. It would probably be better to have the content island register for the event on all its parent scrollviews during its onMount. So, you only have to do the tree walk once.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, Good point, I re-implemented it:

  • ContentIslandComponentView now registers for ScrollBeginDrag on all parent ScrollViewComponentView instances during OnMounted() (one-time tree walk)
  • ScrollViewComponentView exposes a ScrollBeginDrag event and just fires it when scroll begins
  • No tree walking on every scroll - registration happens once during mount
  • Cleanup happens in OnUnmounted()

This is the same pattern used for LayoutMetricsChanged registration.

@acoates-ms
Copy link
Contributor

acoates-ms commented Jan 27, 2026

Assuming LocalToParentTransformMatrix is pretty cheap, the LocalToParentTransformMatrix changes look good.

The light dismiss logic seems more wrong. This looks like we need additional hosting APIs from ContentIslands. ContentIslands should provide APIs to handle this kind of communication, and Xaml / RNW should talk to the new API. Xaml content islands should not have to know about RNW APIs - it breaks the ContentIsland encapsulation.


// Issue #15557: Fire DismissPopupsRequest event on child ContentIslandComponentView instances when scroll begins.
// This notifies 3P components to dismiss their own popups - implementing light dismiss behavior.
void ScrollViewComponentView::DismissChildContentIslandPopups() noexcept {
Copy link
Contributor

Choose a reason for hiding this comment

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

If you have nested ScrollViews, both will fire DismissChildContentIslandPopups() on scroll begin. The inner ScrollView will walk all its children, and the outer ScrollView will also walk all children (including the inner ScrollView's children). Maybve you can consider tracking whether a ContentIsland has already received the dismiss event in this frame to avoid redundant processing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is already addressed with the optimization I just pushed!
With the new implementation:

  • ContentIslandComponentView registers with ALL parent ScrollViews during OnMounted() (one-time tree walk up)
  • Each ScrollView just fires its ScrollBeginDrag event to registered listeners
  • No tree walking on every scroll
  • No nested iteration issue - each ContentIsland gets notified once by its direct parent ScrollView subscription
    This is the same pattern used for LayoutMetricsChanged.

// Issue #15557: Fire LayoutMetricsChanged to notify ContentIslandComponentView instances
// that scroll position has changed, so they can update their LocalToParentTransformMatrix
// for correct XAML popup positioning.
void ScrollViewComponentView::FireLayoutMetricsChangedForScrollPositionChange() noexcept {
Copy link
Contributor

Choose a reason for hiding this comment

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

The method fires a LayoutMetricsChanged event with identical old and new metrics. This is semantically misleading - receivers of this event might check if oldMetrics != newMetrics and ignore the event.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Valid observation. The LayoutMetricsChanged event with identical old/new metrics is intentionally used to notify descendants that the scroll position changed (not the layout metrics themselves).
ContentIslandComponentView doesn't compare old vs new - it just recalculates its position via getClientRect() which includes the scroll offset. So the identical metrics don't cause any issue.
Alternative would be to add a new ScrollPositionChanged event, but that would be more invasive. The current approach reuses existing infrastructure and works correctly.

@Nitin-100 Nitin-100 requested a review from a team January 28, 2026 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pop-ups of Xaml controls need positioning and dismissal

4 participants