Browse Source

Make FlyoutBase.IsOpen a public StyledProperty (#20920)

* Make FlyoutBase.IsOpen a public StyledProperty with two-way binding support

Convert IsOpen from a DirectProperty with a protected setter to a
StyledProperty with a public setter and TwoWay default binding mode.
This enables MVVM scenarios where a ViewModel can control flyout
visibility through data binding.

The implementation mirrors the established Popup.IsOpen pattern:
- Reentrancy guard (BeginIgnoringIsOpen scope) prevents recursive
  property change notifications when internal code syncs the property
- SetCurrentValue preserves active bindings and styles (enforced by
  analyzer AVP1012)
- _isOpen field tracks actual open state independently of the property
  value, since the property system sets the value before the change
  handler fires
- _lastPlacementTarget enables re-opening at the last known target
  when IsOpen is set to true via binding
- IsOpen reverts to false when no target is available or opening is
  cancelled, and reverts to true when closing is cancelled, keeping
  the property honest

Fixes #18716

* ci: retrigger checks

* Add API suppression for FlyoutBase.IsOpenProperty type change

Suppress CP0002 for the intentional binary breaking change from
DirectProperty<FlyoutBase, bool> to StyledProperty<bool>.

* Pre-register owning control as flyout placement target

When Button.Flyout or SplitButton.Flyout is set, the owning control
now registers itself as the default placement target via an internal
SetDefaultPlacementTarget method. This allows IsOpen = true to work
on first use without a prior ShowAt call, addressing review feedback
from MrJul.

* Remove TwoWay default binding mode from IsOpenProperty

Follow Avalonia convention: Popup.IsOpen and ToolTip.IsOpen use the
default OneWay binding mode. TwoWay is reserved for input controls.
Users opt in with Mode=TwoWay when needed.
pull/20942/head
Nathan Nguyen 5 days ago
committed by GitHub
parent
commit
3d1fabed15
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 12
      api/Avalonia.nupkg.xml
  2. 3
      src/Avalonia.Controls/Button.cs
  3. 17
      src/Avalonia.Controls/Flyouts/FlyoutBase.cs
  4. 101
      src/Avalonia.Controls/Flyouts/PopupFlyoutBase.cs
  5. 3
      src/Avalonia.Controls/SplitButton/SplitButton.cs
  6. 218
      tests/Avalonia.Controls.UnitTests/FlyoutTests.cs

12
api/Avalonia.nupkg.xml

@ -5545,4 +5545,16 @@
<Left>baseline/Avalonia/lib/netstandard2.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/netstandard2.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>F:Avalonia.Controls.Primitives.FlyoutBase.IsOpenProperty</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Controls.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>F:Avalonia.Controls.Primitives.FlyoutBase.IsOpenProperty</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Controls.dll</Right>
</Suppression>
</Suppressions>

3
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();
}
}

17
src/Avalonia.Controls/Flyouts/FlyoutBase.cs

@ -7,9 +7,8 @@ namespace Avalonia.Controls.Primitives
/// <summary>
/// Defines the <see cref="IsOpen"/> property
/// </summary>
public static readonly DirectProperty<FlyoutBase, bool> IsOpenProperty =
AvaloniaProperty.RegisterDirect<FlyoutBase, bool>(nameof(IsOpen),
x => x.IsOpen);
public static readonly StyledProperty<bool> IsOpenProperty =
AvaloniaProperty.Register<FlyoutBase, bool>(nameof(IsOpen));
/// <summary>
/// Defines the <see cref="Target"/> property
@ -23,19 +22,23 @@ namespace Avalonia.Controls.Primitives
public static readonly AttachedProperty<FlyoutBase?> AttachedFlyoutProperty =
AvaloniaProperty.RegisterAttached<FlyoutBase, Control, FlyoutBase?>("AttachedFlyout", null);
private bool _isOpen;
private Control? _target;
public event EventHandler? Opened;
public event EventHandler? Closed;
/// <summary>
/// Gets whether this Flyout is currently Open
/// Gets or sets whether this Flyout is currently open.
/// </summary>
/// <remarks>
/// Setting this property to <c>true</c> will show the flyout at the last known
/// placement target. If no target has been set via <see cref="ShowAt"/>,
/// setting this to <c>true</c> will have no effect.
/// </remarks>
public bool IsOpen
{
get => _isOpen;
protected set => SetAndRaise(IsOpenProperty, ref _isOpen, value);
get => GetValue(IsOpenProperty);
set => SetValue(IsOpenProperty, value);
}
/// <summary>

101
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<IPopupHost?>? _popupHostChangedHandler;
private bool _isOpen;
private bool _ignoreIsOpenChanged;
private Control? _lastPlacementTarget;
static PopupFlyoutBase()
{
IsOpenProperty.Changed.AddClassHandler<PopupFlyoutBase>(
(x, e) => x.IsOpenChanged((AvaloniaPropertyChangedEventArgs<bool>)e));
Control.ContextFlyoutProperty.Changed.Subscribe(OnContextFlyoutPropertyChanged);
}
@ -175,6 +180,16 @@ namespace Avalonia.Controls.Primitives
public event EventHandler<CancelEventArgs>? Closing;
public event EventHandler? Opening;
/// <summary>
/// Pre-registers a control as the default placement target for this flyout.
/// Used by owning controls (e.g. <see cref="Button"/>) so that setting
/// <see cref="FlyoutBase.IsOpen"/> to <c>true</c> works on first use.
/// </summary>
internal void SetDefaultPlacementTarget(Control? target)
{
_lastPlacementTarget = target;
}
/// <summary>
/// Shows the Flyout at the given Control
/// </summary>
@ -205,7 +220,7 @@ namespace Avalonia.Controls.Primitives
/// <returns>True, if action was handled</returns>
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)
@ -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<bool> 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;

3
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();
}

218
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:
@ -682,6 +880,26 @@ namespace Avalonia.Controls.UnitTests
{
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

Loading…
Cancel
Save