AvaloniaUI / Avalonia.Xaml.Behaviors

Port of Windows UWP Xaml Behaviors for Avalonia Xaml.
MIT License
385 stars 46 forks source link

Support KeyModifiers in more Triggers and related issues #135

Closed fitdev closed 6 months ago

fitdev commented 1 year ago

This is a multi-point issue regarding adding support for KeyModifiers into more triggers (perhaps via a base class that has this functionality), as well as perhaps changing the visibility of some APIs, such that users can create derived classes of existing behaviors more easily.

After trying to figure out how do I get the state of modifier keys (like Control and Shift) in Avalonia, I realized that apparently the only way is to do it manually by monitoring InputElement.KeyDownEvent and InputElement.KeyUpEvent.

A side question: am I correct in the understanding that there is no general way to get their state via some static call, or through an instance of KeyboardDevice or some such things (for example in WinForms, one can use static Control.ModifierKeys)?

Back to the main issue though...

So, I decided I would combine ButtonClickEventTriggerBehavior (its KeyModifiers support) with RoutedEventTriggerBehavior in my RoutedEventTriggerBehavior-derived class. Unfortunately this approach did not work, because while OnAttachedToVisualTree and OnDetachedFromVisualTree are virtual and can be overridden, the needed AddHandler and RemoveHandler routines are private, and more importantly the Handler routine which actually calls the actions to be executed is also private, and yet this is the routine that needs to be modified such that actions will only execute if the specified ModifierKeys match.

So, then I had to basically write my own implementation of RoutedEventTriggerBehavior basically duplicating most of the code (which is really bad, hence one of the reasons for this issue) and adding relevant bits from ButtonClickEventTriggerBehavior to support KeyModifiers. Unfortunately this did not work, and this is my second question as part of the issue: for some reason InputElement.KeyDownEvent and InputElement.KeyUpEvent were never fired regardless of RoutingStrategies and handledEventsToo parameter in the interactive.AddHandler calls: interactive.AddHandler(InputElement.KeyDownEvent, Button_OnKeyDown, RoutingStrategies, true); So, any help as to why the implementation below does not work for capturing key events would be helpful:

/// <summary>
/// A behavior that listens for a <see cref="RoutedEvent"/> event on its source and executes its actions when that event is fired.
/// </summary>
public class RoutedEventTriggerBehaviorEx2 : Trigger<Interactive> {
  /// <summary>
  /// Identifies the <seealso cref="RoutedEvent"/> avalonia property.
  /// </summary>
  public static readonly StyledProperty<RoutedEvent?> RoutedEventProperty =
      AvaloniaProperty.Register<RoutedEventTriggerBehaviorEx2, RoutedEvent?>(nameof(RoutedEvent));

  /// <summary>
  /// Identifies the <seealso cref="RoutingStrategies"/> avalonia property.
  /// </summary>
  public static readonly StyledProperty<RoutingStrategies> RoutingStrategiesProperty =
      AvaloniaProperty.Register<RoutedEventTriggerBehaviorEx2, RoutingStrategies>(nameof(RoutingStrategies), RoutingStrategies.Direct | RoutingStrategies.Bubble);

  /// <summary>
  /// Identifies the <seealso cref="SourceInteractive"/> avalonia property.
  /// </summary>
  public static readonly StyledProperty<Interactive?> SourceInteractiveProperty =
      AvaloniaProperty.Register<RoutedEventTriggerBehaviorEx2, Interactive?>(nameof(SourceInteractive));

  bool _isInitialized;
  bool _isAttached;

  /// <summary>
  /// Gets or sets routing event to listen for. This is a avalonia property.
  /// </summary>
  public RoutedEvent? RoutedEvent {
    get => GetValue(RoutedEventProperty);
    set => SetValue(RoutedEventProperty, value);
  }

  /// <summary>
  /// Gets or sets the routing event <see cref="RoutingStrategies"/>. This is a avalonia property.
  /// </summary>
  public RoutingStrategies RoutingStrategies {
    get => GetValue(RoutingStrategiesProperty);
    set => SetValue(RoutingStrategiesProperty, value);
  }

  /// <summary>
  /// Gets or sets the source object from which this behavior listens for events.
  /// If <seealso cref="SourceInteractive"/> is not set, the source will default to <seealso cref="Behavior.AssociatedObject"/>. This is a avalonia property.
  /// </summary>
  [ResolveByName]
  public Interactive? SourceInteractive {
    get => GetValue(SourceInteractiveProperty);
    set => SetValue(SourceInteractiveProperty, value);
  }

  KeyModifiers _savedKeyModifiers = Avalonia.Input.KeyModifiers.None;

  /// <summary>
  /// Identifies the <seealso cref="KeyModifiers"/> avalonia property.
  /// </summary>
  public static readonly StyledProperty<KeyModifiers?> KeyModifiersProperty =
      AvaloniaProperty.Register<RoutedEventTriggerBehaviorEx2, KeyModifiers?>(nameof(KeyModifiers));

  /// <summary>
  /// Gets or sets the required key modifiers to execute <see cref="Button.ClickEvent"/> event handler. This is a avalonia property.
  /// </summary>
  public KeyModifiers? KeyModifiers {
    get => GetValue(KeyModifiersProperty);
    set => SetValue(KeyModifiersProperty, value);
  }

  static RoutedEventTriggerBehaviorEx2() {
    RoutedEventProperty.Changed.Subscribe(
        new AnonymousObserver<AvaloniaPropertyChangedEventArgs<RoutedEvent?>>(OnValueChanged));

    RoutingStrategiesProperty.Changed.Subscribe(
        new AnonymousObserver<AvaloniaPropertyChangedEventArgs<RoutingStrategies>>(OnValueChanged));

    SourceInteractiveProperty.Changed.Subscribe(
        new AnonymousObserver<AvaloniaPropertyChangedEventArgs<Interactive?>>(OnValueChanged));

    KeyModifiersProperty.Changed.Subscribe(
        new AnonymousObserver<AvaloniaPropertyChangedEventArgs<KeyModifiers?>>(OnValueChanged));
  }

  static void OnValueChanged(AvaloniaPropertyChangedEventArgs args) {
    if (args.Sender is not RoutedEventTriggerBehaviorEx2 behavior || behavior.AssociatedObject is null) {
      return;
    }

    if (behavior._isInitialized && behavior._isAttached) {
      behavior.RemoveHandler();
      behavior.AddHandler();
    }
  }

  /// <inheritdoc />
  protected override void OnAttachedToVisualTree() {
    _isAttached = true;
    AddHandler();
  }

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

  void AddHandler() {
    var interactive = ComputeResolvedSourceInteractive();
    if (interactive is { } && RoutedEvent is { }) {
      if (KeyModifiers is not null) {
        Debug.WriteLine("AddKeyHandlers");
        interactive.AddHandler(InputElement.KeyDownEvent, Button_OnKeyDown, RoutingStrategies, true);
        interactive.AddHandler(InputElement.KeyUpEvent, Button_OnKeyUp, RoutingStrategies, true);
      }
      interactive.AddHandler(RoutedEvent, Handler, RoutingStrategies);
      _isInitialized = true;
    }
  }

  void RemoveHandler() {
    var interactive = ComputeResolvedSourceInteractive();
    if (interactive is { } && RoutedEvent is { } && _isInitialized) {
      if (KeyModifiers is not null) {
        interactive.RemoveHandler(InputElement.KeyDownEvent, Button_OnKeyDown);
        interactive.RemoveHandler(InputElement.KeyUpEvent, Button_OnKeyUp);
      }
      interactive.RemoveHandler(RoutedEvent, Handler);
      _isInitialized = false;
    }
  }

  void Button_OnKeyDown(object? sender, KeyEventArgs e) {
    _savedKeyModifiers = e.KeyModifiers;
    Debug.WriteLine(_savedKeyModifiers);
  }

  void Button_OnKeyUp(object? sender, KeyEventArgs e) {
    _savedKeyModifiers = Avalonia.Input.KeyModifiers.None;
    Debug.WriteLine("NONE");
  }

  Interactive? ComputeResolvedSourceInteractive() => GetValue(SourceInteractiveProperty) is { } ? SourceInteractive : AssociatedObject;

  void Handler(object? sender, RoutedEventArgs e) {
    var interactive = ComputeResolvedSourceInteractive();
    var keys = KeyModifiers;
    if (interactive is { } && (!keys.HasValue || keys.Value == _savedKeyModifiers)) Interaction.ExecuteActions(interactive, Actions, e);
  }

}

UPDATE: I have got it to work by subscribing to TopLevel instance instead:

        var tl = TopLevel.GetTopLevel(interactive);
        if (tl is not null) {
          tl.AddHandler(InputElement.KeyDownEvent, Button_OnKeyDown, RoutingStrategies.Tunnel, true);
          tl.AddHandler(InputElement.KeyUpEvent, Button_OnKeyUp, RoutingStrategies.Tunnel, true);
        }

However, now I see my Button_OnKeyDown handler invoked many, many times as long as the modifier key remains pressed, which is less than optimal, is there a way around it?