Browse Source

Add Scrollbar Context Menu (#19413)

* Scroll Bar Context Menu V1

* Add resource files

* Use InvariantResources file

* Add Unit Tests

* Refactor to XAML

* Add pointer type

* Add tests
pull/21651/head
Tim Miller 3 months ago
committed by GitHub
parent
commit
9547beef87
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      src/Avalonia.Base/Input/ContextRequestedEventArgs.cs
  2. 111
      src/Avalonia.Controls/Primitives/ScrollBar.cs
  3. 28
      src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml
  4. 13
      src/Avalonia.Themes.Fluent/Strings/InvariantResources.xaml
  5. 28
      src/Avalonia.Themes.Simple/Controls/ScrollBar.xaml
  6. 222
      tests/Avalonia.Controls.UnitTests/Primitives/ScrollBarTests.cs

2
src/Avalonia.Base/Input/ContextRequestedEventArgs.cs

@ -67,5 +67,7 @@ namespace Avalonia.Input
}
internal bool IsHolding => _holdRoutedEventArgs != null;
internal PointerType? PointerType => _pointerEventArgs?.Pointer.Type;
}
}

111
src/Avalonia.Controls/Primitives/ScrollBar.cs

@ -5,6 +5,7 @@ using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Threading;
using Avalonia.Controls.Metadata;
using Avalonia.Controls.Templates;
using Avalonia.Automation.Peers;
using Avalonia.VisualTree;
using Avalonia.Reactive;
@ -86,6 +87,7 @@ namespace Avalonia.Controls.Primitives
private CompositeDisposable? _ownerSubscriptions;
private ScrollViewer? _owner;
private bool _isDragging;
private Point _lastRightClickPosition;
/// <summary>
/// Initializes static members of the <see cref="ScrollBar"/> class.
@ -105,6 +107,7 @@ namespace Avalonia.Controls.Primitives
public ScrollBar()
{
UpdatePseudoClasses(Orientation);
ContextRequested += OnContextRequested;
}
/// <summary>
@ -521,5 +524,113 @@ namespace Avalonia.Controls.Primitives
PseudoClasses.Set(":vertical", o == Orientation.Vertical);
PseudoClasses.Set(":horizontal", o == Orientation.Horizontal);
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
{
_lastRightClickPosition = e.GetPosition(this);
}
}
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (e.PointerType is PointerType.Touch or PointerType.Pen)
{
e.Handled = true;
return;
}
if (e.TryGetPosition(this, out var position))
{
_lastRightClickPosition = position;
}
}
/// <summary>
/// Scrolls to the location at which the context menu was most recently requested.
/// </summary>
public void ScrollHere()
{
if (Track is not { } track)
{
return;
}
var trackLength = Orientation == Orientation.Vertical ? track.Bounds.Height : track.Bounds.Width;
var thumbLength = Orientation == Orientation.Vertical ? track.Thumb?.Bounds.Height ?? 0 : track.Thumb?.Bounds.Width ?? 0;
var clickPosition = Orientation == Orientation.Vertical ? _lastRightClickPosition.Y : _lastRightClickPosition.X;
if (trackLength > thumbLength)
{
var ratio = clickPosition / trackLength;
var range = Maximum - Minimum;
var value = Minimum + (ratio * range);
SetCurrentValue(ValueProperty, Math.Max(Minimum, Math.Min(Maximum, value)));
OnScroll(ScrollEventType.ThumbTrack);
}
}
/// <summary>
/// Scrolls to the top (or left edge) of the scrollbar by setting <see cref="RangeBase.Value"/> to <see cref="RangeBase.Minimum"/>.
/// </summary>
public void ScrollToHome()
{
SetCurrentValue(ValueProperty, Minimum);
OnScroll(ScrollEventType.SmallDecrement);
}
/// <summary>
/// Scrolls to the bottom (or right edge) of the scrollbar by setting <see cref="RangeBase.Value"/> to <see cref="RangeBase.Maximum"/>.
/// </summary>
public void ScrollToEnd()
{
SetCurrentValue(ValueProperty, Maximum);
OnScroll(ScrollEventType.SmallIncrement);
}
/// <summary>
/// Scrolls up by one <see cref="RangeBase.LargeChange"/> (a page) on a vertical scrollbar.
/// </summary>
public void PageUp() => LargeDecrement();
/// <summary>
/// Scrolls down by one <see cref="RangeBase.LargeChange"/> (a page) on a vertical scrollbar.
/// </summary>
public void PageDown() => LargeIncrement();
/// <summary>
/// Scrolls left by one <see cref="RangeBase.LargeChange"/> (a page) on a horizontal scrollbar.
/// </summary>
public void PageLeft() => LargeDecrement();
/// <summary>
/// Scrolls right by one <see cref="RangeBase.LargeChange"/> (a page) on a horizontal scrollbar.
/// </summary>
public void PageRight() => LargeIncrement();
/// <summary>
/// Scrolls up by one <see cref="RangeBase.SmallChange"/> on a vertical scrollbar.
/// </summary>
public void LineUp() => SmallDecrement();
/// <summary>
/// Scrolls down by one <see cref="RangeBase.SmallChange"/> on a vertical scrollbar.
/// </summary>
public void LineDown() => SmallIncrement();
/// <summary>
/// Scrolls left by one <see cref="RangeBase.SmallChange"/> on a horizontal scrollbar.
/// </summary>
public void LineLeft() => SmallDecrement();
/// <summary>
/// Scrolls right by one <see cref="RangeBase.SmallChange"/> on a horizontal scrollbar.
/// </summary>
public void LineRight() => SmallIncrement();
private Track? Track => this.GetTemplateDescendants().OfType<Track>().FirstOrDefault();
}
}

28
src/Avalonia.Themes.Fluent/Controls/ScrollBar.xaml

@ -18,6 +18,32 @@
</Border>
</Design.PreviewWith>
<MenuFlyout x:Key="VerticalScrollBarContextFlyout">
<MenuItem Header="{DynamicResource StringScrollBarScrollHere}" Command="{Binding $parent[ScrollBar].ScrollHere}" AutomationProperties.AutomationId="ScrollHere" />
<Separator />
<MenuItem Header="{DynamicResource StringScrollBarTop}" Command="{Binding $parent[ScrollBar].ScrollToHome}" AutomationProperties.AutomationId="Top" />
<MenuItem Header="{DynamicResource StringScrollBarBottom}" Command="{Binding $parent[ScrollBar].ScrollToEnd}" AutomationProperties.AutomationId="Bottom" />
<Separator />
<MenuItem Header="{DynamicResource StringScrollBarPageUp}" Command="{Binding $parent[ScrollBar].PageUp}" AutomationProperties.AutomationId="PageUp" />
<MenuItem Header="{DynamicResource StringScrollBarPageDown}" Command="{Binding $parent[ScrollBar].PageDown}" AutomationProperties.AutomationId="PageDown" />
<Separator />
<MenuItem Header="{DynamicResource StringScrollBarScrollUp}" Command="{Binding $parent[ScrollBar].LineUp}" AutomationProperties.AutomationId="ScrollUp" />
<MenuItem Header="{DynamicResource StringScrollBarScrollDown}" Command="{Binding $parent[ScrollBar].LineDown}" AutomationProperties.AutomationId="ScrollDown" />
</MenuFlyout>
<MenuFlyout x:Key="HorizontalScrollBarContextFlyout">
<MenuItem Header="{DynamicResource StringScrollBarScrollHere}" Command="{Binding $parent[ScrollBar].ScrollHere}" AutomationProperties.AutomationId="ScrollHere" />
<Separator />
<MenuItem Header="{DynamicResource StringScrollBarLeftEdge}" Command="{Binding $parent[ScrollBar].ScrollToHome}" AutomationProperties.AutomationId="LeftEdge" />
<MenuItem Header="{DynamicResource StringScrollBarRightEdge}" Command="{Binding $parent[ScrollBar].ScrollToEnd}" AutomationProperties.AutomationId="RightEdge" />
<Separator />
<MenuItem Header="{DynamicResource StringScrollBarPageLeft}" Command="{Binding $parent[ScrollBar].PageLeft}" AutomationProperties.AutomationId="PageLeft" />
<MenuItem Header="{DynamicResource StringScrollBarPageRight}" Command="{Binding $parent[ScrollBar].PageRight}" AutomationProperties.AutomationId="PageRight" />
<Separator />
<MenuItem Header="{DynamicResource StringScrollBarScrollLeft}" Command="{Binding $parent[ScrollBar].LineLeft}" AutomationProperties.AutomationId="ScrollLeft" />
<MenuItem Header="{DynamicResource StringScrollBarScrollRight}" Command="{Binding $parent[ScrollBar].LineRight}" AutomationProperties.AutomationId="ScrollRight" />
</MenuFlyout>
<ControlTheme x:Key="FluentScrollBarThumb" TargetType="Thumb">
<Setter Property="Background" Value="{DynamicResource ScrollBarPanningThumbBackground}"/>
<Setter Property="Template">
@ -111,6 +137,7 @@
<Setter Property="BorderBrush" Value="{DynamicResource ScrollBarBorderBrush}" />
<Style Selector="^:vertical">
<Setter Property="ContextFlyout" Value="{StaticResource VerticalScrollBarContextFlyout}" />
<Setter Property="Template">
<ControlTemplate>
<Grid x:Name="Root">
@ -194,6 +221,7 @@
</Style>
<Style Selector="^:horizontal">
<Setter Property="ContextFlyout" Value="{StaticResource HorizontalScrollBarContextFlyout}" />
<Setter Property="Template">
<ControlTemplate>
<Grid x:Name="Root">

13
src/Avalonia.Themes.Fluent/Strings/InvariantResources.xaml

@ -25,5 +25,18 @@
<x:String x:Key="StringManagedFileChooserOverwritePromptFileAlreadyExistsText">{0} already exists. Do you want to replace it?</x:String>
<x:String x:Key="StringManagedFileChooserOverwritePromptConfirmText">Yes</x:String>
<x:String x:Key="StringManagedFileChooserOverwritePromptCancelText">No</x:String>
<x:String x:Key="StringScrollBarScrollHere">Scroll Here</x:String>
<x:String x:Key="StringScrollBarTop">Top</x:String>
<x:String x:Key="StringScrollBarBottom">Bottom</x:String>
<x:String x:Key="StringScrollBarPageUp">Page Up</x:String>
<x:String x:Key="StringScrollBarPageDown">Page Down</x:String>
<x:String x:Key="StringScrollBarScrollUp">Scroll Up</x:String>
<x:String x:Key="StringScrollBarScrollDown">Scroll Down</x:String>
<x:String x:Key="StringScrollBarLeftEdge">Left Edge</x:String>
<x:String x:Key="StringScrollBarRightEdge">Right Edge</x:String>
<x:String x:Key="StringScrollBarPageLeft">Page Left</x:String>
<x:String x:Key="StringScrollBarPageRight">Page Right</x:String>
<x:String x:Key="StringScrollBarScrollLeft">Scroll Left</x:String>
<x:String x:Key="StringScrollBarScrollRight">Scroll Right</x:String>
</ResourceDictionary>

28
src/Avalonia.Themes.Simple/Controls/ScrollBar.xaml

@ -1,11 +1,38 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:ClassModifier="internal">
<MenuFlyout x:Key="SimpleVerticalScrollBarContextFlyout">
<MenuItem Header="{DynamicResource StringScrollBarScrollHere}" Command="{Binding $parent[ScrollBar].ScrollHere}" AutomationProperties.AutomationId="ScrollHere" />
<Separator />
<MenuItem Header="{DynamicResource StringScrollBarTop}" Command="{Binding $parent[ScrollBar].ScrollToHome}" AutomationProperties.AutomationId="Top" />
<MenuItem Header="{DynamicResource StringScrollBarBottom}" Command="{Binding $parent[ScrollBar].ScrollToEnd}" AutomationProperties.AutomationId="Bottom" />
<Separator />
<MenuItem Header="{DynamicResource StringScrollBarPageUp}" Command="{Binding $parent[ScrollBar].PageUp}" AutomationProperties.AutomationId="PageUp" />
<MenuItem Header="{DynamicResource StringScrollBarPageDown}" Command="{Binding $parent[ScrollBar].PageDown}" AutomationProperties.AutomationId="PageDown" />
<Separator />
<MenuItem Header="{DynamicResource StringScrollBarScrollUp}" Command="{Binding $parent[ScrollBar].LineUp}" AutomationProperties.AutomationId="ScrollUp" />
<MenuItem Header="{DynamicResource StringScrollBarScrollDown}" Command="{Binding $parent[ScrollBar].LineDown}" AutomationProperties.AutomationId="ScrollDown" />
</MenuFlyout>
<MenuFlyout x:Key="SimpleHorizontalScrollBarContextFlyout">
<MenuItem Header="{DynamicResource StringScrollBarScrollHere}" Command="{Binding $parent[ScrollBar].ScrollHere}" AutomationProperties.AutomationId="ScrollHere" />
<Separator />
<MenuItem Header="{DynamicResource StringScrollBarLeftEdge}" Command="{Binding $parent[ScrollBar].ScrollToHome}" AutomationProperties.AutomationId="LeftEdge" />
<MenuItem Header="{DynamicResource StringScrollBarRightEdge}" Command="{Binding $parent[ScrollBar].ScrollToEnd}" AutomationProperties.AutomationId="RightEdge" />
<Separator />
<MenuItem Header="{DynamicResource StringScrollBarPageLeft}" Command="{Binding $parent[ScrollBar].PageLeft}" AutomationProperties.AutomationId="PageLeft" />
<MenuItem Header="{DynamicResource StringScrollBarPageRight}" Command="{Binding $parent[ScrollBar].PageRight}" AutomationProperties.AutomationId="PageRight" />
<Separator />
<MenuItem Header="{DynamicResource StringScrollBarScrollLeft}" Command="{Binding $parent[ScrollBar].LineLeft}" AutomationProperties.AutomationId="ScrollLeft" />
<MenuItem Header="{DynamicResource StringScrollBarScrollRight}" Command="{Binding $parent[ScrollBar].LineRight}" AutomationProperties.AutomationId="ScrollRight" />
</MenuFlyout>
<ControlTheme x:Key="{x:Type ScrollBar}"
TargetType="ScrollBar">
<Setter Property="Cursor" Value="Arrow" />
<Style Selector="^:horizontal">
<Setter Property="ContextFlyout" Value="{StaticResource SimpleHorizontalScrollBarContextFlyout}" />
<Setter Property="Height" Value="{DynamicResource ScrollBarThickness}" />
<Setter Property="Template">
<ControlTemplate>
@ -62,6 +89,7 @@
</Setter>
</Style>
<Style Selector="^:vertical">
<Setter Property="ContextFlyout" Value="{StaticResource SimpleVerticalScrollBarContextFlyout}" />
<Setter Property="Width" Value="{DynamicResource ScrollBarThickness}" />
<Setter Property="Template">
<ControlTemplate>

222
tests/Avalonia.Controls.UnitTests/Primitives/ScrollBarTests.cs

@ -1,8 +1,10 @@
using System;
using System.Linq;
using Avalonia.Automation;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.UnitTests;
using Xunit;
@ -194,5 +196,225 @@ namespace Avalonia.Controls.UnitTests.Primitives
Background = Brushes.Gray,
};
}
[Theory]
[InlineData(Orientation.Vertical)]
[InlineData(Orientation.Horizontal)]
public void Orientation_Should_Set_Matching_PseudoClass(Orientation orientation)
{
var target = new ScrollBar { Orientation = orientation };
Assert.Equal(orientation == Orientation.Vertical, target.Classes.Contains(":vertical"));
Assert.Equal(orientation == Orientation.Horizontal, target.Classes.Contains(":horizontal"));
}
[Fact]
public void ScrollToHome_Should_Set_Value_To_Minimum()
{
var target = CreateScrollBar(minimum: 10, maximum: 100, value: 50);
target.ScrollToHome();
Assert.Equal(10, target.Value);
}
[Fact]
public void ScrollToEnd_Should_Set_Value_To_Maximum()
{
var target = CreateScrollBar(minimum: 0, maximum: 90, value: 50);
target.ScrollToEnd();
Assert.Equal(90, target.Value);
}
[Fact]
public void PageUp_Should_Decrease_Value_By_LargeChange()
{
var target = CreateScrollBar(value: 50, largeChange: 10);
target.PageUp();
Assert.Equal(40, target.Value);
}
[Fact]
public void PageDown_Should_Increase_Value_By_LargeChange()
{
var target = CreateScrollBar(value: 50, largeChange: 10);
target.PageDown();
Assert.Equal(60, target.Value);
}
[Fact]
public void PageLeft_Should_Decrease_Value_By_LargeChange()
{
var target = CreateScrollBar(orientation: Orientation.Horizontal, value: 50, largeChange: 10);
target.PageLeft();
Assert.Equal(40, target.Value);
}
[Fact]
public void PageRight_Should_Increase_Value_By_LargeChange()
{
var target = CreateScrollBar(orientation: Orientation.Horizontal, value: 50, largeChange: 10);
target.PageRight();
Assert.Equal(60, target.Value);
}
[Fact]
public void LineUp_Should_Decrease_Value_By_SmallChange()
{
var target = CreateScrollBar(value: 50, smallChange: 5);
target.LineUp();
Assert.Equal(45, target.Value);
}
[Fact]
public void LineDown_Should_Increase_Value_By_SmallChange()
{
var target = CreateScrollBar(value: 50, smallChange: 5);
target.LineDown();
Assert.Equal(55, target.Value);
}
[Fact]
public void LineLeft_Should_Decrease_Value_By_SmallChange()
{
var target = CreateScrollBar(orientation: Orientation.Horizontal, value: 50, smallChange: 5);
target.LineLeft();
Assert.Equal(45, target.Value);
}
[Fact]
public void LineRight_Should_Increase_Value_By_SmallChange()
{
var target = CreateScrollBar(orientation: Orientation.Horizontal, value: 50, smallChange: 5);
target.LineRight();
Assert.Equal(55, target.Value);
}
[Fact]
public void Scroll_Methods_Should_Respect_Value_Bounds()
{
var target = CreateScrollBar(minimum: 20, maximum: 80, value: 25, smallChange: 10);
target.LineUp();
Assert.Equal(20, target.Value); // Should not go below Minimum
target.Value = 75;
target.LineDown();
Assert.Equal(80, target.Value); // Should not go above Maximum
}
[Fact]
public void Scroll_Methods_Should_Raise_Scroll_Event()
{
var target = CreateScrollBar(value: 50, smallChange: 5, largeChange: 10);
var events = new System.Collections.Generic.List<ScrollEventType>();
target.Scroll += (_, e) => events.Add(e.ScrollEventType);
target.LineDown();
target.PageDown();
target.ScrollToHome();
target.ScrollToEnd();
Assert.Equal(
new[]
{
ScrollEventType.SmallIncrement,
ScrollEventType.LargeIncrement,
ScrollEventType.SmallDecrement,
ScrollEventType.SmallIncrement,
},
events);
}
[Fact]
public void ScrollHere_Should_Set_Value_Within_Bounds()
{
var target = CreateScrollBar(minimum: 0, maximum: 100, value: 0);
// No exception even though no pointer position was recorded yet.
target.ScrollHere();
Assert.InRange(target.Value, target.Minimum, target.Maximum);
}
[Theory]
[InlineData(PointerType.Touch)]
[InlineData(PointerType.Pen)]
public void ContextRequested_Should_Be_Handled_For_Touch_Or_Pen_Input(PointerType pointerType)
{
var target = CreateScrollBar();
var args = CreateContextRequested(target, pointerType);
target.RaiseEvent(args);
Assert.True(args.Handled);
}
[Fact]
public void ContextRequested_Should_Not_Be_Handled_For_Mouse_Input()
{
var target = CreateScrollBar();
var args = CreateContextRequested(target, PointerType.Mouse);
target.RaiseEvent(args);
Assert.False(args.Handled);
}
private static ContextRequestedEventArgs CreateContextRequested(ScrollBar target, PointerType pointerType)
{
var pointer = new Pointer(Pointer.GetNextFreeId(), pointerType, true);
var pointerArgs = new PointerPressedEventArgs(
target,
pointer,
target,
default,
timestamp: 1,
new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.Other),
KeyModifiers.None);
return new ContextRequestedEventArgs(pointerArgs);
}
private static ScrollBar CreateScrollBar(
Orientation orientation = Orientation.Vertical,
double minimum = 0,
double maximum = 100,
double value = 0,
double smallChange = 1,
double largeChange = 10)
{
var target = new ScrollBar
{
Orientation = orientation,
Template = new FuncControlTemplate<ScrollBar>(Template),
Minimum = minimum,
Maximum = maximum,
Value = value,
SmallChange = smallChange,
LargeChange = largeChange,
};
target.ApplyTemplate();
return target;
}
}
}

Loading…
Cancel
Save