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

Add Expander Events #9555

Merged
merged 7 commits into from
Nov 30, 2022
Merged
Show file tree
Hide file tree
Changes from 6 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
18 changes: 18 additions & 0 deletions samples/ControlCatalog/Pages/ExpanderPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@
</StackPanel>
</Expander>
<CheckBox IsChecked="{Binding Rounded}">Rounded</CheckBox>
<Expander x:Name="CollapsingDisabledExpander"
Header="Collapsing Disabled"
IsExpanded="True"
ExpandDirection="Down"
CornerRadius="{Binding CornerRadius}">
<StackPanel>
<TextBlock>Expanded content</TextBlock>
</StackPanel>
</Expander>
<Expander x:Name="ExpandingDisabledExpander"
Header="Expanding Disabled"
IsExpanded="False"
ExpandDirection="Down"
CornerRadius="{Binding CornerRadius}">
<StackPanel>
<TextBlock>Expanded content</TextBlock>
</StackPanel>
</Expander>
</StackPanel>
</StackPanel>
</UserControl>
6 changes: 6 additions & 0 deletions samples/ControlCatalog/Pages/ExpanderPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ public ExpanderPage()
{
this.InitializeComponent();
DataContext = new ExpanderPageViewModel();

var CollapsingDisabledExpander = this.Get<Expander>("CollapsingDisabledExpander");
var ExpandingDisabledExpander = this.Get<Expander>("ExpandingDisabledExpander");

CollapsingDisabledExpander.Collapsing += (s, e) => { e.Handled = true; };
ExpandingDisabledExpander.Expanding += (s, e) => { e.Handled = true; };
}

private void InitializeComponent()
Expand Down
251 changes: 230 additions & 21 deletions src/Avalonia.Controls/Expander.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down Expand Up @@ -37,60 +41,231 @@ public enum ExpandDirection
[PseudoClasses(":expanded", ":up", ":down", ":left", ":right")]
public class Expander : HeaderedContentControl
{
/// <summary>
/// Defines the <see cref="ContentTransition"/> property.
/// </summary>
public static readonly StyledProperty<IPageTransition?> ContentTransitionProperty =
AvaloniaProperty.Register<Expander, IPageTransition?>(nameof(ContentTransition));
AvaloniaProperty.Register<Expander, IPageTransition?>(
nameof(ContentTransition));

/// <summary>
/// Defines the <see cref="ExpandDirection"/> property.
/// </summary>
public static readonly StyledProperty<ExpandDirection> ExpandDirectionProperty =
AvaloniaProperty.Register<Expander, ExpandDirection>(nameof(ExpandDirection), ExpandDirection.Down);
AvaloniaProperty.Register<Expander, ExpandDirection>(
nameof(ExpandDirection),
ExpandDirection.Down);

/// <summary>
/// Defines the <see cref="IsExpanded"/> property.
/// </summary>
public static readonly DirectProperty<Expander, bool> IsExpandedProperty =
AvaloniaProperty.RegisterDirect<Expander, bool>(
nameof(IsExpanded),
o => o.IsExpanded,
(o, v) => o.IsExpanded = v,
defaultBindingMode: Data.BindingMode.TwoWay);

/// <summary>
/// Defines the <see cref="Collapsed"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> CollapsedEvent =
RoutedEvent.Register<Expander, RoutedEventArgs>(
nameof(Collapsed),
RoutingStrategies.Bubble);

/// <summary>
/// Defines the <see cref="Collapsing"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> CollapsingEvent =
maxkatz6 marked this conversation as resolved.
Show resolved Hide resolved
RoutedEvent.Register<Expander, RoutedEventArgs>(
nameof(Collapsing),
RoutingStrategies.Bubble);

/// <summary>
/// Defines the <see cref="Expanded"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> ExpandedEvent =
RoutedEvent.Register<Expander, RoutedEventArgs>(
nameof(Expanded),
RoutingStrategies.Bubble);

/// <summary>
/// Defines the <see cref="Expanding"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> ExpandingEvent =
robloo marked this conversation as resolved.
Show resolved Hide resolved
RoutedEvent.Register<Expander, RoutedEventArgs>(
nameof(Expanding),
RoutingStrategies.Bubble);

private bool _ignorePropertyChanged = false;
private bool _isExpanded;
private CancellationTokenSource? _lastTransitionCts;

static Expander()
{
IsExpandedProperty.Changed.AddClassHandler<Expander>((x, e) => x.OnIsExpandedChanged(e));
}

/// <summary>
/// Initializes a new instance of the <see cref="Expander"/> class.
/// </summary>
public Expander()
{
UpdatePseudoClasses(ExpandDirection);
UpdatePseudoClasses();
}

/// <summary>
/// Gets or sets the transition used when expanding or collapsing the content.
/// </summary>
public IPageTransition? ContentTransition
{
get => GetValue(ContentTransitionProperty);
set => SetValue(ContentTransitionProperty, value);
}

/// <summary>
/// Gets or sets the direction in which the <see cref="Expander"/> opens.
/// </summary>
public ExpandDirection ExpandDirection
{
get => GetValue(ExpandDirectionProperty);
set => SetValue(ExpandDirectionProperty, value);
}

/// <summary>
/// Gets or sets a value indicating whether the <see cref="Expander"/>
/// content area is open and visible.
/// </summary>
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);
Comment on lines +166 to +171
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is kind of code I generally would prefer to avoid, as this API changes the property value without actually setting the value in conventional way.
Although, it also seems to the most convenient way to do these events for the users who would use this control.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tend to agree with you. I made sure to document reasoning well in case there are different suggestions. However, while it looks bad in general, I can't think of any downsides. It should be perfectly harmless. It's just one of those bad special cases.

That said, I did think of a way around this. It requires removing the toggle button entirely from the control template. State would then be managed entirely within the Expander itself -- which is probably a fundamentally better design to begin with. I think WPF was also being lazy and didn't want to duplicate a lot of code, as well.

Im not sure how much code duplication would be required to remove the toggle button from the default template. Pointer press and click would have to be entirely managed by the Expander. Keyboard navigation as well. It would probably be similar to what we did for SplitButton.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll leave this open and we can always come back and change things in the future. It shouldn't be a bad breaking change except for those writing their own control themes (rare).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It probably will be less customizable without the toggle button


_ignorePropertyChanged = false;
}
else
{
SetAndRaise(IsExpandedProperty, ref _isExpanded, value);
}
}
}
}

protected virtual async void OnIsExpandedChanged(AvaloniaPropertyChangedEventArgs e)
/// <summary>
/// Occurs after the content area has closed and only the header is visible.
/// </summary>
public event EventHandler<RoutedEventArgs>? Collapsed
{
add => AddHandler(CollapsedEvent, value);
remove => RemoveHandler(CollapsedEvent, value);
}

/// <summary>
/// Occurs as the content area is closing.
/// </summary>
/// <remarks>
/// The event args <see cref="RoutedEventArgs.Handled"/> property may be set to true to cancel the event
/// and keep the control open (expanded).
/// </remarks>
public event EventHandler<RoutedEventArgs>? Collapsing
{
add => AddHandler(CollapsingEvent, value);
remove => RemoveHandler(CollapsingEvent, value);
}

/// <summary>
/// Occurs after the <see cref="Expander"/> has opened to display both its header and content.
/// </summary>
public event EventHandler<RoutedEventArgs>? Expanded
{
add => AddHandler(ExpandedEvent, value);
remove => RemoveHandler(ExpandedEvent, value);
}

/// <summary>
/// Occurs as the content area is opening.
/// </summary>
/// <remarks>
/// The event args <see cref="RoutedEventArgs.Handled"/> property may be set to true to cancel the event
/// and keep the control closed (collapsed).
/// </remarks>
public event EventHandler<RoutedEventArgs>? Expanding
{
add => AddHandler(ExpandingEvent, value);
remove => RemoveHandler(ExpandingEvent, value);
}

/// <summary>
/// Invoked just before the <see cref="Collapsed"/> event.
/// </summary>
protected virtual void OnCollapsed(RoutedEventArgs eventArgs)
{
RaiseEvent(eventArgs);
}

/// <summary>
/// Invoked just before the <see cref="Collapsing"/> event.
/// </summary>
protected virtual void OnCollapsing(RoutedEventArgs eventArgs)
{
RaiseEvent(eventArgs);
}

/// <summary>
/// Invoked just before the <see cref="Expanded"/> event.
/// </summary>
protected virtual void OnExpanded(RoutedEventArgs eventArgs)
{
RaiseEvent(eventArgs);
}

/// <summary>
/// Invoked just before the <see cref="Expanding"/> event.
/// </summary>
protected virtual void OnExpanding(RoutedEventArgs eventArgs)
{
RaiseEvent(eventArgs);
}

/// <summary>
/// Starts the content transition (if set) and invokes the <see cref="Expanded"/>
/// and <see cref="Collapsed"/> events when completed.
/// </summary>
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();
Expand All @@ -104,24 +279,58 @@ protected virtual async void OnIsExpandedChanged(AvaloniaPropertyChangedEventArg
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));
}
});
}

/// <inheritdoc/>
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);

if (_ignorePropertyChanged)
{
return;
}

if (change.Property == ExpandDirectionProperty)
{
UpdatePseudoClasses(change.GetNewValue<ExpandDirection>());
UpdatePseudoClasses();
}
else if (change.Property == IsExpandedProperty)
{
// Expanded/Collapsed will be raised once transitions are complete
StartContentTransition();

UpdatePseudoClasses();
}
}

private void UpdatePseudoClasses(ExpandDirection d)
/// <summary>
/// Updates the visual state of the control by applying latest PseudoClasses.
/// </summary>
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);
}
}
}
2 changes: 1 addition & 1 deletion src/Avalonia.Controls/Flyouts/FlyoutPresenter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ protected override void OnKeyDown(KeyEventArgs e)
}
}

base.OnKeyDown(e);
base.OnKeyDown(e);
}
}
}
Loading