diff --git a/samples/ControlCatalog/Pages/ExpanderPage.xaml b/samples/ControlCatalog/Pages/ExpanderPage.xaml index f0a80fd7ab..b5a2e6cdd0 100644 --- a/samples/ControlCatalog/Pages/ExpanderPage.xaml +++ b/samples/ControlCatalog/Pages/ExpanderPage.xaml @@ -52,6 +52,24 @@ Rounded + + + Expanded content + + + + + Expanded content + + diff --git a/samples/ControlCatalog/Pages/ExpanderPage.xaml.cs b/samples/ControlCatalog/Pages/ExpanderPage.xaml.cs index e8a080899a..98a494e533 100644 --- a/samples/ControlCatalog/Pages/ExpanderPage.xaml.cs +++ b/samples/ControlCatalog/Pages/ExpanderPage.xaml.cs @@ -10,6 +10,12 @@ namespace ControlCatalog.Pages { this.InitializeComponent(); DataContext = new ExpanderPageViewModel(); + + var CollapsingDisabledExpander = this.Get("CollapsingDisabledExpander"); + var ExpandingDisabledExpander = this.Get("ExpandingDisabledExpander"); + + CollapsingDisabledExpander.Collapsing += (s, e) => { e.Handled = true; }; + ExpandingDisabledExpander.Expanding += (s, e) => { e.Handled = true; }; } private void InitializeComponent() diff --git a/src/Avalonia.Controls/Expander.cs b/src/Avalonia.Controls/Expander.cs index 3ba99d8a67..65227a826a 100644 --- a/src/Avalonia.Controls/Expander.cs +++ b/src/Avalonia.Controls/Expander.cs @@ -1,7 +1,11 @@ +using System; using System.Threading; using Avalonia.Animation; using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Interactivity; +using Avalonia.Threading; namespace Avalonia.Controls { @@ -37,12 +41,24 @@ namespace Avalonia.Controls [PseudoClasses(":expanded", ":up", ":down", ":left", ":right")] public class Expander : HeaderedContentControl { + /// + /// Defines the property. + /// public static readonly StyledProperty ContentTransitionProperty = - AvaloniaProperty.Register(nameof(ContentTransition)); + AvaloniaProperty.Register( + nameof(ContentTransition)); + /// + /// Defines the property. + /// public static readonly StyledProperty ExpandDirectionProperty = - AvaloniaProperty.Register(nameof(ExpandDirection), ExpandDirection.Down); + AvaloniaProperty.Register( + nameof(ExpandDirection), + ExpandDirection.Down); + /// + /// Defines the property. + /// public static readonly DirectProperty IsExpandedProperty = AvaloniaProperty.RegisterDirect( nameof(IsExpanded), @@ -50,47 +66,206 @@ namespace Avalonia.Controls (o, v) => o.IsExpanded = v, defaultBindingMode: Data.BindingMode.TwoWay); + /// + /// Defines the event. + /// + public static readonly RoutedEvent CollapsedEvent = + RoutedEvent.Register( + nameof(Collapsed), + RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent CollapsingEvent = + RoutedEvent.Register( + nameof(Collapsing), + RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent ExpandedEvent = + RoutedEvent.Register( + nameof(Expanded), + RoutingStrategies.Bubble); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent ExpandingEvent = + RoutedEvent.Register( + nameof(Expanding), + RoutingStrategies.Bubble); + + private bool _ignorePropertyChanged = false; private bool _isExpanded; private CancellationTokenSource? _lastTransitionCts; - static Expander() - { - IsExpandedProperty.Changed.AddClassHandler((x, e) => x.OnIsExpandedChanged(e)); - } - + /// + /// Initializes a new instance of the class. + /// public Expander() { - UpdatePseudoClasses(ExpandDirection); + UpdatePseudoClasses(); } + /// + /// Gets or sets the transition used when expanding or collapsing the content. + /// public IPageTransition? ContentTransition { get => GetValue(ContentTransitionProperty); set => SetValue(ContentTransitionProperty, value); } + /// + /// Gets or sets the direction in which the opens. + /// public ExpandDirection ExpandDirection { get => GetValue(ExpandDirectionProperty); set => SetValue(ExpandDirectionProperty, value); } + /// + /// Gets or sets a value indicating whether the + /// content area is open and visible. + /// public bool IsExpanded { - get { return _isExpanded; } - set - { - SetAndRaise(IsExpandedProperty, ref _isExpanded, value); - PseudoClasses.Set(":expanded", value); + get => _isExpanded; + set + { + // It is important here that IsExpanded is a direct property so events can be invoked + // BEFORE the property system gets notified of updated values. This is because events + // may be canceled by external code. + if (_isExpanded != value) + { + RoutedEventArgs eventArgs; + + if (value) + { + eventArgs = new RoutedEventArgs(ExpandingEvent, this); + OnExpanding(eventArgs); + } + else + { + eventArgs = new RoutedEventArgs(CollapsingEvent, this); + OnCollapsing(eventArgs); + } + + if (eventArgs.Handled) + { + // If the event was externally handled (canceled) we must still notify the value has changed. + // This property changed notification will update any external code observing this property that itself may have set the new value. + // We are essentially reverted any external state change along with ignoring the IsExpanded property set. + // Remember IsExpanded is usually controlled by a ToggleButton in the control theme. + _ignorePropertyChanged = true; + + RaisePropertyChanged( + IsExpandedProperty, + oldValue: value, + newValue: _isExpanded, + BindingPriority.LocalValue, + isEffectiveValue: true); + + _ignorePropertyChanged = false; + } + else + { + SetAndRaise(IsExpandedProperty, ref _isExpanded, value); + } + } } } - protected virtual async void OnIsExpandedChanged(AvaloniaPropertyChangedEventArgs e) + /// + /// Occurs after the content area has closed and only the header is visible. + /// + public event EventHandler? Collapsed + { + add => AddHandler(CollapsedEvent, value); + remove => RemoveHandler(CollapsedEvent, value); + } + + /// + /// Occurs as the content area is closing. + /// + /// + /// The event args property may be set to true to cancel the event + /// and keep the control open (expanded). + /// + public event EventHandler? Collapsing + { + add => AddHandler(CollapsingEvent, value); + remove => RemoveHandler(CollapsingEvent, value); + } + + /// + /// Occurs after the has opened to display both its header and content. + /// + public event EventHandler? Expanded + { + add => AddHandler(ExpandedEvent, value); + remove => RemoveHandler(ExpandedEvent, value); + } + + /// + /// Occurs as the content area is opening. + /// + /// + /// The event args property may be set to true to cancel the event + /// and keep the control closed (collapsed). + /// + public event EventHandler? Expanding + { + add => AddHandler(ExpandingEvent, value); + remove => RemoveHandler(ExpandingEvent, value); + } + + /// + /// Invoked just before the event. + /// + protected virtual void OnCollapsed(RoutedEventArgs eventArgs) + { + RaiseEvent(eventArgs); + } + + /// + /// Invoked just before the event. + /// + protected virtual void OnCollapsing(RoutedEventArgs eventArgs) + { + RaiseEvent(eventArgs); + } + + /// + /// Invoked just before the event. + /// + protected virtual void OnExpanded(RoutedEventArgs eventArgs) + { + RaiseEvent(eventArgs); + } + + /// + /// Invoked just before the event. + /// + protected virtual void OnExpanding(RoutedEventArgs eventArgs) + { + RaiseEvent(eventArgs); + } + + /// + /// Starts the content transition (if set) and invokes the + /// and events when completed. + /// + private async void StartContentTransition() { if (Content != null && ContentTransition != null && Presenter is Visual visualContent) { bool forward = ExpandDirection == ExpandDirection.Left || - ExpandDirection == ExpandDirection.Up; + ExpandDirection == ExpandDirection.Up; _lastTransitionCts?.Cancel(); _lastTransitionCts = new CancellationTokenSource(); @@ -104,24 +279,58 @@ namespace Avalonia.Controls await ContentTransition.Start(visualContent, null, forward, _lastTransitionCts.Token); } } + + // Expanded/Collapsed events are invoked asynchronously to ensure other events, + // such as Click, have time to complete first. + Dispatcher.UIThread.Post(() => + { + if (IsExpanded) + { + OnExpanded(new RoutedEventArgs(ExpandedEvent, this)); + } + else + { + OnCollapsed(new RoutedEventArgs(CollapsedEvent, this)); + } + }); } + /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); + if (_ignorePropertyChanged) + { + return; + } + if (change.Property == ExpandDirectionProperty) { - UpdatePseudoClasses(change.GetNewValue()); + UpdatePseudoClasses(); + } + else if (change.Property == IsExpandedProperty) + { + // Expanded/Collapsed will be raised once transitions are complete + StartContentTransition(); + + UpdatePseudoClasses(); } } - private void UpdatePseudoClasses(ExpandDirection d) + /// + /// Updates the visual state of the control by applying latest PseudoClasses. + /// + private void UpdatePseudoClasses() { - PseudoClasses.Set(":up", d == ExpandDirection.Up); - PseudoClasses.Set(":down", d == ExpandDirection.Down); - PseudoClasses.Set(":left", d == ExpandDirection.Left); - PseudoClasses.Set(":right", d == ExpandDirection.Right); + var expandDirection = ExpandDirection; + + PseudoClasses.Set(":up", expandDirection == ExpandDirection.Up); + PseudoClasses.Set(":down", expandDirection == ExpandDirection.Down); + PseudoClasses.Set(":left", expandDirection == ExpandDirection.Left); + PseudoClasses.Set(":right", expandDirection == ExpandDirection.Right); + + PseudoClasses.Set(":expanded", IsExpanded); } } } diff --git a/src/Avalonia.Controls/Flyouts/FlyoutPresenter.cs b/src/Avalonia.Controls/Flyouts/FlyoutPresenter.cs index 0f257224dd..8590974462 100644 --- a/src/Avalonia.Controls/Flyouts/FlyoutPresenter.cs +++ b/src/Avalonia.Controls/Flyouts/FlyoutPresenter.cs @@ -18,7 +18,7 @@ namespace Avalonia.Controls } } - base.OnKeyDown(e); + base.OnKeyDown(e); } } } diff --git a/src/Avalonia.Themes.Fluent/Controls/Expander.xaml b/src/Avalonia.Themes.Fluent/Controls/Expander.xaml index 3cf819c4b8..37e9b71185 100644 --- a/src/Avalonia.Themes.Fluent/Controls/Expander.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/Expander.xaml @@ -103,17 +103,18 @@ RenderTransformOrigin="50%,50%" Stretch="None" Stroke="{DynamicResource ExpanderChevronForeground}" - StrokeThickness="1" /> - - - + StrokeThickness="1"> + + + + - - -