From 24914cc1edc763faedaa30a969a98797dd9a29df Mon Sep 17 00:00:00 2001 From: Max Katz Date: Wed, 26 Jun 2024 00:51:47 -0700 Subject: [PATCH] New ToolTipClosing, ToolTipOpening attached events and ToolTip.Opened, ToolTip.Closed (#15493) * Add ToolTip.Opened and ToolTip.Closed * Add ToolTipOpeningEvent and ToolTipClosingEvent attache events * Add tests and small sample * Docs * Remove new Opened/Closed events * Update tests * Restore internal Closed * Update tests * Don't use coerce logic, use CancelRoutedEventArgs in Opening API * Update samples API * Fix incorrect routed event definition --------- Co-authored-by: Jumar Macato <16554748+jmacato@users.noreply.github.com> --- samples/ControlCatalog/Pages/ToolTipPage.xaml | 11 +++ .../ControlCatalog/Pages/ToolTipPage.xaml.cs | 6 ++ src/Avalonia.Controls/ToolTip.cs | 89 +++++++++++++++++-- src/Avalonia.Controls/ToolTipService.cs | 3 +- .../ToolTipTests.cs | 81 +++++++++++++++++ 5 files changed, 180 insertions(+), 10 deletions(-) diff --git a/samples/ControlCatalog/Pages/ToolTipPage.xaml b/samples/ControlCatalog/Pages/ToolTipPage.xaml index fa40bc1a0f..8aaed903f3 100644 --- a/samples/ControlCatalog/Pages/ToolTipPage.xaml +++ b/samples/ControlCatalog/Pages/ToolTipPage.xaml @@ -77,12 +77,23 @@ Nested ToolTips + + + ToolTip replaced on the fly + diff --git a/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs b/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs index 202f83aaf1..0e8bf3a181 100644 --- a/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs +++ b/samples/ControlCatalog/Pages/ToolTipPage.xaml.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Interactivity; using Avalonia.Markup.Xaml; namespace ControlCatalog.Pages @@ -14,5 +15,10 @@ namespace ControlCatalog.Pages { AvaloniaXamlLoader.Load(this); } + + private void ToolTipOpening(object? sender, CancelRoutedEventArgs args) + { + ((Control)args.Source!).SetValue(ToolTip.TipProperty, "New tip set from ToolTipOpening."); + } } } diff --git a/src/Avalonia.Controls/ToolTip.cs b/src/Avalonia.Controls/ToolTip.cs index 245b4d74b0..8bf0adedee 100644 --- a/src/Avalonia.Controls/ToolTip.cs +++ b/src/Avalonia.Controls/ToolTip.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using Avalonia.Controls.Diagnostics; using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; using Avalonia.Reactive; using Avalonia.Styling; @@ -80,6 +81,28 @@ namespace Avalonia.Controls internal static readonly AttachedProperty ToolTipProperty = AvaloniaProperty.RegisterAttached("ToolTip"); + /// + /// The event raised when a ToolTip is going to be shown on an element. + /// + /// + /// To prevent a tooltip from appearing in the UI, your handler for ToolTipOpening can mark the event data handled. + /// Otherwise, the tooltip is displayed, using the value of the ToolTip property as the tooltip content. + /// Another possible scenario is that you could write a handler that resets the value of the ToolTip property for the element that is the event source, just before the tooltip is displayed. + /// ToolTipOpening will not be raised if the value of ToolTip is null or otherwise unset. Do not deliberately set ToolTip to null while a tooltip is open or opening; this will not have the effect of closing the tooltip, and will instead create an undesirable visual artifact in the UI. + /// + public static readonly RoutedEvent ToolTipOpeningEvent = + RoutedEvent.Register("ToolTipOpening", RoutingStrategies.Direct); + + /// + /// The event raised when a ToolTip on an element that was shown should now be hidden. + /// + /// + /// Marking the ToolTipClosing event as handled does not cancel closing the tooltip. + /// Once the tooltip is displayed, closing the tooltip is done only in response to user interaction with the UI. + /// + public static readonly RoutedEvent ToolTipClosingEvent = + RoutedEvent.Register("ToolTipClosing", RoutingStrategies.Direct); + private Popup? _popup; private Action? _popupHostChangedHandler; private CompositeDisposable? _subscriptions; @@ -92,9 +115,6 @@ namespace Avalonia.Controls IsOpenProperty.Changed.Subscribe(IsOpenChanged); } - internal Control? AdornedControl { get; private set; } - internal event EventHandler? Closed; - /// /// Gets the value of the ToolTip.Tip attached property. /// @@ -275,6 +295,38 @@ namespace Avalonia.Controls public static void SetServiceEnabled(Control element, bool value) => element.SetValue(ServiceEnabledProperty, value); + /// + /// Adds a handler for the attached event. + /// + /// that listens to this event. + /// Event Handler to be added. + public static void AddToolTipOpeningHandler(Control element, EventHandler handler) => + element.AddHandler(ToolTipOpeningEvent, handler); + + /// + /// Removes a handler for the attached event. + /// + /// that listens to this event. + /// Event Handler to be removed. + public static void RemoveToolTipOpeningHandler(Control element, EventHandler handler) => + element.RemoveHandler(ToolTipOpeningEvent, handler); + + /// + /// Adds a handler for the attached event. + /// + /// that listens to this event. + /// Event Handler to be removed. + public static void AddToolTipClosingHandler(Control element, EventHandler handler) => + element.AddHandler(ToolTipClosingEvent, handler); + + /// + /// Removes a handler for the attached event. + /// + /// that listens to this event. + /// Event Handler to be removed. + public static void RemoveToolTipClosingHandler(Control element, EventHandler handler) => + element.RemoveHandler(ToolTipClosingEvent, handler); + private static void IsOpenChanged(AvaloniaPropertyChangedEventArgs e) { var control = (Control)e.Sender; @@ -282,8 +334,20 @@ namespace Avalonia.Controls if (newValue) { + var args = new CancelRoutedEventArgs(ToolTipOpeningEvent); + control.RaiseEvent(args); + if (args.Cancel) + { + control.SetCurrentValue(IsOpenProperty, false); + return; + } + var tip = GetTip(control); - if (tip == null) return; + if (tip == null) + { + control.SetCurrentValue(IsOpenProperty, false); + return; + } var toolTip = control.GetValue(ToolTipProperty); if (toolTip == null || (tip != toolTip && tip != toolTip.Content)) @@ -292,25 +356,23 @@ namespace Avalonia.Controls toolTip = tip as ToolTip ?? new ToolTip { Content = tip }; control.SetValue(ToolTipProperty, toolTip); - toolTip.SetValue(ThemeVariant.RequestedThemeVariantProperty, control.ActualThemeVariant); } toolTip.AdornedControl = control; toolTip.Open(control); - toolTip?.UpdatePseudoClasses(newValue); } else if (control.GetValue(ToolTipProperty) is { } toolTip) { toolTip.AdornedControl = null; toolTip.Close(); - toolTip?.UpdatePseudoClasses(newValue); } } - IPopupHost? IPopupHostProvider.PopupHost => _popup?.Host; - + internal Control? AdornedControl { get; private set; } + internal event EventHandler? Closed; internal IPopupHost? PopupHost => _popup?.Host; + IPopupHost? IPopupHostProvider.PopupHost => _popup?.Host; event Action? IPopupHostProvider.PopupHostChanged { add => _popupHostChangedHandler += value; @@ -346,6 +408,13 @@ namespace Avalonia.Controls private void Close() { + if (AdornedControl is { } adornedControl + && GetIsOpen(adornedControl)) + { + var args = new RoutedEventArgs(ToolTipClosingEvent); + adornedControl.RaiseEvent(args); + } + _subscriptions?.Dispose(); if (_popup is not null) @@ -366,12 +435,14 @@ namespace Avalonia.Controls } _popupHostChangedHandler?.Invoke(null); + UpdatePseudoClasses(false); Closed?.Invoke(this, EventArgs.Empty); } private void OnPopupOpened(object? sender, EventArgs e) { _popupHostChangedHandler?.Invoke(((Popup)sender!).Host); + UpdatePseudoClasses(true); } private void UpdatePseudoClasses(bool newValue) diff --git a/src/Avalonia.Controls/ToolTipService.cs b/src/Avalonia.Controls/ToolTipService.cs index c3231fcde9..d01114be6f 100644 --- a/src/Avalonia.Controls/ToolTipService.cs +++ b/src/Avalonia.Controls/ToolTipService.cs @@ -221,7 +221,8 @@ namespace Avalonia.Controls { ToolTip.SetIsOpen(control, true); - if (control.GetValue(ToolTip.ToolTipProperty) is { } tooltip) + // Value can be coerced back to false, need to double check. + if (ToolTip.GetIsOpen(control) && control.GetValue(ToolTip.ToolTipProperty) is { } tooltip) { tooltip.Closed += ToolTipClosed; tooltip.PointerExited += ToolTipPointerExited; diff --git a/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs b/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs index 8dafef7e18..6199a20872 100644 --- a/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ToolTipTests.cs @@ -368,6 +368,87 @@ namespace Avalonia.Controls.UnitTests Assert.False(ToolTip.GetIsOpen(other)); } + [Fact] + public void ToolTip_Events_Order_Is_Defined() + { + using var app = UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow)); + + var tip = new ToolTip() { Content = "Tip" }; + var target = new Decorator() + { + [ToolTip.TipProperty] = tip, + [ToolTip.ShowDelayProperty] = 0 + }; + + var eventsOrder = new List<(string eventName, object sender, object source)>(); + + ToolTip.AddToolTipOpeningHandler(target, + (sender, args) => eventsOrder.Add(("Opening", sender, args.Source))); + ToolTip.AddToolTipClosingHandler(target, + (sender, args) => eventsOrder.Add(("Closing", sender, args.Source))); + + SetupWindowAndActivateToolTip(target); + + Assert.True(ToolTip.GetIsOpen(target)); + + target[ToolTip.TipProperty] = null; + + Assert.False(ToolTip.GetIsOpen(target)); + + Assert.Equal( + new[] + { + ("Opening", (object)target, (object)target), + ("Closing", target, target) + }, + eventsOrder); + } + + [Fact] + public void ToolTip_Is_Not_Opened_If_Opening_Event_Handled() + { + using var app = UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow)); + + var tip = new ToolTip() { Content = "Tip" }; + var target = new Decorator() + { + [ToolTip.TipProperty] = tip, + [ToolTip.ShowDelayProperty] = 0 + }; + + ToolTip.AddToolTipOpeningHandler(target, + (sender, args) => args.Cancel = true); + + SetupWindowAndActivateToolTip(target); + + Assert.False(ToolTip.GetIsOpen(target)); + } + + [Fact] + public void ToolTip_Can_Be_Replaced_On_The_Fly_Via_Opening_Event() + { + using var app = UnitTestApplication.Start(ConfigureServices(TestServices.FocusableWindow)); + + var tip1 = new ToolTip() { Content = "Hi" }; + var tip2 = new ToolTip() { Content = "Bye" }; + var target = new Decorator() + { + [ToolTip.TipProperty] = tip1, + [ToolTip.ShowDelayProperty] = 0 + }; + + ToolTip.AddToolTipOpeningHandler(target, + (sender, args) => target[ToolTip.TipProperty] = tip2); + + SetupWindowAndActivateToolTip(target); + + Assert.True(ToolTip.GetIsOpen(target)); + + target[ToolTip.TipProperty] = null; + + Assert.False(ToolTip.GetIsOpen(target)); + } + [Fact] public void Should_Close_When_Pointer_Leaves_Window() {