Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New ToolTipClosing, ToolTipOpening attached events and ToolTip.Opened, ToolTip.Closed #15493

Merged
merged 15 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions samples/ControlCatalog/Pages/ToolTipPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,23 @@
</Button>

<Border Grid.Row="3"
Grid.Column="0"
Background="{DynamicResource SystemAccentColor}"
Margin="5"
Padding="50"
ToolTip.Tip="Outer tooltip">
<TextBlock Background="{StaticResource SystemAccentColorDark1}" Padding="10" ToolTip.Tip="Inner tooltip" VerticalAlignment="Center">Nested ToolTips</TextBlock>
</Border>

<Border Grid.Row="3"
Grid.Column="1"
Background="{DynamicResource SystemAccentColor}"
Margin="5"
Padding="50"
ToolTip.ToolTipOpening="ToolTipOpening"
ToolTip.Tip="Should never be visible">
<TextBlock VerticalAlignment="Center">ToolTip replaced on the fly</TextBlock>
</Border>
</Grid>
</StackPanel>
</UserControl>
6 changes: 6 additions & 0 deletions samples/ControlCatalog/Pages/ToolTipPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;

namespace ControlCatalog.Pages
Expand All @@ -14,5 +15,10 @@ private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}

private void ToolTipOpening(object? sender, CancelRoutedEventArgs args)
{
((Control)args.Source!).SetValue(ToolTip.TipProperty, "New tip set from ToolTipOpening.");
}
}
}
89 changes: 80 additions & 9 deletions src/Avalonia.Controls/ToolTip.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Avalonia.Controls.Diagnostics;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Reactive;
using Avalonia.Styling;

Expand Down Expand Up @@ -80,6 +81,28 @@ public class ToolTip : ContentControl, IPopupHostProvider
internal static readonly AttachedProperty<ToolTip?> ToolTipProperty =
AvaloniaProperty.RegisterAttached<ToolTip, Control, ToolTip?>("ToolTip");

/// <summary>
/// The event raised when a ToolTip is going to be shown on an element.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static readonly RoutedEvent<CancelRoutedEventArgs> ToolTipOpeningEvent =
RoutedEvent.Register<ToolTip, CancelRoutedEventArgs>("ToolTipOpening", RoutingStrategies.Direct);

/// <summary>
/// The event raised when a ToolTip on an element that was shown should now be hidden.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static readonly RoutedEvent ToolTipClosingEvent =
RoutedEvent.Register<ToolTip, RoutedEventArgs>("ToolTipClosing", RoutingStrategies.Direct);

private Popup? _popup;
private Action<IPopupHost?>? _popupHostChangedHandler;
private CompositeDisposable? _subscriptions;
Expand All @@ -92,9 +115,6 @@ static ToolTip()
IsOpenProperty.Changed.Subscribe(IsOpenChanged);
}

internal Control? AdornedControl { get; private set; }
internal event EventHandler? Closed;

/// <summary>
/// Gets the value of the ToolTip.Tip attached property.
/// </summary>
Expand Down Expand Up @@ -275,15 +295,59 @@ public static bool GetServiceEnabled(Control element) =>
public static void SetServiceEnabled(Control element, bool value) =>
element.SetValue(ServiceEnabledProperty, value);

/// <summary>
/// Adds a handler for the <see cref="ToolTipOpeningEvent"/> attached event.
/// </summary>
/// <param name="element"><see cref="Control"/> that listens to this event.</param>
/// <param name="handler">Event Handler to be added.</param>
public static void AddToolTipOpeningHandler(Control element, EventHandler<CancelRoutedEventArgs> handler) =>
element.AddHandler(ToolTipOpeningEvent, handler);

/// <summary>
/// Removes a handler for the <see cref="ToolTipOpeningEvent"/> attached event.
/// </summary>
/// <param name="element"><see cref="Control"/> that listens to this event.</param>
/// <param name="handler">Event Handler to be removed.</param>
public static void RemoveToolTipOpeningHandler(Control element, EventHandler<CancelRoutedEventArgs> handler) =>
element.RemoveHandler(ToolTipOpeningEvent, handler);

/// <summary>
/// Adds a handler for the <see cref="ToolTipClosingEvent"/> attached event.
/// </summary>
/// <param name="element"><see cref="Control"/> that listens to this event.</param>
/// <param name="handler">Event Handler to be removed.</param>
public static void AddToolTipClosingHandler(Control element, EventHandler<RoutedEventArgs> handler) =>
element.AddHandler(ToolTipClosingEvent, handler);

/// <summary>
/// Removes a handler for the <see cref="ToolTipClosingEvent"/> attached event.
/// </summary>
/// <param name="element"><see cref="Control"/> that listens to this event.</param>
/// <param name="handler">Event Handler to be removed.</param>
public static void RemoveToolTipClosingHandler(Control element, EventHandler<RoutedEventArgs> handler) =>
element.RemoveHandler(ToolTipClosingEvent, handler);

private static void IsOpenChanged(AvaloniaPropertyChangedEventArgs e)
{
var control = (Control)e.Sender;
var newValue = (bool)e.NewValue!;

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))
Expand All @@ -292,25 +356,23 @@ private static void IsOpenChanged(AvaloniaPropertyChangedEventArgs e)

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<IPopupHost?>? IPopupHostProvider.PopupHostChanged
{
add => _popupHostChangedHandler += value;
Expand Down Expand Up @@ -346,6 +408,13 @@ private void Open(Control control)

private void Close()
{
if (AdornedControl is { } adornedControl
&& GetIsOpen(adornedControl))
{
var args = new RoutedEventArgs(ToolTipClosingEvent);
adornedControl.RaiseEvent(args);
}

_subscriptions?.Dispose();

if (_popup is not null)
Expand All @@ -366,12 +435,14 @@ private void OnPopupClosed(object? sender, EventArgs e)
}

_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)
Expand Down
3 changes: 2 additions & 1 deletion src/Avalonia.Controls/ToolTipService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@ private void Open(Control control)
{
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;
Expand Down
81 changes: 81 additions & 0 deletions tests/Avalonia.Controls.UnitTests/ToolTipTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,87 @@ public void New_ToolTip_Replaces_Other_ToolTip_Immediately()
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()
{
Expand Down
Loading