diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml
index 63e8234919..d215b13118 100644
--- a/api/Avalonia.nupkg.xml
+++ b/api/Avalonia.nupkg.xml
@@ -5545,4 +5545,16 @@
baseline/Avalonia/lib/netstandard2.0/Avalonia.Base.dll
current/Avalonia/lib/netstandard2.0/Avalonia.Base.dll
+
+ CP0002
+ F:Avalonia.Controls.Primitives.FlyoutBase.IsOpenProperty
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+
+ CP0002
+ F:Avalonia.Controls.Primitives.FlyoutBase.IsOpenProperty
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
diff --git a/src/Avalonia.Controls/Button.cs b/src/Avalonia.Controls/Button.cs
index ceadae432c..c094db57ec 100644
--- a/src/Avalonia.Controls/Button.cs
+++ b/src/Avalonia.Controls/Button.cs
@@ -543,10 +543,13 @@ namespace Avalonia.Controls
oldFlyout.Hide();
}
+ (oldFlyout as PopupFlyoutBase)?.SetDefaultPlacementTarget(null);
+
// Must unregister events here while a reference to the old flyout still exists
UnregisterFlyoutEvents(oldFlyout);
RegisterFlyoutEvents(newFlyout);
+ (newFlyout as PopupFlyoutBase)?.SetDefaultPlacementTarget(this);
UpdatePseudoClasses();
}
}
diff --git a/src/Avalonia.Controls/Flyouts/FlyoutBase.cs b/src/Avalonia.Controls/Flyouts/FlyoutBase.cs
index b5328ccab8..b7de300e84 100644
--- a/src/Avalonia.Controls/Flyouts/FlyoutBase.cs
+++ b/src/Avalonia.Controls/Flyouts/FlyoutBase.cs
@@ -7,9 +7,8 @@ namespace Avalonia.Controls.Primitives
///
/// Defines the property
///
- public static readonly DirectProperty IsOpenProperty =
- AvaloniaProperty.RegisterDirect(nameof(IsOpen),
- x => x.IsOpen);
+ public static readonly StyledProperty IsOpenProperty =
+ AvaloniaProperty.Register(nameof(IsOpen));
///
/// Defines the property
@@ -23,19 +22,23 @@ namespace Avalonia.Controls.Primitives
public static readonly AttachedProperty AttachedFlyoutProperty =
AvaloniaProperty.RegisterAttached("AttachedFlyout", null);
- private bool _isOpen;
private Control? _target;
public event EventHandler? Opened;
public event EventHandler? Closed;
-
+
///
- /// Gets whether this Flyout is currently Open
+ /// Gets or sets whether this Flyout is currently open.
///
+ ///
+ /// Setting this property to true will show the flyout at the last known
+ /// placement target. If no target has been set via ,
+ /// setting this to true will have no effect.
+ ///
public bool IsOpen
{
- get => _isOpen;
- protected set => SetAndRaise(IsOpenProperty, ref _isOpen, value);
+ get => GetValue(IsOpenProperty);
+ set => SetValue(IsOpenProperty, value);
}
///
diff --git a/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs b/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs
index 19d1f52850..cea69524f1 100644
--- a/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs
+++ b/src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel;
using System.Linq;
using Avalonia.Controls.Diagnostics;
@@ -67,9 +67,14 @@ namespace Avalonia.Controls.Primitives
private PixelRect? _enlargePopupRectScreenPixelRect;
private IDisposable? _transientDisposable;
private Action? _popupHostChangedHandler;
+ private bool _isOpen;
+ private bool _ignoreIsOpenChanged;
+ private Control? _lastPlacementTarget;
static PopupFlyoutBase()
{
+ IsOpenProperty.Changed.AddClassHandler(
+ (x, e) => x.IsOpenChanged((AvaloniaPropertyChangedEventArgs)e));
Control.ContextFlyoutProperty.Changed.Subscribe(OnContextFlyoutPropertyChanged);
}
@@ -136,9 +141,9 @@ namespace Avalonia.Controls.Primitives
/// through to the parent window.
///
///
- /// Clicks outside the popup cause the popup to close. When
- /// is set to false, these clicks will be
- /// handled by the popup and not be registered by the parent window. When set to true,
+ /// Clicks outside the popup cause the popup to close. When
+ /// is set to false, these clicks will be
+ /// handled by the popup and not be registered by the parent window. When set to true,
/// the events will be passed through to the parent window.
///
public bool OverlayDismissEventPassThrough
@@ -175,6 +180,16 @@ namespace Avalonia.Controls.Primitives
public event EventHandler? Closing;
public event EventHandler? Opening;
+ ///
+ /// Pre-registers a control as the default placement target for this flyout.
+ /// Used by owning controls (e.g. ) so that setting
+ /// to true works on first use.
+ ///
+ internal void SetDefaultPlacementTarget(Control? target)
+ {
+ _lastPlacementTarget = target;
+ }
+
///
/// Shows the Flyout at the given Control
///
@@ -205,7 +220,7 @@ namespace Avalonia.Controls.Primitives
/// True, if action was handled
protected virtual bool HideCore(bool canCancel = true)
{
- if (!IsOpen)
+ if (!_isOpen)
{
return false;
}
@@ -218,7 +233,11 @@ namespace Avalonia.Controls.Primitives
}
}
- IsOpen = false;
+ _isOpen = false;
+ using (BeginIgnoringIsOpen())
+ {
+ SetCurrentValue(IsOpenProperty, false);
+ }
Popup.IsOpen = false;
Popup.PlacementTarget = null;
@@ -251,7 +270,9 @@ namespace Avalonia.Controls.Primitives
throw new ArgumentNullException(nameof(placementTarget));
}
- if (IsOpen)
+ _lastPlacementTarget = placementTarget;
+
+ if (_isOpen)
{
if (placementTarget == Target)
{
@@ -280,7 +301,12 @@ namespace Avalonia.Controls.Primitives
}
PositionPopup(showAtPointer);
- IsOpen = Popup.IsOpen = true;
+ _isOpen = true;
+ using (BeginIgnoringIsOpen())
+ {
+ SetCurrentValue(IsOpenProperty, true);
+ }
+ Popup.IsOpen = true;
OnOpened();
placementTarget.DetachedFromVisualTree += PlacementTarget_DetachedFromVisualTree;
@@ -310,6 +336,7 @@ namespace Avalonia.Controls.Primitives
private void PlacementTarget_DetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_ = HideCore(false);
+ _lastPlacementTarget = null;
}
private void HandleTransientDismiss(RawInputEventArgs args)
@@ -318,7 +345,7 @@ namespace Avalonia.Controls.Primitives
{
// In ShowMode = TransientWithDismissOnPointerMoveAway, the Flyout is kept
// shown as long as the pointer is within a certain px distance from the
- // flyout itself. I'm not sure what WinUI uses, but I'm defaulting to
+ // flyout itself. I'm not sure what WinUI uses, but I'm defaulting to
// 100px, which seems about right
// enlargedPopupRect is the Flyout bounds enlarged 100px
// For windowed popups, enlargedPopupRect is in screen coordinates,
@@ -348,7 +375,7 @@ namespace Avalonia.Controls.Primitives
// As long as the pointer stays within the enlargedPopupRect
// the flyout stays open. If it leaves, close it
// Despite working in screen coordinates, leaving the TopLevel
- // window will not close this (as pointer events stop), which
+ // window will not close this (as pointer events stop), which
// does match UWP
var pt = eventRoot.PointToScreen(pArgs.Position);
if (!_enlargePopupRectScreenPixelRect?.Contains(pt) ?? false)
@@ -401,14 +428,18 @@ namespace Avalonia.Controls.Primitives
private void OnPopupOpened(object? sender, EventArgs e)
{
- IsOpen = true;
+ _isOpen = true;
+ using (BeginIgnoringIsOpen())
+ {
+ SetCurrentValue(IsOpenProperty, true);
+ }
_popupHostChangedHandler?.Invoke(Popup.Host);
}
private void OnPopupClosing(object? sender, CancelEventArgs e)
{
- if (IsOpen)
+ if (_isOpen)
{
e.Cancel = CancelClosing();
}
@@ -425,7 +456,7 @@ namespace Avalonia.Controls.Primitives
private void OnPlacementTargetOrPopupKeyUp(object? sender, KeyEventArgs e)
{
if (!e.Handled
- && IsOpen
+ && _isOpen
&& Target?.ContextFlyout == this)
{
var keymap = Application.Current!.PlatformSettings?.HotkeyConfiguration;
@@ -437,6 +468,60 @@ namespace Avalonia.Controls.Primitives
}
}
+ private void IsOpenChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (_ignoreIsOpenChanged)
+ {
+ return;
+ }
+
+ if (e.NewValue.Value)
+ {
+ if (_lastPlacementTarget != null && ShowAtCore(_lastPlacementTarget))
+ {
+ return;
+ }
+
+ // No target, or opening was cancelled — revert so IsOpen stays honest
+ using (BeginIgnoringIsOpen())
+ {
+ SetCurrentValue(IsOpenProperty, false);
+ }
+ }
+ else
+ {
+ if (!HideCore())
+ {
+ // Closing was cancelled — revert so IsOpen stays honest
+ using (BeginIgnoringIsOpen())
+ {
+ SetCurrentValue(IsOpenProperty, true);
+ }
+ }
+ }
+ }
+
+ private IgnoreIsOpenScope BeginIgnoringIsOpen()
+ {
+ return new IgnoreIsOpenScope(this);
+ }
+
+ private readonly struct IgnoreIsOpenScope : IDisposable
+ {
+ private readonly PopupFlyoutBase _owner;
+
+ public IgnoreIsOpenScope(PopupFlyoutBase owner)
+ {
+ _owner = owner;
+ _owner._ignoreIsOpenChanged = true;
+ }
+
+ public void Dispose()
+ {
+ _owner._ignoreIsOpenChanged = false;
+ }
+ }
+
private void PositionPopup(bool showAtPointer)
{
Size sz;
diff --git a/src/Avalonia.Controls/SplitButton/SplitButton.cs b/src/Avalonia.Controls/SplitButton/SplitButton.cs
index 28225a5ad1..390f679191 100644
--- a/src/Avalonia.Controls/SplitButton/SplitButton.cs
+++ b/src/Avalonia.Controls/SplitButton/SplitButton.cs
@@ -331,10 +331,13 @@ namespace Avalonia.Controls
oldFlyout.Hide();
}
+ (oldFlyout as PopupFlyoutBase)?.SetDefaultPlacementTarget(null);
+
// Must unregister events here while a reference to the old flyout still exists
UnregisterFlyoutEvents(oldFlyout);
RegisterFlyoutEvents(newFlyout);
+ (newFlyout as PopupFlyoutBase)?.SetDefaultPlacementTarget(this);
UpdatePseudoClasses();
}
diff --git a/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs b/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs
index 4d8a90f5e7..77c3eb0874 100644
--- a/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/FlyoutTests.cs
@@ -3,6 +3,7 @@ using System.ComponentModel;
using System.Linq;
using Avalonia.Controls.Primitives;
+using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
@@ -646,6 +647,203 @@ namespace Avalonia.Controls.UnitTests
}
}
+ [Fact]
+ public void IsOpen_SetFalse_Closes_Flyout()
+ {
+ using (CreateServicesWithFocus())
+ {
+ var window = PreparedWindow();
+ window.Show();
+
+ var flyout = new TestFlyout();
+ bool closedFired = false;
+ flyout.Closed += (s, e) => closedFired = true;
+
+ flyout.ShowAt(window);
+ Assert.True(flyout.IsOpen);
+
+ flyout.IsOpen = false;
+
+ Assert.False(flyout.IsOpen);
+ Assert.False(flyout.Popup.IsOpen);
+ Assert.True(closedFired);
+ }
+ }
+
+ [Fact]
+ public void IsOpen_SetTrue_Reopens_At_Last_Target()
+ {
+ using (CreateServicesWithFocus())
+ {
+ var window = PreparedWindow();
+ window.Show();
+
+ var flyout = new TestFlyout();
+ flyout.ShowAt(window);
+ Assert.True(flyout.IsOpen);
+
+ flyout.Hide();
+ Assert.False(flyout.IsOpen);
+
+ flyout.IsOpen = true;
+
+ Assert.True(flyout.IsOpen);
+ Assert.True(flyout.Popup.IsOpen);
+ Assert.Equal(window, flyout.Popup.PlacementTarget);
+ }
+ }
+
+ [Fact]
+ public void IsOpen_SetTrue_Without_Previous_Target_Reverts_To_False()
+ {
+ using (CreateServicesWithFocus())
+ {
+ var window = PreparedWindow();
+ window.Show();
+
+ var flyout = new TestFlyout();
+
+ flyout.IsOpen = true;
+
+ Assert.False(flyout.IsOpen);
+ Assert.False(flyout.Popup.IsOpen);
+ }
+ }
+
+ [Fact]
+ public void IsOpen_SetTrue_Opens_At_Button_Flyout_Owner()
+ {
+ using (CreateServicesWithFocus())
+ {
+ var button = new Button();
+ var window = PreparedWindow(button);
+ window.Show();
+
+ var flyout = new TestFlyout();
+ button.Flyout = flyout;
+
+ flyout.IsOpen = true;
+
+ Assert.True(flyout.IsOpen);
+ Assert.True(flyout.Popup.IsOpen);
+ Assert.Equal(button, flyout.Popup.PlacementTarget);
+ }
+ }
+
+ [Fact]
+ public void IsOpen_Button_Flyout_Removed_Clears_Target()
+ {
+ using (CreateServicesWithFocus())
+ {
+ var button = new Button();
+ var window = PreparedWindow(button);
+ window.Show();
+
+ var flyout = new TestFlyout();
+ button.Flyout = flyout;
+
+ button.Flyout = null;
+
+ flyout.IsOpen = true;
+
+ Assert.False(flyout.IsOpen);
+ }
+ }
+
+ [Fact]
+ public void IsOpen_TwoWay_Binding_Syncs_With_Source()
+ {
+ using (CreateServicesWithFocus())
+ {
+ var window = PreparedWindow();
+ window.Show();
+
+ var viewModel = new FlyoutViewModel();
+ var flyout = new TestFlyout();
+ flyout.Bind(FlyoutBase.IsOpenProperty, new Binding(nameof(FlyoutViewModel.IsOpen))
+ {
+ Source = viewModel,
+ Mode = BindingMode.TwoWay
+ });
+
+ Assert.False(viewModel.IsOpen);
+
+ flyout.ShowAt(window);
+ Assert.True(viewModel.IsOpen);
+
+ flyout.Hide();
+ Assert.False(viewModel.IsOpen);
+ }
+ }
+
+ [Fact]
+ public void IsOpen_SetFalse_Cancelled_Closing_Reverts_To_True()
+ {
+ using (CreateServicesWithFocus())
+ {
+ var window = PreparedWindow();
+ window.Show();
+
+ var flyout = new TestFlyout();
+ flyout.Closing += (s, e) => e.Cancel = true;
+
+ flyout.ShowAt(window);
+ Assert.True(flyout.IsOpen);
+
+ flyout.IsOpen = false;
+
+ Assert.True(flyout.IsOpen);
+ Assert.True(flyout.Popup.IsOpen);
+ }
+ }
+
+ [Fact]
+ public void IsOpen_SetTrue_After_Target_Detached_Reverts_To_False()
+ {
+ using (CreateServicesWithFocus())
+ {
+ var target = new Button();
+ var window = PreparedWindow(target);
+ window.Show();
+
+ var flyout = new TestFlyout();
+ flyout.ShowAt(target);
+ Assert.True(flyout.IsOpen);
+
+ // Detach the target from the visual tree
+ window.Content = null;
+ Assert.False(flyout.IsOpen);
+
+ flyout.IsOpen = true;
+
+ Assert.False(flyout.IsOpen);
+ }
+ }
+
+ [Fact]
+ public void IsOpen_SetTrue_Cancelled_Opening_Reverts_To_False()
+ {
+ using (CreateServicesWithFocus())
+ {
+ var window = PreparedWindow();
+ window.Show();
+
+ var flyout = new TestFlyout();
+ flyout.ShowAt(window);
+ flyout.Hide();
+
+ flyout.Opening += (s, e) =>
+ {
+ if (e is CancelEventArgs cancelArgs)
+ cancelArgs.Cancel = true;
+ };
+
+ flyout.IsOpen = true;
+
+ Assert.False(flyout.IsOpen);
+ }
+ }
+
private IDisposable CreateServicesWithFocus()
{
return UnitTestApplication.Start(TestServices.StyledWindow.With(windowingPlatform:
@@ -677,11 +875,31 @@ namespace Avalonia.Controls.UnitTests
new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.LeftButtonPressed),
KeyModifiers.None);
}
-
+
public class TestFlyout : Flyout
{
public new Popup Popup => base.Popup;
}
+
+ private class FlyoutViewModel : INotifyPropertyChanged
+ {
+ private bool _isOpen;
+
+ public bool IsOpen
+ {
+ get => _isOpen;
+ set
+ {
+ if (_isOpen != value)
+ {
+ _isOpen = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsOpen)));
+ }
+ }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ }
}
public class OverlayPopupFlyoutTests : FlyoutTests