diff --git a/change/react-native-windows-4cf47ab7-542b-4ccb-bc2e-46c79be1bb02.json b/change/react-native-windows-4cf47ab7-542b-4ccb-bc2e-46c79be1bb02.json new file mode 100644 index 00000000000..35229b9592b --- /dev/null +++ b/change/react-native-windows-4cf47ab7-542b-4ccb-bc2e-46c79be1bb02.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "[Fabric] implement view tooltip property", + "packageName": "react-native-windows", + "email": "30809111+acoates-ms@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/e2e-test-app-fabric/test/__snapshots__/ViewComponentTest.test.ts.snap b/packages/e2e-test-app-fabric/test/__snapshots__/ViewComponentTest.test.ts.snap index 90015e53205..4bb568dd7e4 100644 --- a/packages/e2e-test-app-fabric/test/__snapshots__/ViewComponentTest.test.ts.snap +++ b/packages/e2e-test-app-fabric/test/__snapshots__/ViewComponentTest.test.ts.snap @@ -6085,12 +6085,24 @@ exports[`View Tests Views can have tooltips 1`] = ` "_Props": {}, }, { - "Type": "Microsoft.ReactNative.Composition.ParagraphComponentView", + "Type": "Microsoft.ReactNative.Composition.ViewComponentView", "_Props": {}, + "__Children": [ + { + "Type": "Microsoft.ReactNative.Composition.ParagraphComponentView", + "_Props": {}, + }, + ], }, { - "Type": "Microsoft.ReactNative.Composition.ParagraphComponentView", + "Type": "Microsoft.ReactNative.Composition.ViewComponentView", "_Props": {}, + "__Children": [ + { + "Type": "Microsoft.ReactNative.Composition.ParagraphComponentView", + "_Props": {}, + }, + ], }, ], }, @@ -6126,6 +6138,25 @@ exports[`View Tests Views can have tooltips 1`] = ` "Offset": "0, 0, 0", "Size": "916, 15", "Visual Type": "SpriteVisual", + "__Children": [ + { + "Offset": "0, 0, 0", + "Size": "916, 15", + "Visual Type": "SpriteVisual", + "__Children": [ + { + "Offset": "0, 0, 0", + "Size": "916, 15", + "Visual Type": "SpriteVisual", + }, + { + "Offset": "0, 0, 0", + "Size": "0, 0", + "Visual Type": "SpriteVisual", + }, + ], + }, + ], }, { "Offset": "0, 0, 0", @@ -6136,13 +6167,32 @@ exports[`View Tests Views can have tooltips 1`] = ` }, { "Offset": "0, 29, 0", - "Size": "916, 16", + "Size": "916, 14", "Visual Type": "SpriteVisual", "__Children": [ { "Offset": "0, 0, 0", - "Size": "916, 16", + "Size": "916, 14", "Visual Type": "SpriteVisual", + "__Children": [ + { + "Offset": "0, 0, 0", + "Size": "916, 16", + "Visual Type": "SpriteVisual", + "__Children": [ + { + "Offset": "0, 0, 0", + "Size": "916, 16", + "Visual Type": "SpriteVisual", + }, + { + "Offset": "0, 0, 0", + "Size": "0, 0", + "Visual Type": "SpriteVisual", + }, + ], + }, + ], }, { "Offset": "0, 0, 0", diff --git a/vnext/Desktop.DLL/React.Windows.Desktop.DLL.vcxproj b/vnext/Desktop.DLL/React.Windows.Desktop.DLL.vcxproj index 2a73b805d36..2031723bd0f 100644 --- a/vnext/Desktop.DLL/React.Windows.Desktop.DLL.vcxproj +++ b/vnext/Desktop.DLL/React.Windows.Desktop.DLL.vcxproj @@ -103,6 +103,7 @@ comsuppw.lib; Shlwapi.lib; Version.lib; + Dwmapi.lib; WindowsApp_downlevel.lib; %(AdditionalDependencies) diff --git a/vnext/Microsoft.ReactNative/Fabric/ComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/ComponentView.cpp index 0f5a12f6435..83db26575dd 100644 --- a/vnext/Microsoft.ReactNative/Fabric/ComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/ComponentView.cpp @@ -14,6 +14,7 @@ #include #include "AbiEventEmitter.h" #include "AbiShadowNode.h" +#include "ReactCoreInjection.h" namespace winrt::Microsoft::ReactNative::Composition::implementation { struct RootComponentView; @@ -262,6 +263,17 @@ void ComponentView::HandleCommand(const winrt::Microsoft::ReactNative::HandleCom } } +HWND ComponentView::GetHwndForParenting() noexcept { + if (m_parent) { + return winrt::get_self(m_parent) + ->GetHwndForParenting(); + } + + // Fallback if we do not know any more specific HWND + return reinterpret_cast(winrt::Microsoft::ReactNative::implementation::ReactCoreInjection::GetTopLevelWindowId( + m_reactContext.Properties().Handle())); +} + winrt::Microsoft::ReactNative::Composition::implementation::RootComponentView *ComponentView::rootComponentView() const noexcept { if (m_rootView) diff --git a/vnext/Microsoft.ReactNative/Fabric/ComponentView.h b/vnext/Microsoft.ReactNative/Fabric/ComponentView.h index 540a2714b85..74bcb459c69 100644 --- a/vnext/Microsoft.ReactNative/Fabric/ComponentView.h +++ b/vnext/Microsoft.ReactNative/Fabric/ComponentView.h @@ -209,6 +209,9 @@ struct ComponentView : public ComponentViewT { // Notify up the tree to bring the rect into view by scrolling as needed virtual void StartBringIntoView(BringIntoViewOptions &&args) noexcept; + // Eventually PopupContentLink and similar APIs will remove the need for this. + virtual HWND GetHwndForParenting() noexcept; + virtual const winrt::Microsoft::ReactNative::IComponentProps userProps( facebook::react::Props::Shared const &props) noexcept; diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp index baf6a03ee7c..eb1f8d7d246 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.cpp @@ -23,6 +23,7 @@ #include "CompositionHelpers.h" #include "RootComponentView.h" #include "Theme.h" +#include "TooltipService.h" #include "UiaHelpers.h" #include "d2d1helper.h" @@ -43,6 +44,13 @@ ComponentView::ComponentView( m_outerVisual.InsertAt(m_focusVisual.InnerVisual(), 0); } +ComponentView::~ComponentView() { + if (m_tooltipTracked) { + TooltipService::GetCurrent(m_reactContext.Properties())->StopTracking(*this); + m_tooltipTracked = false; + } +} + facebook::react::Tag ComponentView::Tag() const noexcept { return m_tag; } @@ -130,6 +138,16 @@ void ComponentView::updateProps( updateShadowProps(oldViewProps, newViewProps); } + if (oldViewProps.tooltip != newViewProps.tooltip) { + if (!m_tooltipTracked && newViewProps.tooltip) { + TooltipService::GetCurrent(m_reactContext.Properties())->StartTracking(*this); + m_tooltipTracked = true; + } else if (m_tooltipTracked && !newViewProps.tooltip) { + TooltipService::GetCurrent(m_reactContext.Properties())->StopTracking(*this); + m_tooltipTracked = false; + } + } + base_type::updateProps(props, oldProps); } diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.h b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.h index d2482ec8475..dc3e0dd8c4f 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionViewComponentView.h @@ -30,6 +30,7 @@ struct ComponentView : public ComponentViewT< facebook::react::Tag tag, winrt::Microsoft::ReactNative::ReactContext const &reactContext, ComponentViewFeatures flags); + virtual ~ComponentView(); virtual winrt::Microsoft::ReactNative::Composition::Experimental::IVisual Visual() const noexcept { return nullptr; @@ -151,6 +152,7 @@ struct ComponentView : public ComponentViewT< const facebook::react::ViewProps &viewProps) noexcept; bool m_FinalizeTransform{false}; + bool m_tooltipTracked{false}; ComponentViewFeatures m_flags; void showFocusVisual(bool show) noexcept; winrt::Microsoft::ReactNative::Composition::Experimental::IFocusVisual m_focusVisual{nullptr}; diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.cpp index d01e812cd5d..b9518d7a118 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.cpp @@ -332,7 +332,7 @@ winrt::IInspectable ReactNativeIsland::GetUiaProvider() noexcept { if (m_uiaProvider == nullptr) { m_uiaProvider = winrt::make(*this); - if (m_hwnd) { + if (m_hwnd && !m_island) { auto pRootProvider = static_cast( m_uiaProvider.as().get()); @@ -348,6 +348,10 @@ void ReactNativeIsland::SetWindow(uint64_t hwnd) noexcept { m_hwnd = reinterpret_cast(hwnd); } +HWND ReactNativeIsland::GetHwndForParenting() noexcept { + return m_hwnd; +} + int64_t ReactNativeIsland::SendMessage(uint32_t msg, uint64_t wParam, int64_t lParam) noexcept { if (m_rootTag == -1) return 0; @@ -367,7 +371,7 @@ int64_t ReactNativeIsland::SendMessage(uint32_t msg, uint64_t wParam, int64_t lP bool ReactNativeIsland::CapturePointer( const winrt::Microsoft::ReactNative::Composition::Input::Pointer &pointer, facebook::react::Tag tag) noexcept { - if (m_hwnd) { + if (m_hwnd && !m_island) { SetCapture(m_hwnd); } return m_CompositionEventHandler->CapturePointer(pointer, tag); @@ -377,7 +381,7 @@ void ReactNativeIsland::ReleasePointerCapture( const winrt::Microsoft::ReactNative::Composition::Input::Pointer &pointer, facebook::react::Tag tag) noexcept { if (m_CompositionEventHandler->ReleasePointerCapture(pointer, tag)) { - if (m_hwnd) { + if (m_hwnd && !m_island) { if (m_hwnd == GetCapture()) { ReleaseCapture(); } diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.h b/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.h index 8f292357515..f280ddc6a1d 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ReactNativeIsland.h @@ -82,6 +82,7 @@ struct ReactNativeIsland void AddRenderedVisual(const winrt::Microsoft::ReactNative::Composition::Experimental::IVisual &visual) noexcept; void RemoveRenderedVisual(const winrt::Microsoft::ReactNative::Composition::Experimental::IVisual &visual) noexcept; bool TrySetFocus() noexcept; + HWND GetHwndForParenting() noexcept; winrt::Microsoft::ReactNative::Composition::ICustomResourceLoader Resources() noexcept; void Resources(const winrt::Microsoft::ReactNative::Composition::ICustomResourceLoader &resources) noexcept; diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.cpp index 9d3cd495d62..282cba603b8 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.cpp @@ -231,4 +231,15 @@ winrt::Microsoft::ReactNative::implementation::ClipState RootComponentView::getC return winrt::Microsoft::ReactNative::implementation::ClipState::NoClip; } +HWND RootComponentView::GetHwndForParenting() noexcept { + if (auto rootView = m_wkRootView.get()) { + auto hwnd = winrt::get_self(rootView) + ->GetHwndForParenting(); + if (hwnd) + return hwnd; + } + + return base_type::GetHwndForParenting(); +} + } // namespace winrt::Microsoft::ReactNative::Composition::implementation diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.h b/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.h index 42b3ec70468..ddd61b7e7fa 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/RootComponentView.h @@ -64,6 +64,8 @@ struct RootComponentView : RootComponentViewT, std::equal_to<>> s_lightColors = { @@ -326,7 +328,9 @@ bool Theme::TryGetPlatformColor(const std::string &platformColor, winrt::Windows {"ControlStrongFillColorDefault", {0x72, 0x00, 0x00, 0x00}}, {"ControlStrongFillColorDisabled", {0x51, 0x00, 0x00, 0x00}}, {"AcrylicInAppFillColorDefault", {0x9E, 0xFF, 0xFF, 0xFF}}, - }; + {"SystemChromeMediumLowColor", {0xFF, 0xF2, 0xF2, 0xF2}}, + {"SystemControlForegroundBaseHighColor", {0xFF, 0x00, 0x00, 0x00}}, + {"SystemControlTransientBorderColor", {0x24, 0x00, 0x00, 0x00}}}; static std::unordered_map, std::equal_to<>> s_darkColors = { @@ -356,7 +360,9 @@ bool Theme::TryGetPlatformColor(const std::string &platformColor, winrt::Windows {"ControlStrongFillColorDefault", {0x8B, 0xFF, 0xFF, 0xFF}}, {"ControlStrongFillColorDisabled", {0x3F, 0xFF, 0xFF, 0xFF}}, {"AcrylicInAppFillColorDefault", {0x9E, 0x00, 0x00, 0x00}}, - }; + {"SystemChromeMediumLowColor", {0xFF, 0x2B, 0x2B, 0x2B}}, + {"SystemControlForegroundBaseHighColor", {0xFF, 0xFF, 0xFF, 0xFF}}, + {"SystemControlTransientBorderColor", {0x5C, 0x00, 0x00, 0x00}}}; static std::unordered_map< std::string, @@ -391,7 +397,9 @@ bool Theme::TryGetPlatformColor(const std::string &platformColor, winrt::Windows {"SubtleFillColorSecondary", {winrt::Windows::UI::ViewManagement::UIElementType::ButtonFace, {}}}, {"ControlStrongFillColorDefault", {winrt::Windows::UI::ViewManagement::UIElementType::ButtonFace, {}}}, {"ControlStrongFillColorDisabled", {winrt::Windows::UI::ViewManagement::UIElementType::ButtonFace, {}}}, - }; + {"SystemChromeMediumLowColor", {winrt::Windows::UI::ViewManagement::UIElementType::ButtonFace, {}}}, + {"SystemControlForegroundBaseHighColor", {winrt::Windows::UI::ViewManagement::UIElementType::ButtonText, {}}}, + {"SystemControlTransientBorderColor", {winrt::Windows::UI::ViewManagement::UIElementType::ButtonText, {}}}}; auto alias = s_xamlAliasedColors.find(platformColor); if (alias != s_xamlAliasedColors.end()) { diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/TooltipService.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/TooltipService.cpp new file mode 100644 index 00000000000..dde9878d10b --- /dev/null +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/TooltipService.cpp @@ -0,0 +1,338 @@ + +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#include "TooltipService.h" + +#include +#include +#include +#include +#include +#include +#include "TextDrawing.h" +#include "dwmapi.h" + +namespace winrt::Microsoft::ReactNative { + +constexpr PCWSTR c_tooltipWindowClassName = L"RN_TOOLTIP"; +constexpr auto TooltipDataProperty = L"TooltipData"; +constexpr float tooltipFontSize = 12; +constexpr float tooltipMaxHeight = 1000; +constexpr float tooltipMaxWidth = 320; +constexpr float tooltipHorizontalPadding = 8; +constexpr float tooltipTopPadding = 5; +constexpr float tooltipBottomPadding = 7; +constexpr float toolTipBorderThickness = 1; +constexpr int toolTipPlacementMargin = 12; +constexpr int toolTipAnimationTimeMs = 200; +constexpr int toolTipTimeToShowMs = 1000; + +struct TooltipData { + TooltipData(const winrt::Microsoft::ReactNative::ComponentView &view) : view(view) {} + + static TooltipData *GetFromWindow(HWND hwnd) { + auto data = reinterpret_cast(GetProp(hwnd, TooltipDataProperty)); + return data; + } + + int width; + int height; + ::Microsoft::ReactNative::ReactTaggedView view; + winrt::com_ptr<::IDWriteTextLayout> textLayout; + facebook::react::AttributedStringBox attributedString; + winrt::Microsoft::ReactNative::Composition::Experimental::ICompositionContext compositionContext; +}; + +facebook::react::AttributedStringBox CreateTooltipAttributedString(const std::string &tooltip) noexcept { + auto attributedString = facebook::react::AttributedString{}; + auto fragment = facebook::react::AttributedString::Fragment{}; + fragment.string = tooltip; + fragment.textAttributes.fontSize = tooltipFontSize; + attributedString.appendFragment(fragment); + return facebook::react::AttributedStringBox{attributedString}; +} + +LRESULT CALLBACK TooltipWndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) noexcept { + switch (message) { + case WM_DESTROY: { + delete TooltipData::GetFromWindow(hwnd); + SetProp(hwnd, TooltipDataProperty, 0); + return 0; + } + case WM_PRINTCLIENT: + case WM_PAINT: { + HDC hdc; + PAINTSTRUCT ps; + + if (message != WM_PRINTCLIENT) + hdc = BeginPaint(hwnd, &ps); + else + hdc = (HDC)wparam; + auto data = TooltipData::GetFromWindow(hwnd); + + if (auto view = data->view.view()) { + auto scaleFactor = view.LayoutMetrics().PointScaleFactor; + + auto ccInterop = data->compositionContext + .as<::Microsoft::ReactNative::Composition::Experimental::ICompositionContextInterop>(); + winrt::com_ptr factory; + ccInterop->D2DFactory(factory.put()); + + winrt::com_ptr renderTarget; + + D2D1_RENDER_TARGET_PROPERTIES props = D2D1::RenderTargetProperties( + D2D1_RENDER_TARGET_TYPE_DEFAULT, + D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_IGNORE), + 0, + 0, + D2D1_RENDER_TARGET_USAGE_NONE, + D2D1_FEATURE_LEVEL_DEFAULT); + winrt::check_hresult(factory->CreateDCRenderTarget(&props, renderTarget.put())); + RECT rc; + GetClientRect(hwnd, &rc); + winrt::check_hresult(renderTarget->BindDC(hdc, &rc)); + auto theme = view.as().Theme(); + auto selfTheme = winrt::get_self(theme); + + renderTarget->BeginDraw(); + renderTarget->Clear(selfTheme->D2DPlatformColor("ToolTipBackground")); + + auto textAttributes = facebook::react::TextAttributes{}; + facebook::react::Color fgColor; + fgColor.m_platformColor.push_back("ToolTipForeground"); + textAttributes.foregroundColor = fgColor; + + winrt::Microsoft::ReactNative::Composition::RenderText( + *renderTarget, + *data->textLayout, + data->attributedString.getValue(), + textAttributes, + {std::round(tooltipHorizontalPadding * scaleFactor), std::round(tooltipTopPadding * scaleFactor)}, + scaleFactor, + *selfTheme); + + auto hr = renderTarget->EndDraw(); + } + + if (message != WM_PRINTCLIENT) + EndPaint(hwnd, &ps); + return 0; + } + case WM_NCCREATE: { + auto cs = reinterpret_cast(lparam); + auto data = static_cast(cs->lpCreateParams); + WINRT_ASSERT(data); + SetProp(hwnd, TooltipDataProperty, reinterpret_cast(data)); + break; + } + } + + return DefWindowProc(hwnd, message, wparam, lparam); +} + +void RegisterTooltipWndClass() noexcept { + static bool registered = false; + if (registered) { + return; + } + + HINSTANCE hInstance = + GetModuleHandle(NULL); // returns a handle to the file used to create the calling process (.exe file) + + WNDCLASSEX wcex = {}; // contains window class information + wcex.cbSize = sizeof(wcex); // size of windows class (bytes) + wcex.style = CS_HREDRAW | CS_VREDRAW; // class style (redraw window on size adjustment) + wcex.lpfnWndProc = &TooltipWndProc; // pointer to windows procedure + wcex.cbClsExtra = DLGWINDOWEXTRA; // extra bytes to allocate + wcex.cbWndExtra = sizeof(TooltipData *); // extra bytes to allocate + wcex.hInstance = hInstance; + wcex.hCursor = LoadCursor(nullptr, IDC_ARROW); // handle to class cursor + wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); // background color + wcex.lpszClassName = c_tooltipWindowClassName; // specify resource name + ATOM classId = RegisterClassEx(&wcex); // register new windows class + WINRT_VERIFY(classId); // 0 = fail + winrt::check_win32(!classId); + + registered = true; +} + +TooltipTracker::TooltipTracker( + const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::ReactPropertyBag &properties, + TooltipService *outer) + : m_view(view), m_properties(properties), m_outer(outer) { + view.PointerEntered({this, &TooltipTracker::OnPointerEntered}); + view.PointerExited({this, &TooltipTracker::OnPointerExited}); + view.PointerMoved({this, &TooltipTracker::OnPointerMoved}); + view.Unmounted({this, &TooltipTracker::OnUnmounted}); +} + +TooltipTracker::~TooltipTracker() { + DestroyTimer(); + DestroyTooltip(); +} + +facebook::react::Tag TooltipTracker::Tag() const noexcept { + return m_view.Tag(); +} + +void TooltipTracker::OnPointerEntered( + const winrt::Windows::Foundation::IInspectable &sender, + const winrt::Microsoft::ReactNative::Composition::Input::PointerRoutedEventArgs &args) noexcept { + if (args.Pointer().PointerDeviceType() != + winrt::Microsoft::ReactNative::Composition::Input::PointerDeviceType::Mouse && + args.Pointer().PointerDeviceType() != winrt::Microsoft::ReactNative::Composition::Input::PointerDeviceType::Pen) + return; + + auto pp = args.GetCurrentPoint(-1); + m_pos = pp.Position(); + + m_timer = winrt::Microsoft::ReactNative::Timer::Create(m_properties.Handle()); + m_timer.Interval(std::chrono::milliseconds(toolTipTimeToShowMs)); + m_timer.Tick({this, &TooltipTracker::OnTick}); + m_timer.Start(); +} + +void TooltipTracker::OnPointerMoved( + const winrt::Windows::Foundation::IInspectable &sender, + const winrt::Microsoft::ReactNative::Composition::Input::PointerRoutedEventArgs &args) noexcept { + if (args.Pointer().PointerDeviceType() != + winrt::Microsoft::ReactNative::Composition::Input::PointerDeviceType::Mouse && + args.Pointer().PointerDeviceType() != winrt::Microsoft::ReactNative::Composition::Input::PointerDeviceType::Pen) + return; + + auto pp = args.GetCurrentPoint(-1); + m_pos = pp.Position(); +} + +void TooltipTracker::OnTick( + const winrt::Windows::Foundation::IInspectable &, + const winrt::Windows::Foundation::IInspectable &) noexcept { + ShowTooltip(m_view.view()); +} + +void TooltipTracker::OnPointerExited( + const winrt::Windows::Foundation::IInspectable &sender, + const winrt::Microsoft::ReactNative::Composition::Input::PointerRoutedEventArgs &args) noexcept { + if (args.Pointer().PointerDeviceType() != + winrt::Microsoft::ReactNative::Composition::Input::PointerDeviceType::Mouse && + args.Pointer().PointerDeviceType() != winrt::Microsoft::ReactNative::Composition::Input::PointerDeviceType::Pen) + return; + DestroyTimer(); + DestroyTooltip(); +} + +void TooltipTracker::OnUnmounted( + const winrt::Windows::Foundation::IInspectable &, + const winrt::Microsoft::ReactNative::ComponentView &) noexcept { + DestroyTimer(); + DestroyTooltip(); +} + +void TooltipTracker::ShowTooltip(const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { + auto viewCompView = view.as(); + + auto selfView = + winrt::get_self(viewCompView); + auto parentHwnd = selfView->GetHwndForParenting(); + DestroyTimer(); + + if (!m_hwndTip) { + auto tooltipData = std::make_unique(view); + tooltipData->attributedString = CreateTooltipAttributedString(*selfView->viewProps()->tooltip); + + tooltipData->compositionContext = selfView->CompositionContext(); + tooltipData->view = view; + + auto scaleFactor = view.LayoutMetrics().PointScaleFactor; + facebook::react::LayoutConstraints layoutConstraints; + layoutConstraints.layoutDirection = facebook::react::LayoutDirection::Undefined; + layoutConstraints.maximumSize.height = tooltipMaxHeight * scaleFactor; + layoutConstraints.maximumSize.width = tooltipMaxWidth * scaleFactor; + layoutConstraints.minimumSize.height = 0; + layoutConstraints.minimumSize.width = 0; + + facebook::react::TextLayoutManager::GetTextLayout( + tooltipData->attributedString, {} /*paragraphAttributes*/, layoutConstraints, tooltipData->textLayout); + + DWRITE_TEXT_METRICS tm; + winrt::check_hresult(tooltipData->textLayout->GetMetrics(&tm)); + + tooltipData->width = + static_cast(tm.width + ((tooltipHorizontalPadding + tooltipHorizontalPadding) * scaleFactor)); + tooltipData->height = static_cast(tm.height + ((tooltipTopPadding + tooltipBottomPadding) * scaleFactor)); + + POINT pt = {static_cast(m_pos.X), static_cast(m_pos.Y)}; + ClientToScreen(parentHwnd, &pt); + + RegisterTooltipWndClass(); + HINSTANCE hInstance = GetModuleHandle(NULL); + m_hwndTip = CreateWindow( + c_tooltipWindowClassName, + L"Tooltip", + WS_POPUP, + pt.x - tooltipData->width / 2, + static_cast(pt.y - tooltipData->height - (toolTipPlacementMargin * scaleFactor)), + tooltipData->width, + tooltipData->height, + parentHwnd, + NULL, + hInstance, + tooltipData.get()); + + DWM_WINDOW_CORNER_PREFERENCE preference = DWMWCP_ROUNDSMALL; + UINT borderThickness = 0; + DwmSetWindowAttribute(m_hwndTip, DWMWA_WINDOW_CORNER_PREFERENCE, &preference, sizeof(preference)); + + tooltipData.release(); + AnimateWindow(m_hwndTip, toolTipAnimationTimeMs, AW_BLEND); + } +} + +void TooltipTracker::DestroyTooltip() noexcept { + if (m_hwndTip) { + AnimateWindow(m_hwndTip, toolTipAnimationTimeMs, AW_BLEND | AW_HIDE); + DestroyWindow(m_hwndTip); + m_hwndTip = nullptr; + } +} + +void TooltipTracker::DestroyTimer() noexcept { + if (m_timer) { + m_timer.Stop(); + m_timer = nullptr; + } +} + +TooltipService::TooltipService(const winrt::Microsoft::ReactNative::ReactPropertyBag &properties) + : m_properties(properties) {} + +void TooltipService::StartTracking(const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { + m_trackers.push_back(std::make_shared(view, m_properties, this)); +} + +void TooltipService::StopTracking(const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { + for (auto it = m_trackers.begin(); it != m_trackers.end();) { + if ((*it)->Tag() == view.Tag()) + it = m_trackers.erase(it); + else + ++it; + } +} + +static const ReactPropertyId>> + &TooltipServicePropertyId() noexcept { + static const ReactPropertyId>> prop{ + L"ReactNative", L"TooltipService"}; + return prop; +} + +std::shared_ptr TooltipService::GetCurrent( + const winrt::Microsoft::ReactNative::ReactPropertyBag &properties) noexcept { + return *properties.GetOrCreate(TooltipServicePropertyId(), [properties]() -> std::shared_ptr { + return std::make_shared(properties); + }); +} + +} // namespace winrt::Microsoft::ReactNative diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/TooltipService.h b/vnext/Microsoft.ReactNative/Fabric/Composition/TooltipService.h new file mode 100644 index 00000000000..b3090061a54 --- /dev/null +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/TooltipService.h @@ -0,0 +1,66 @@ + +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#pragma once + +#include +#include + +namespace winrt::Microsoft::ReactNative { + +struct TooltipService; + +struct TooltipTracker { + TooltipTracker( + const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::ReactPropertyBag &properties, + TooltipService *outer); + ~TooltipTracker(); + + void OnPointerEntered( + const winrt::Windows::Foundation::IInspectable &sender, + const winrt::Microsoft::ReactNative::Composition::Input::PointerRoutedEventArgs &args) noexcept; + void OnPointerMoved( + const winrt::Windows::Foundation::IInspectable &sender, + const winrt::Microsoft::ReactNative::Composition::Input::PointerRoutedEventArgs &args) noexcept; + void OnPointerExited( + const winrt::Windows::Foundation::IInspectable &sender, + const winrt::Microsoft::ReactNative::Composition::Input::PointerRoutedEventArgs &args) noexcept; + void OnTick( + const winrt::Windows::Foundation::IInspectable &, + const winrt::Windows::Foundation::IInspectable &) noexcept; + void OnUnmounted( + const winrt::Windows::Foundation::IInspectable &, + const winrt::Microsoft::ReactNative::ComponentView &) noexcept; + + facebook::react::Tag Tag() const noexcept; + + private: + void ShowTooltip(const winrt::Microsoft::ReactNative::ComponentView &view) noexcept; + void DestroyTimer() noexcept; + void DestroyTooltip() noexcept; + + TooltipService *m_outer; + winrt::Windows::Foundation::Point m_pos; + ::Microsoft::ReactNative::ReactTaggedView m_view; + winrt::Microsoft::ReactNative::ITimer m_timer; + HWND m_hwndTip{nullptr}; + winrt::Microsoft::ReactNative::ReactPropertyBag m_properties; +}; + +struct TooltipService { + TooltipService(const winrt::Microsoft::ReactNative::ReactPropertyBag &properties); + void StartTracking(const winrt::Microsoft::ReactNative::ComponentView &view) noexcept; + void StopTracking(const winrt::Microsoft::ReactNative::ComponentView &view) noexcept; + + static std::shared_ptr GetCurrent( + const winrt::Microsoft::ReactNative::ReactPropertyBag &properties) noexcept; + + private: + std::vector> m_enteredTrackers; + std::vector> m_trackers; + winrt::Microsoft::ReactNative::ReactPropertyBag m_properties; +}; + +} // namespace winrt::Microsoft::ReactNative diff --git a/vnext/Microsoft.ReactNative/Fabric/ReactTaggedView.h b/vnext/Microsoft.ReactNative/Fabric/ReactTaggedView.h index 66ec48fe1f3..37b9059a066 100644 --- a/vnext/Microsoft.ReactNative/Fabric/ReactTaggedView.h +++ b/vnext/Microsoft.ReactNative/Fabric/ReactTaggedView.h @@ -31,6 +31,10 @@ struct ReactTaggedView { return strongView; } + facebook::react::Tag Tag() const noexcept { + return m_tag; + } + private: facebook::react::Tag m_tag; winrt::weak_ref m_view; diff --git a/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/components/view/HostPlatformViewProps.cpp b/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/components/view/HostPlatformViewProps.cpp index 7e4638224cc..dfdb20cdc49 100644 --- a/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/components/view/HostPlatformViewProps.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/components/view/HostPlatformViewProps.cpp @@ -27,6 +27,10 @@ HostPlatformViewProps::HostPlatformViewProps( CoreFeatures::enablePropIteratorSetter ? sourceProps.focusable : convertRawProp(context, rawProps, "focusable", sourceProps.focusable, {})), + tooltip( + CoreFeatures::enablePropIteratorSetter + ? sourceProps.tooltip + : convertRawProp(context, rawProps, "tooltip", sourceProps.tooltip, {})), accessibilityPosInSet( CoreFeatures::enablePropIteratorSetter ? sourceProps.accessibilityPosInSet @@ -82,6 +86,7 @@ void HostPlatformViewProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(accessibilityLiveRegion); RAW_SET_PROP_SWITCH_CASE_BASIC(keyDownEvents); RAW_SET_PROP_SWITCH_CASE_BASIC(keyUpEvents); + RAW_SET_PROP_SWITCH_CASE_BASIC(tooltip); } } diff --git a/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/components/view/HostPlatformViewProps.h b/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/components/view/HostPlatformViewProps.h index b2fce99065b..885dd8cbf18 100644 --- a/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/components/view/HostPlatformViewProps.h +++ b/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/components/view/HostPlatformViewProps.h @@ -29,7 +29,7 @@ class HostPlatformViewProps : public BaseViewProps { std::string accessibilityLiveRegion{"none"}; // std::optional overflowAnchor{}; - // std::optional tooltip{}; + std::optional tooltip{}; std::vector keyDownEvents{}; std::vector keyUpEvents{}; }; diff --git a/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/components/view/HostPlatformViewTraitsInitializer.h b/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/components/view/HostPlatformViewTraitsInitializer.h index 57d71a25e54..a71a16389bd 100644 --- a/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/components/view/HostPlatformViewTraitsInitializer.h +++ b/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/components/view/HostPlatformViewTraitsInitializer.h @@ -13,7 +13,7 @@ inline bool formsStackingContext(ViewProps const &viewProps) { // Only Views which are marked as focusable can actually trigger the events, which will already avoid being collapsed. constexpr decltype(WindowsViewEvents::bits) focusEventsMask = { (1 << (int)WindowsViewEvents::Offset::Focus) & (1 << (int)WindowsViewEvents::Offset::Blur)}; - return (viewProps.windowsEvents.bits & focusEventsMask).any(); + return (viewProps.windowsEvents.bits & focusEventsMask).any() || viewProps.tooltip; } inline bool formsView(ViewProps const &viewProps) { diff --git a/vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj b/vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj index 4fb9b143880..9e8df4b4325 100644 --- a/vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj +++ b/vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj @@ -144,7 +144,7 @@ ProgramDatabase - winsqlite3.lib;ChakraRT.lib;dxguid.lib;dloadhelper.lib;OneCoreUap_apiset.lib;%(AdditionalDependencies) + winsqlite3.lib;ChakraRT.lib;dxguid.lib;dloadhelper.lib;OneCoreUap_apiset.lib;Dwmapi.lib;%(AdditionalDependencies) api-ms-win-core-file-l1-2-0.dll; api-ms-win-core-windowserrorreporting-l1-1-0.dll; diff --git a/vnext/Microsoft.ReactNative/ReactCoreInjection.h b/vnext/Microsoft.ReactNative/ReactCoreInjection.h index 89e105bf2f4..c14e7cd0df2 100644 --- a/vnext/Microsoft.ReactNative/ReactCoreInjection.h +++ b/vnext/Microsoft.ReactNative/ReactCoreInjection.h @@ -53,7 +53,6 @@ struct ReactCoreInjection : ReactCoreInjectionT { static uint64_t GetTopLevelWindowId(const IReactPropertyBag &properties) noexcept; static void SetTopLevelWindowId(const IReactPropertyBag &properties, uint64_t windowId) noexcept; - static ITimer CreateTimer(const IReactPropertyBag &properties); static TimerFactory GetTimerFactory(const IReactPropertyBag &properties) noexcept; static void SetTimerFactory(const IReactPropertyBag &properties, const TimerFactory &timerFactory) noexcept; }; diff --git a/vnext/Shared/Shared.vcxitems b/vnext/Shared/Shared.vcxitems index 1d7b31ad7c9..00bff89a92d 100644 --- a/vnext/Shared/Shared.vcxitems +++ b/vnext/Shared/Shared.vcxitems @@ -150,6 +150,9 @@ true + + true + true diff --git a/vnext/Shared/Shared.vcxitems.filters b/vnext/Shared/Shared.vcxitems.filters index bca7ec269d2..ba47e68d52a 100644 --- a/vnext/Shared/Shared.vcxitems.filters +++ b/vnext/Shared/Shared.vcxitems.filters @@ -333,6 +333,7 @@ +