diff --git a/change/@react-native-windows-automation-0a641d65-6bc9-4769-8666-f856ff15f040.json b/change/@react-native-windows-automation-0a641d65-6bc9-4769-8666-f856ff15f040.json new file mode 100644 index 00000000000..db317b9bef6 --- /dev/null +++ b/change/@react-native-windows-automation-0a641d65-6bc9-4769-8666-f856ff15f040.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Fix XAML popup positioning and light dismiss in ScrollView (#15557)", + "packageName": "@react-native-windows/automation", + "email": "nitchaudhary@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@react-native-windows-automation-channel-e7ad73ac-7704-441b-81a3-2d8f97ece377.json b/change/@react-native-windows-automation-channel-e7ad73ac-7704-441b-81a3-2d8f97ece377.json new file mode 100644 index 00000000000..3c13d2e78d7 --- /dev/null +++ b/change/@react-native-windows-automation-channel-e7ad73ac-7704-441b-81a3-2d8f97ece377.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Fix XAML popup positioning and light dismiss in ScrollView (#15557)", + "packageName": "@react-native-windows/automation-channel", + "email": "nitchaudhary@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@react-native-windows-automation-commands-6b4b918c-021a-41a9-8eac-5ed58b0cec22.json b/change/@react-native-windows-automation-commands-6b4b918c-021a-41a9-8eac-5ed58b0cec22.json new file mode 100644 index 00000000000..1f3e4e525ce --- /dev/null +++ b/change/@react-native-windows-automation-commands-6b4b918c-021a-41a9-8eac-5ed58b0cec22.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Fix XAML popup positioning and light dismiss in ScrollView (#15557)", + "packageName": "@react-native-windows/automation-commands", + "email": "nitchaudhary@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/react-native-windows-83ceb93b-b350-41ed-a00b-0cd4927d42cb.json b/change/react-native-windows-83ceb93b-b350-41ed-a00b-0cd4927d42cb.json new file mode 100644 index 00000000000..864a7eab89c --- /dev/null +++ b/change/react-native-windows-83ceb93b-b350-41ed-a00b-0cd4927d42cb.json @@ -0,0 +1,7 @@ +{ + "comment": "Fix XAML popup positioning and light dismiss in ScrollView (#15557)", + "type": "prerelease", + "packageName": "react-native-windows", + "email": "nitchaudhary@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/playground/Samples/xamlPopupBug.tsx b/packages/playground/Samples/xamlPopupBug.tsx new file mode 100644 index 00000000000..db5459005eb --- /dev/null +++ b/packages/playground/Samples/xamlPopupBug.tsx @@ -0,0 +1,152 @@ +/** + * XAML Popup Positioning Bug Repro - Issue #15557 + * + * HOW TO REPRO: + * 1. Run this sample in Playground + * 2. SCROLL DOWN in the ScrollView + * 3. Click on the ComboBox to open the dropdown popup + * 4. BUG: The popup appears at the WRONG position! + * + * The popup offset = how much you scrolled + */ + +import React from 'react'; +import {AppRegistry, ScrollView, View, Text, StyleSheet} from 'react-native'; +import {ComboBox} from 'sample-custom-component'; + +const XamlPopupBugRepro = () => { + const [selectedValue, setSelectedValue] = React.useState('(click to select)'); + + return ( + + {/* Header - Fixed at top */} + + XAML Popup Bug Repro #15557 + Selected: {selectedValue} + + + {/* Instructions */} + + 1. SCROLL DOWN in the box below + 2. Click a ComboBox to open dropdown + 3. See the popup at WRONG position! + + + {/* Scrollable area with ComboBoxes */} + + + SCROLL DOWN + + + + Keep scrolling... + + + + Almost there... + + + {/* First ComboBox */} + + ComboBox 1 - Click me! + { + setSelectedValue(`CB1: ${e.nativeEvent.selectedValue}`); + }} + /> + + + + More space... + + + {/* Second ComboBox */} + + ComboBox 2 - Click me! + { + setSelectedValue(`CB2: ${e.nativeEvent.selectedValue}`); + }} + /> + + + + End of content + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#1a1a2e', + }, + header: { + padding: 20, + backgroundColor: '#16213e', + alignItems: 'center', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + color: '#fff', + }, + subtitle: { + fontSize: 16, + color: '#0f0', + marginTop: 5, + }, + instructions: { + padding: 15, + backgroundColor: '#0f3460', + }, + step: { + fontSize: 18, + color: '#fff', + marginVertical: 3, + }, + scrollView: { + flex: 1, + margin: 10, + borderWidth: 3, + borderColor: '#e94560', + borderRadius: 10, + }, + spacer: { + height: 200, + justifyContent: 'center', + alignItems: 'center', + margin: 10, + borderRadius: 10, + }, + spacerText: { + fontSize: 24, + fontWeight: 'bold', + color: '#fff', + }, + comboBoxContainer: { + margin: 10, + padding: 15, + backgroundColor: '#fff', + borderRadius: 10, + borderWidth: 3, + borderColor: '#e94560', + }, + comboLabel: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 10, + color: '#1a1a2e', + }, + comboBox: { + width: 350, + height: 60, + }, +}); + +AppRegistry.registerComponent('Bootstrap', () => XamlPopupBugRepro); +export default XamlPopupBugRepro; diff --git a/packages/playground/windows/playground-composition/Playground-Composition.cpp b/packages/playground/windows/playground-composition/Playground-Composition.cpp index 0ca8491496f..e13b70f8238 100644 --- a/packages/playground/windows/playground-composition/Playground-Composition.cpp +++ b/packages/playground/windows/playground-composition/Playground-Composition.cpp @@ -379,7 +379,8 @@ struct WindowData { LR"(Samples\mouse)", LR"(Samples\scrollViewSnapSample)", LR"(Samples\simple)", LR"(Samples\text)", LR"(Samples\textinput)", LR"(Samples\ticTacToe)", - LR"(Samples\view)", LR"(Samples\debugTest01)"}; + LR"(Samples\view)", LR"(Samples\debugTest01)", + LR"(Samples\xamlPopupBug)"}; static INT_PTR CALLBACK Bundle(HWND hwnd, UINT message, WPARAM wparam, LPARAM /*lparam*/) noexcept { switch (message) { diff --git a/packages/sample-custom-component/src/FabricXamlComboBoxNativeComponent.ts b/packages/sample-custom-component/src/FabricXamlComboBoxNativeComponent.ts new file mode 100644 index 00000000000..6ede7ffac5b --- /dev/null +++ b/packages/sample-custom-component/src/FabricXamlComboBoxNativeComponent.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * @format + * @flow + */ + +'use strict'; + +// ComboBox component for testing XAML popup positioning bug #15557 +// The ComboBox dropdown popup should appear at the correct position after scrolling + +import {codegenNativeComponent} from 'react-native'; +import type {ViewProps} from 'react-native'; +import type { + DirectEventHandler, + Int32, +} from 'react-native/Libraries/Types/CodegenTypes'; + +type SelectionChangedEvent = Readonly<{ + selectedIndex: Int32; + selectedValue: string; +}>; + +export interface ComboBoxProps extends ViewProps { + selectedIndex?: Int32; + placeholder?: string; + onSelectionChanged?: DirectEventHandler; +} + +export default codegenNativeComponent('ComboBox'); diff --git a/packages/sample-custom-component/src/index.ts b/packages/sample-custom-component/src/index.ts index 49b2bd07d43..eb312b6a4dd 100644 --- a/packages/sample-custom-component/src/index.ts +++ b/packages/sample-custom-component/src/index.ts @@ -5,6 +5,8 @@ import DrawingIsland from './DrawingIsland'; import CalendarView from './FabricXamlCalendarViewNativeComponent' +import ComboBox from './FabricXamlComboBoxNativeComponent' + import CustomAccessibility from './CustomAccessibilityNativeComponent'; export { @@ -13,4 +15,5 @@ export { MovingLight, MovingLightHandle, CalendarView, -}; \ No newline at end of file + ComboBox, +}; diff --git a/packages/sample-custom-component/windows/SampleCustomComponent/ComboBox.cpp b/packages/sample-custom-component/windows/SampleCustomComponent/ComboBox.cpp new file mode 100644 index 00000000000..6b8f57b865c --- /dev/null +++ b/packages/sample-custom-component/windows/SampleCustomComponent/ComboBox.cpp @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// ComboBox component for testing XAML popup positioning bug #15557 +#include "pch.h" + +#include "ComboBox.h" + +#if defined(RNW_NEW_ARCH) + +#include "codegen/react/components/SampleCustomComponent/ComboBox.g.h" + +#include +#include +#include + +namespace winrt::SampleCustomComponent { + +// ComboBox component to test popup positioning issue #15557 +// When inside a ScrollView, the dropdown popup should appear at the correct position +// Bug 1: After scrolling, the popup appears at the wrong offset (FIXED via LayoutMetricsChanged) +// Bug 2: When popup is open and user scrolls, popup should dismiss (FIXED via DismissPopupsRequest event) + +struct ComboBoxComponentView : public winrt::implements, + Codegen::BaseComboBox { + void InitializeContentIsland( + const winrt::Microsoft::ReactNative::Composition::ContentIslandComponentView &islandView) noexcept { + m_xamlIsland = winrt::Microsoft::UI::Xaml::XamlIsland{}; + m_comboBox = winrt::Microsoft::UI::Xaml::Controls::ComboBox{}; + + // Add default items + m_comboBox.Items().Append(winrt::box_value(L"Option 1 - Select me after scrolling")); + m_comboBox.Items().Append(winrt::box_value(L"Option 2 - Test popup position")); + m_comboBox.Items().Append(winrt::box_value(L"Option 3 - Bug #15557")); + m_comboBox.Items().Append(winrt::box_value(L"Option 4 - Popup should be here")); + m_comboBox.Items().Append(winrt::box_value(L"Option 5 - Not somewhere else!")); + + m_comboBox.PlaceholderText(L"Click to open dropdown..."); + m_comboBox.FontSize(20); + m_comboBox.HorizontalAlignment(winrt::Microsoft::UI::Xaml::HorizontalAlignment::Stretch); + m_comboBox.VerticalAlignment(winrt::Microsoft::UI::Xaml::VerticalAlignment::Center); + + m_xamlIsland.Content(m_comboBox); + islandView.Connect(m_xamlIsland.ContentIsland()); + + // Issue #15557 Bug 2 Fix: Subscribe to DismissPopupsRequest event to close popups when scroll begins. + // This is the pattern that ANY 3rd party XAML component should use: + // 1. Subscribe to the DismissPopupsRequest event + // 2. When the event fires, use VisualTreeHelper to find and close your open popups + // This works for ComboBox, DatePicker, TimePicker, Flyouts, etc. - any XAML popup! + m_dismissPopupsRequestToken = islandView.DismissPopupsRequest( + [this](winrt::Windows::Foundation::IInspectable const &, winrt::Windows::Foundation::IInspectable const &) { + DismissPopups(); + }); + + m_selectionChangedToken = + m_comboBox.SelectionChanged([this]( + winrt::Windows::Foundation::IInspectable const &, + winrt::Microsoft::UI::Xaml::Controls::SelectionChangedEventArgs const &) { + if (auto emitter = EventEmitter()) { + Codegen::ComboBox_OnSelectionChanged args; + args.selectedIndex = m_comboBox.SelectedIndex(); + if (m_comboBox.SelectedItem()) { + auto selectedText = winrt::unbox_value(m_comboBox.SelectedItem()); + args.selectedValue = winrt::to_string(selectedText); + } else { + args.selectedValue = ""; + } + emitter->onSelectionChanged(args); + } + }); + } + + // Dismiss any open popups for this component's XamlRoot + void DismissPopups() noexcept { + if (auto xamlRoot = m_comboBox.XamlRoot()) { + auto openPopups = winrt::Microsoft::UI::Xaml::Media::VisualTreeHelper::GetOpenPopupsForXamlRoot(xamlRoot); + for (const auto &popup : openPopups) { + if (popup.IsOpen()) { + popup.IsOpen(false); + } + } + } + } + + private: + winrt::Microsoft::UI::Xaml::XamlIsland m_xamlIsland{nullptr}; + winrt::Microsoft::UI::Xaml::Controls::ComboBox m_comboBox{nullptr}; + winrt::event_token m_selectionChangedToken{}; + winrt::event_token m_dismissPopupsRequestToken{}; +}; + +} // namespace winrt::SampleCustomComponent + +void RegisterComboBoxComponentView(winrt::Microsoft::ReactNative::IReactPackageBuilder const &packageBuilder) { + winrt::SampleCustomComponent::Codegen::RegisterComboBoxNativeComponent< + winrt::SampleCustomComponent::ComboBoxComponentView>( + packageBuilder, + [](const winrt::Microsoft::ReactNative::Composition::IReactCompositionViewComponentBuilder &builder) { + builder.SetContentIslandComponentViewInitializer( + [](const winrt::Microsoft::ReactNative::Composition::ContentIslandComponentView &islandView) noexcept { + auto userData = winrt::make_self(); + userData->InitializeContentIsland(islandView); + islandView.UserData(*userData); + }); + }); +} + +#endif // defined(RNW_NEW_ARCH) diff --git a/packages/sample-custom-component/windows/SampleCustomComponent/ComboBox.h b/packages/sample-custom-component/windows/SampleCustomComponent/ComboBox.h new file mode 100644 index 00000000000..9cbbe36b992 --- /dev/null +++ b/packages/sample-custom-component/windows/SampleCustomComponent/ComboBox.h @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once + +#include + +void RegisterComboBoxComponentView(winrt::Microsoft::ReactNative::IReactPackageBuilder const &packageBuilder); diff --git a/packages/sample-custom-component/windows/SampleCustomComponent/ReactPackageProvider.cpp b/packages/sample-custom-component/windows/SampleCustomComponent/ReactPackageProvider.cpp index 29f8c8905e1..8f19b9be9cf 100644 --- a/packages/sample-custom-component/windows/SampleCustomComponent/ReactPackageProvider.cpp +++ b/packages/sample-custom-component/windows/SampleCustomComponent/ReactPackageProvider.cpp @@ -8,6 +8,7 @@ #endif #include "CalendarView.h" +#include "ComboBox.h" #include "CustomAccessibility.h" #include "DrawingIsland.h" #include "MovingLight.h" @@ -24,6 +25,7 @@ void ReactPackageProvider::CreatePackage(IReactPackageBuilder const &packageBuil RegisterMovingLightNativeComponent(packageBuilder); RegisterCalendarViewComponentView(packageBuilder); RegisterCustomAccessibilityComponentView(packageBuilder); + RegisterComboBoxComponentView(packageBuilder); #endif // #ifdef RNW_NEW_ARCH } diff --git a/packages/sample-custom-component/windows/SampleCustomComponent/SampleCustomComponent.vcxproj b/packages/sample-custom-component/windows/SampleCustomComponent/SampleCustomComponent.vcxproj index 2a9dae1e5fe..8462d529796 100644 --- a/packages/sample-custom-component/windows/SampleCustomComponent/SampleCustomComponent.vcxproj +++ b/packages/sample-custom-component/windows/SampleCustomComponent/SampleCustomComponent.vcxproj @@ -101,6 +101,7 @@ + DrawingIsland.idl @@ -126,6 +127,7 @@ ReactPackageProvider.idl + diff --git a/packages/sample-custom-component/windows/SampleCustomComponent/SampleCustomComponent.vcxproj.filters b/packages/sample-custom-component/windows/SampleCustomComponent/SampleCustomComponent.vcxproj.filters index 362d95add21..6b6b592ca9f 100644 --- a/packages/sample-custom-component/windows/SampleCustomComponent/SampleCustomComponent.vcxproj.filters +++ b/packages/sample-custom-component/windows/SampleCustomComponent/SampleCustomComponent.vcxproj.filters @@ -1,4 +1,4 @@ - + @@ -27,6 +27,9 @@ Header Files + + Header Files + @@ -38,6 +41,9 @@ Source Files + + Source Files + diff --git a/vnext/Microsoft.ReactNative/CompositionComponentView.idl b/vnext/Microsoft.ReactNative/CompositionComponentView.idl index d155d3290b6..2192d4aa72d 100644 --- a/vnext/Microsoft.ReactNative/CompositionComponentView.idl +++ b/vnext/Microsoft.ReactNative/CompositionComponentView.idl @@ -11,161 +11,127 @@ import "ReactNativeIsland.idl"; #include "DocString.h" -namespace Microsoft.ReactNative.Composition -{ - - [flags] - [webhosthidden] - [experimental] - enum ComponentViewFeatures - { - None = 0x00000000, - NativeBorder = 0x00000001, - ShadowProps = 0x00000002, - Background = 0x00000004, - FocusVisual = 0x00000008, - - Default = 0x0000000F, // ShadowProps | NativeBorder | Background | FocusVisual +namespace Microsoft.ReactNative.Composition { + +[flags][webhosthidden][experimental] enum ComponentViewFeatures { + None = 0x00000000, + NativeBorder = 0x00000001, + ShadowProps = 0x00000002, + Background = 0x00000004, + FocusVisual = 0x00000008, + + Default = 0x0000000F, // ShadowProps | NativeBorder | Background | FocusVisual +}; + +namespace Experimental { +[webhosthidden][experimental] interface IInternalComponentView { + ICompositionContext CompositionContext { + get; }; - - namespace Experimental { - [webhosthidden] - [experimental] - interface IInternalComponentView - { - ICompositionContext CompositionContext { get; }; - } - } - - // [exclusiveto(ComponentView)] - // [uuid(ABFAC092-E527-47DC-9CF9-7A4003B0AFB0)] - // interface IComponentViewFactory - // { - // } - - // [composable(IComponentViewFactory, protected)] - [experimental] - [webhosthidden] - unsealed runtimeclass ComponentView : Microsoft.ReactNative.ComponentView { - Microsoft.UI.Composition.Compositor Compositor { get; }; - RootComponentView Root { get; }; - Theme Theme; - - event Windows.Foundation.EventHandler ThemeChanged; - Boolean CapturePointer(Microsoft.ReactNative.Composition.Input.Pointer pointer); - void ReleasePointerCapture(Microsoft.ReactNative.Composition.Input.Pointer pointer); +} +} // namespace Experimental + +// [exclusiveto(ComponentView)] +// [uuid(ABFAC092-E527-47DC-9CF9-7A4003B0AFB0)] +// interface IComponentViewFactory +// { +// } + +// [composable(IComponentViewFactory, protected)] +[experimental][webhosthidden] unsealed runtimeclass ComponentView : Microsoft.ReactNative.ComponentView { + Microsoft.UI.Composition.Compositor Compositor { + get; + }; + RootComponentView Root { + get; }; + Theme Theme; - namespace Experimental { + event Windows.Foundation.EventHandler ThemeChanged; + Boolean CapturePointer(Microsoft.ReactNative.Composition.Input.Pointer pointer); + void ReleasePointerCapture(Microsoft.ReactNative.Composition.Input.Pointer pointer); +}; - [webhosthidden] - [experimental] - delegate Microsoft.ReactNative.Composition.Experimental.IVisual CreateInternalVisualDelegate(Microsoft.ReactNative.ComponentView view); +namespace Experimental { - [webhosthidden] - [experimental] - DOC_STRING("Custom ViewComponents need to implement this interface to be able to provide a custom" +[webhosthidden][experimental] delegate Microsoft.ReactNative.Composition.Experimental.IVisual +CreateInternalVisualDelegate(Microsoft.ReactNative.ComponentView view); + +[webhosthidden][experimental] DOC_STRING( + "Custom ViewComponents need to implement this interface to be able to provide a custom" " visual using the composition context that allows custom compositors. This is only required for" " custom components that need to support running in RNW instances with custom compositors. Most" - " custom components can just set CreateVisualHandler on ViewComponentView." - " This will be removed in a future version") - interface IInternalCreateVisual - { - CreateInternalVisualDelegate CreateInternalVisualHandler; - } - } - - // [exclusiveto(ViewComponentView)] - // [uuid(756AA1DF-ED74-467E-9BAA-3797B39B1875)] - // interface IViewComponentViewFactory - // { - // } - - // [composable(IViewComponentViewFactory, protected)] - [experimental] - [webhosthidden] - unsealed runtimeclass ViewComponentView : ComponentView { - - Microsoft.ReactNative.ViewProps ViewProps { get; }; + " custom components can just set CreateVisualHandler on ViewComponentView." + " This will be removed in a future version") interface IInternalCreateVisual { + CreateInternalVisualDelegate CreateInternalVisualHandler; +} +} // namespace Experimental + +// [exclusiveto(ViewComponentView)] +// [uuid(756AA1DF-ED74-467E-9BAA-3797B39B1875)] +// interface IViewComponentViewFactory +// { +// } + +// [composable(IViewComponentViewFactory, protected)] +[experimental][webhosthidden] unsealed runtimeclass ViewComponentView : ComponentView { + Microsoft.ReactNative.ViewProps ViewProps { + get; }; +}; - [experimental] - [webhosthidden] - runtimeclass ContentIslandComponentView : ViewComponentView { - void Connect(Microsoft.UI.Content.ContentIsland contentIsland); - }; +[experimental][webhosthidden] runtimeclass ContentIslandComponentView : ViewComponentView { + void Connect(Microsoft.UI.Content.ContentIsland contentIsland); - [experimental] - [webhosthidden] - [default_interface] - runtimeclass SwitchComponentView : ViewComponentView { - }; + // Issue #15557: Event fired when a parent ScrollView starts scrolling. + // 3rd party XAML components should subscribe to this event to dismiss any open popups. + event Windows.Foundation.EventHandler DismissPopupsRequest; +}; - [experimental] - [webhosthidden] - [default_interface] - runtimeclass RootComponentView : ViewComponentView { - Microsoft.ReactNative.ComponentView GetFocusedComponent(); - Microsoft.ReactNative.ReactNativeIsland ReactNativeIsland { get; }; - DOC_STRING("Is non-null if this RootComponentView is the content of a portal") - PortalComponentView Portal { get; }; - }; +[experimental][webhosthidden][default_interface] runtimeclass SwitchComponentView : ViewComponentView{}; - [experimental] - [webhosthidden] - [default_interface] - DOC_STRING("Used to implement UI that is hosted outside the main UI tree, such as modal.") - runtimeclass PortalComponentView : Microsoft.ReactNative.ComponentView { - RootComponentView ContentRoot { get; }; +[experimental][webhosthidden][default_interface] runtimeclass RootComponentView : ViewComponentView { + Microsoft.ReactNative.ComponentView GetFocusedComponent(); + Microsoft.ReactNative.ReactNativeIsland ReactNativeIsland { + get; }; - - [experimental] - [webhosthidden] - [default_interface] - runtimeclass DebuggingOverlayComponentView : ViewComponentView { + DOC_STRING("Is non-null if this RootComponentView is the content of a portal") + PortalComponentView Portal { + get; }; +}; - [experimental] - [webhosthidden] - [default_interface] - runtimeclass ActivityIndicatorComponentView : ViewComponentView { +[experimental][webhosthidden][default_interface] DOC_STRING( + "Used to implement UI that is hosted outside the main UI tree, such as modal.") runtimeclass PortalComponentView + : Microsoft.ReactNative.ComponentView { + RootComponentView ContentRoot { + get; }; +}; - [experimental] - [webhosthidden] - [default_interface] - runtimeclass WindowsModalHostComponentView : ViewComponentView { - }; +[experimental][webhosthidden][default_interface] runtimeclass DebuggingOverlayComponentView : ViewComponentView{}; - [experimental] - [webhosthidden] - [default_interface] - runtimeclass ImageComponentView : ViewComponentView { - Microsoft.ReactNative.ImageProps ViewProps { get; }; - }; +[experimental][webhosthidden][default_interface] runtimeclass ActivityIndicatorComponentView : ViewComponentView{}; - [experimental] - [webhosthidden] - [default_interface] - runtimeclass ParagraphComponentView : ViewComponentView { - }; +[experimental][webhosthidden][default_interface] runtimeclass WindowsModalHostComponentView : ViewComponentView{}; - [experimental] - [webhosthidden] - [default_interface] - runtimeclass ScrollViewComponentView : ViewComponentView { +[experimental][webhosthidden][default_interface] runtimeclass ImageComponentView : ViewComponentView { + Microsoft.ReactNative.ImageProps ViewProps { + get; }; +}; - [experimental] - [webhosthidden] - [default_interface] - runtimeclass UnimplementedNativeViewComponentView : ViewComponentView { - }; +[experimental][webhosthidden][default_interface] runtimeclass ParagraphComponentView : ViewComponentView{}; - [experimental] - [webhosthidden] - [default_interface] - runtimeclass WindowsTextInputComponentView : ViewComponentView { - }; - -} // namespace Microsoft.ReactNative +[experimental][webhosthidden][default_interface] runtimeclass ScrollViewComponentView : ViewComponentView { + // Issue #15557: Event fired when scroll drag begins. + // ContentIslandComponentView uses this to know when to dismiss popups (light dismiss). + event Windows.Foundation.EventHandler ScrollBeginDrag; +}; + +[experimental][webhosthidden][default_interface] runtimeclass UnimplementedNativeViewComponentView + : ViewComponentView{}; + +[experimental][webhosthidden][default_interface] runtimeclass WindowsTextInputComponentView : ViewComponentView{}; + +} // namespace Microsoft.ReactNative. Composition diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ContentIslandComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/ContentIslandComponentView.cpp index 84d73ef2899..420f3a4102f 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ContentIslandComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ContentIslandComponentView.cpp @@ -14,6 +14,7 @@ #include #include "CompositionContextHelper.h" #include "RootComponentView.h" +#include "ScrollViewComponentView.h" #include "Composition.ContentIslandComponentView.g.cpp" @@ -49,6 +50,14 @@ void ContentIslandComponentView::OnMounted() noexcept { .as()); m_childSiteLink.ActualSize({m_layoutMetrics.frame.size.width, m_layoutMetrics.frame.size.height}); + // Issue #15557: Set initial LocalToParentTransformMatrix synchronously before Connect. + // This fixes popup position being wrong even without scrolling. + // Note: getClientRect() returns physical pixels, but LocalToParentTransformMatrix expects DIPs. + auto clientRect = getClientRect(); + float scaleFactor = m_layoutMetrics.pointScaleFactor; + m_childSiteLink.LocalToParentTransformMatrix(winrt::Windows::Foundation::Numerics::make_float4x4_translation( + static_cast(clientRect.left) / scaleFactor, static_cast(clientRect.top) / scaleFactor, 0.0f)); + m_navigationHost = winrt::Microsoft::UI::Input::InputFocusNavigationHost::GetForSiteLink(m_childSiteLink); m_navigationHostDepartFocusRequestedToken = @@ -80,12 +89,34 @@ void ContentIslandComponentView::OnMounted() noexcept { strongThis->ParentLayoutChanged(); } })); + + // Issue #15557: Register for ScrollBeginDrag on parent ScrollViews for light dismiss. + // This is more efficient than walking the tree on every scroll begin. + if (auto scrollView = view.try_as()) { + auto token = + scrollView.ScrollBeginDrag([wkThis = get_weak()](const winrt::IInspectable &, const winrt::IInspectable &) { + if (auto strongThis = wkThis.get()) { + strongThis->FireDismissPopupsRequest(); + } + }); + m_scrollBeginDragSubscriptions.push_back({scrollView, token}); + } + view = view.Parent(); } } void ContentIslandComponentView::OnUnmounted() noexcept { m_layoutMetricChangedRevokers.clear(); + + // Issue #15557: Unsubscribe from parent ScrollView events + for (auto &subscription : m_scrollBeginDragSubscriptions) { + if (auto scrollView = subscription.scrollView.get()) { + scrollView.ScrollBeginDrag(subscription.token); + } + } + m_scrollBeginDragSubscriptions.clear(); + if (m_navigationHostDepartFocusRequestedToken && m_navigationHost) { m_navigationHost.DepartFocusRequested(m_navigationHostDepartFocusRequestedToken); m_navigationHostDepartFocusRequestedToken = {}; @@ -93,21 +124,25 @@ void ContentIslandComponentView::OnUnmounted() noexcept { } void ContentIslandComponentView::ParentLayoutChanged() noexcept { - if (m_layoutChangePosted) - return; - - m_layoutChangePosted = true; - ReactContext().UIDispatcher().Post([wkThis = get_weak()]() { - if (auto strongThis = wkThis.get()) { - auto clientRect = strongThis->getClientRect(); - - strongThis->m_childSiteLink.LocalToParentTransformMatrix( - winrt::Windows::Foundation::Numerics::make_float4x4_translation( - static_cast(clientRect.left), static_cast(clientRect.top), 0.0f)); - - strongThis->m_layoutChangePosted = false; - } - }); + // Issue #15557: Update transform synchronously to ensure correct popup position + // when user clicks. Async updates via UIDispatcher().Post() were causing the + // popup to open with stale transform values. + // + // Note: The original async approach was for batching notifications during layout passes. + // However, LocalToParentTransformMatrix is a cheap call (just sets a matrix), and + // synchronous updates are required to ensure correct popup position when clicked. + // + // getClientRect() returns values in physical pixels (scaled by pointScaleFactor), + // but LocalToParentTransformMatrix expects logical pixels (DIPs). We need to divide + // by the scale factor to convert. + auto clientRect = getClientRect(); + float scaleFactor = m_layoutMetrics.pointScaleFactor; + + float x = static_cast(clientRect.left) / scaleFactor; + float y = static_cast(clientRect.top) / scaleFactor; + + m_childSiteLink.LocalToParentTransformMatrix( + winrt::Windows::Foundation::Numerics::make_float4x4_translation(x, y, 0.0f)); } winrt::Windows::Foundation::IInspectable ContentIslandComponentView::CreateAutomationProvider() noexcept { @@ -166,6 +201,22 @@ void ContentIslandComponentView::onGotFocus( m_navigationHost.NavigateFocus(winrt::Microsoft::UI::Input::FocusNavigationRequest::Create(navigationReason)); } +// Issue #15557: Fire event to notify 3P component to dismiss popups when scroll begins. +// The 3P component is responsible for closing its own popups. +void ContentIslandComponentView::FireDismissPopupsRequest() noexcept { + m_dismissPopupsRequestEvent(*this, nullptr); +} + +// Issue #15557: Event accessors for DismissPopupsRequest +winrt::event_token ContentIslandComponentView::DismissPopupsRequest( + winrt::Windows::Foundation::EventHandler const &handler) noexcept { + return m_dismissPopupsRequestEvent.add(handler); +} + +void ContentIslandComponentView::DismissPopupsRequest(winrt::event_token const &token) noexcept { + m_dismissPopupsRequestEvent.remove(token); +} + ContentIslandComponentView::~ContentIslandComponentView() noexcept { if (m_navigationHostDepartFocusRequestedToken && m_navigationHost) { m_navigationHost.DepartFocusRequested(m_navigationHostDepartFocusRequestedToken); diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ContentIslandComponentView.h b/vnext/Microsoft.ReactNative/Fabric/Composition/ContentIslandComponentView.h index 7c85ee1cc01..fd96d641b46 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ContentIslandComponentView.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ContentIslandComponentView.h @@ -47,6 +47,14 @@ struct ContentIslandComponentView : ContentIslandComponentViewT const &handler) noexcept; + void DismissPopupsRequest(winrt::event_token const &token) noexcept; + ContentIslandComponentView( const winrt::Microsoft::ReactNative::Composition::Experimental::ICompositionContext &compContext, facebook::react::Tag tag, @@ -68,8 +76,19 @@ struct ContentIslandComponentView : ContentIslandComponentViewT scrollView; + winrt::event_token token; + }; + std::vector m_scrollBeginDragSubscriptions; + // Automation void ConfigureChildSiteLinkAutomation() noexcept; + + // Issue #15557: Event for notifying 3P components to dismiss popups when scroll begins + winrt::event> + m_dismissPopupsRequestEvent; }; } // namespace winrt::Microsoft::ReactNative::Composition::implementation diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp index ca5b83decdc..c60afe5b26f 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp @@ -6,6 +6,7 @@ #include "ScrollViewComponentView.h" +#include #include #pragma warning(push) @@ -19,6 +20,8 @@ #include #include #include +#include +#include "ContentIslandComponentView.h" #include "JSValueReader.h" #include "RootComponentView.h" @@ -1325,6 +1328,11 @@ winrt::Microsoft::ReactNative::Composition::Experimental::IVisual ScrollViewComp m_allowNextScrollNoMatterWhat = false; } } + + // Issue #15557: Fire LayoutMetricsChanged to notify ContentIslandComponentView instances + // that scroll position has changed, so they can update their LocalToParentTransformMatrix + // for correct XAML popup positioning + FireLayoutMetricsChangedForScrollPositionChange(); }); m_scrollBeginDragRevoker = m_scrollVisual.ScrollBeginDrag( @@ -1332,6 +1340,9 @@ winrt::Microsoft::ReactNative::Composition::Experimental::IVisual ScrollViewComp [this]( winrt::IInspectable const & /*sender*/, winrt::Microsoft::ReactNative::Composition::Experimental::IScrollPositionChangedArgs const &args) { + // Issue #15557: Dismiss any open XAML popups when scroll begins (light dismiss behavior) + DismissChildContentIslandPopups(); + m_allowNextScrollNoMatterWhat = true; // Ensure next scroll event is recorded, regardless of throttle updateStateWithContentOffset(); auto eventEmitter = GetEventEmitter(); @@ -1478,4 +1489,41 @@ void ScrollViewComponentView::updateShowsVerticalScrollIndicator(bool value) noe void ScrollViewComponentView::updateDecelerationRate(float value) noexcept { m_scrollVisual.SetDecelerationRate({value, value, value}); } + +// 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 { + // Create LayoutMetricsChangedArgs with same old/new metrics + // The actual scroll offset is handled in getClientOffset() which ContentIslandComponentView + // uses when calculating the transform matrix via getClientRect() + winrt::Microsoft::ReactNative::LayoutMetrics metrics{ + {m_layoutMetrics.frame.origin.x, + m_layoutMetrics.frame.origin.y, + m_layoutMetrics.frame.size.width, + m_layoutMetrics.frame.size.height}, + m_layoutMetrics.pointScaleFactor}; + + m_layoutMetricsChangedEvent( + *this, winrt::make(metrics, metrics)); +} + +// Issue #15557: Event accessors for ScrollBeginDrag +winrt::event_token ScrollViewComponentView::ScrollBeginDrag( + winrt::Windows::Foundation::EventHandler const &handler) noexcept { + return m_scrollBeginDragEvent.add(handler); +} + +void ScrollViewComponentView::ScrollBeginDrag(winrt::event_token const &token) noexcept { + m_scrollBeginDragEvent.remove(token); +} + +// Issue #15557: Fire ScrollBeginDrag event to notify registered ContentIslandComponentView instances. +// ContentIslandComponentViews register during their OnMounted by walking up the tree and subscribing +// to this event on any parent ScrollViewComponentViews. This is more efficient than walking the tree +// on every scroll begin. +void ScrollViewComponentView::DismissChildContentIslandPopups() noexcept { + // Fire the event to all registered listeners (ContentIslandComponentViews that are descendants of this ScrollView) + m_scrollBeginDragEvent(*this, nullptr); +} } // namespace winrt::Microsoft::ReactNative::Composition::implementation diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h index 495f0a1e2c4..af282edb57a 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h @@ -118,6 +118,11 @@ struct ScrollInteractionTrackerOwner : public winrt::implements< double getVerticalSize() noexcept; double getHorizontalSize() noexcept; + // Issue #15557: Event accessors for ScrollBeginDrag (used by ContentIslandComponentView for light dismiss) + winrt::event_token ScrollBeginDrag( + winrt::Windows::Foundation::EventHandler const &handler) noexcept; + void ScrollBeginDrag(winrt::event_token const &token) noexcept; + private: void updateDecelerationRate(float value) noexcept; void updateContentVisualSize() noexcept; @@ -129,6 +134,10 @@ struct ScrollInteractionTrackerOwner : public winrt::implements< bool scrollRight(float delta, bool animate) noexcept; void updateBackgroundColor(const facebook::react::SharedColor &color) noexcept; void updateStateWithContentOffset() noexcept; + // Issue #15557: Notify ContentIslandComponentView instances that scroll position has changed + void FireLayoutMetricsChangedForScrollPositionChange() noexcept; + // Issue #15557: Fire DismissPopupsRequest event on child ContentIslandComponentView instances when scroll begins + void DismissChildContentIslandPopups() noexcept; facebook::react::ScrollViewEventEmitter::Metrics getScrollMetrics( facebook::react::SharedViewEventEmitter const &eventEmitter, winrt::Microsoft::ReactNative::Composition::Experimental::IScrollPositionChangedArgs const &args) noexcept; @@ -160,6 +169,10 @@ struct ScrollInteractionTrackerOwner : public winrt::implements< bool m_allowNextScrollNoMatterWhat{false}; std::chrono::steady_clock::time_point m_lastScrollEventTime{}; std::shared_ptr m_state; + + // Issue #15557: Event for notifying ContentIslandComponentView instances when scroll begins + winrt::event> + m_scrollBeginDragEvent; }; } // namespace winrt::Microsoft::ReactNative::Composition::implementation