Skip to content
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
12 changes: 12 additions & 0 deletions docfx/articles/interactions-custom/core/routed-event-trigger.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,15 @@ The `RoutedEventTrigger` (and its variants `RoutedEventTriggerBase`, `RoutedEven
### Properties
- `RoutedEvent`: The routed event to listen for (e.g., `Button.ClickEvent`).
- `RoutingStrategies`: The routing strategy to use (Tunnel, Bubble, Direct).

When a `RoutedEventTriggerBehavior` is attached directly to a `TopLevel`, its routed-event subscription and action bindings remain active through the top-level's closed event. They are released when the behavior is actually detached. Other controls continue to subscribe only while attached to the visual tree.

```xml
<Window>
<Interaction.Behaviors>
<RoutedEventTriggerBehavior RoutedEvent="{x:Static Window.WindowClosedEvent}">
<InvokeCommandAction Command="{Binding WindowClosedCommand}" />
</RoutedEventTriggerBehavior>
</Interaction.Behaviors>
</Window>
```
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,30 @@ protected override void OnAttachedToVisualTree()

/// <inheritdoc />
protected override void OnDetachedFromVisualTree()
{
_isAttached = false;

if (AssociatedObject is not TopLevel || ComputeResolvedSourceInteractive() is not TopLevel)
{
RemoveHandler();
}
}

/// <inheritdoc />
protected override void OnDetaching()
{
_isAttached = false;
RemoveHandler();
base.OnDetaching();
}

private void AddHandler()
{
if (_isInitialized)
{
return;
}

var interactive = ComputeResolvedSourceInteractive();
if (interactive is not null && RoutedEvent is not null)
{
Expand All @@ -139,6 +156,11 @@ private void RemoveHandler()
private void Handler(object? sender, RoutedEventArgs e)
{
Execute(e);

if (!_isAttached)
{
RemoveHandler();
}
}

private void Execute(object? parameter)
Expand Down
38 changes: 35 additions & 3 deletions src/Xaml.Behaviors.Interactivity/Interaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
using Avalonia.Interactivity;
using Avalonia.LogicalTree;
using Avalonia.Reactive;
using Avalonia.Threading;
using Avalonia.VisualTree;

namespace Avalonia.Xaml.Interactivity;

Expand Down Expand Up @@ -278,8 +280,17 @@ private static void Visual_DetachedFromVisualTree_FromGetter(object? sender, Vis
return;
}

GetBehaviors(d).DetachedFromVisualTree();
GetBehaviors(d).Detach();
var behaviors = GetBehaviors(d);
behaviors.DetachedFromVisualTree();

if (d is TopLevel topLevel)
{
ScheduleTopLevelBehaviorDetach(topLevel, behaviors);
}
else
{
behaviors.Detach();
}
}

private static void Visual_AttachedToVisualTree_FromChangedEvent(object? sender, VisualTreeAttachmentEventArgs e)
Expand All @@ -299,7 +310,28 @@ private static void Visual_DetachedFromVisualTree_FromChangedEvent(object? sende
return;
}

GetBehaviors(d).DetachedFromVisualTree();
var behaviors = GetBehaviors(d);
behaviors.DetachedFromVisualTree();

if (d is TopLevel topLevel)
{
ScheduleTopLevelBehaviorDetach(topLevel, behaviors);
}
}

private static void ScheduleTopLevelBehaviorDetach(
TopLevel topLevel,
BehaviorCollection behaviors)
{
Dispatcher.UIThread.Post(() =>
{
if (!topLevel.IsAttachedToVisualTree() &&
ReferenceEquals(topLevel.GetValue(BehaviorsProperty), behaviors) &&
behaviors.AssociatedObject is not null)
{
behaviors.Detach();
}
});
}

// AttachedToLogicalTree / DetachedFromLogicalTree
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,10 @@ void IBehaviorEventsHandler.AttachedToVisualTreeEventHandler()

void IBehaviorEventsHandler.DetachedFromVisualTreeEventHandler()
{
DetachBehaviorFromLogicalTree();
if (AssociatedObject is not TopLevel)
{
DetachBehaviorFromLogicalTree();
Comment thread
wieslawsoltes marked this conversation as resolved.
}

OnDetachedFromVisualTree();
}
Expand All @@ -132,7 +135,10 @@ void IBehaviorEventsHandler.AttachedToLogicalTreeEventHandler()

void IBehaviorEventsHandler.DetachedFromLogicalTreeEventHandler()
{
DetachBehaviorFromLogicalTree();
if (AssociatedObject is not TopLevel)
{
DetachBehaviorFromLogicalTree();
}

OnDetachedFromLogicalTree();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Interactivity;
using Avalonia.Threading;
using Avalonia.Xaml.Interactions.Custom;
using Avalonia.Xaml.Interactivity;
using Xunit;

namespace Avalonia.Xaml.Interactions.UnitTests.Custom;

public class RoutedEventTriggerBehaviorTests
{
[AvaloniaFact]
public void WindowClosedEvent_ExecutesBoundCommand()
{
var window = new WindowClosedRoutedEventWindow();
var source = Assert.IsType<WindowClosedBindingSource>(window.DataContext);
var behavior = Assert.IsType<RoutedEventTriggerBehavior>(
Assert.Single(Interaction.GetBehaviors(window)));
var action = new CountingAction();
behavior.Actions!.Add(action);

window.Show();
window.Close();
Dispatcher.UIThread.RunJobs();

Assert.Equal(1, action.ExecutionCount);
Assert.Equal(1, source.CloseCommand.ExecutionCount);
Assert.Null(behavior.AssociatedObject);

window.RaiseEvent(new RoutedEventArgs(Window.WindowClosedEvent));

Assert.Equal(1, action.ExecutionCount);
Assert.Equal(1, source.CloseCommand.ExecutionCount);
}

[AvaloniaFact]
public void ControlRoutedEvent_UnsubscribesWhenControlLeavesVisualTree()
{
var button = new Button();
var panel = new Panel { Children = { button } };
var window = new Window { Content = panel };
var behavior = new RoutedEventTriggerBehavior
{
RoutedEvent = Button.ClickEvent
};
var action = new CountingAction();
behavior.Actions!.Add(action);
Interaction.GetBehaviors(button).Add(behavior);

window.Show();
button.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));

Assert.Equal(1, action.ExecutionCount);

panel.Children.Remove(button);
Dispatcher.UIThread.RunJobs();
button.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));

Assert.Equal(1, action.ExecutionCount);
}

private sealed class CountingAction : Avalonia.Xaml.Interactivity.Action
{
public int ExecutionCount { get; private set; }

public override object? Execute(object? sender, object? parameter)
{
ExecutionCount++;
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Avalonia.Xaml.Interactions.UnitTests.Custom"
x:Class="Avalonia.Xaml.Interactions.UnitTests.Custom.WindowClosedRoutedEventWindow"
x:DataType="local:WindowClosedBindingSource">
<Window.DataContext>
<local:WindowClosedBindingSource />
</Window.DataContext>

<Interaction.Behaviors>
<RoutedEventTriggerBehavior RoutedEvent="{x:Static Window.WindowClosedEvent}">
<InvokeCommandAction Command="{Binding CloseCommand}" />
</RoutedEventTriggerBehavior>
</Interaction.Behaviors>
</Window>
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using System.Windows.Input;
using Avalonia.Controls;

namespace Avalonia.Xaml.Interactions.UnitTests.Custom;

public partial class WindowClosedRoutedEventWindow : Window
{
public WindowClosedRoutedEventWindow()
{
InitializeComponent();
}
}

public class WindowClosedBindingSource
{
public WindowClosedBindingSource()
{
CloseCommand = new CountingCommand();
}

public CountingCommand CloseCommand { get; }
}

public class CountingCommand : ICommand
{
public int ExecutionCount { get; private set; }

public event EventHandler? CanExecuteChanged
{
add { }
remove { }
}

public bool CanExecute(object? parameter) => true;

public void Execute(object? parameter)
{
ExecutionCount++;
}
}
22 changes: 22 additions & 0 deletions tests/Xaml.Behaviors.Interactivity.UnitTests/InteractionTest.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Linq;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Xunit;

namespace Avalonia.Xaml.Interactivity.UnitTests;
Expand Down Expand Up @@ -97,6 +98,27 @@ public void SetBehaviors_ManualDetachThenNull_DoesNotDoubleDetach()
}
}

[AvaloniaFact]
public void SetBehaviors_TopLevelClose_DefersThenDetachesCollection()
{
var behavior = new StubBehavior();
var behaviors = new BehaviorCollection { behavior };
var window = new Window();
Interaction.SetBehaviors(window, behaviors);

window.Show();
window.Close();

Assert.Same(window, behaviors.AssociatedObject);
Assert.Same(window, behavior.AssociatedObject);

Dispatcher.UIThread.RunJobs();

Assert.Null(behaviors.AssociatedObject);
Assert.Null(behavior.AssociatedObject);
Assert.Equal(1, behavior.DetachCount);
}

[AvaloniaFact]
public void ExecuteActions_NullParameters_ReturnsEmptyEnumerable()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Xunit;

namespace Avalonia.Xaml.Interactivity.UnitTests;
Expand Down Expand Up @@ -32,5 +33,28 @@ public void Detach_ClearsLogicalParentAndTemplatedParent()
window.Close();
}

[AvaloniaFact]
public void TopLevelClose_PreservesLogicalParentUntilDeferredDetach()
{
var behavior = new TestStyledElementBehavior();
var window = new Window();
Interaction.GetBehaviors(window).Add(behavior);

window.Show();

Assert.Same(window, behavior.AssociatedObject);
Assert.Same(window, behavior.Parent);

window.Close();

Assert.Same(window, behavior.AssociatedObject);
Assert.Same(window, behavior.Parent);

Dispatcher.UIThread.RunJobs();

Assert.Null(behavior.AssociatedObject);
Assert.Null(behavior.Parent);
}

private sealed class TestStyledElementBehavior : StyledElementBehavior;
}
Loading