Browse Source

[Feature] Add PipsPager Control (#20660)

* Added PipsPager control

* Added tests

* Added render tests

* Added samples

* More improvements

* More tests

* Added more samples

* Fix formatting

* Updated Automation

* Small optimization

* More changes

* Changes based on feedback

* Fix build errors

* More changes

* Updated samples

* Fixes

* More changes

* Fix build

* More changes

* More changes

* More tests

* More constants
pull/19830/merge
Javier Suárez 2 weeks ago
committed by GitHub
parent
commit
9a21a48aa4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      samples/ControlCatalog/MainView.xaml
  2. 50
      samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml
  3. 11
      samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml.cs
  4. 78
      samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml
  5. 11
      samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml.cs
  6. 59
      samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml
  7. 11
      samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml.cs
  8. 197
      samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml
  9. 11
      samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml.cs
  10. 35
      samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml
  11. 29
      samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml.cs
  12. 46
      samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml
  13. 11
      samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml.cs
  14. 52
      samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml
  15. 11
      samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml.cs
  16. 11
      samples/ControlCatalog/Pages/PipsPagerPage.xaml
  17. 47
      samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs
  18. 85
      src/Avalonia.Controls/Automation/Peers/PipsPagerAutomationPeer.cs
  19. 662
      src/Avalonia.Controls/PipsPager/PipsPager.cs
  20. 27
      src/Avalonia.Controls/PipsPager/PipsPagerSelectedIndexChangedEventArgs.cs
  21. 30
      src/Avalonia.Controls/PipsPager/PipsPagerTemplateSettings.cs
  22. 1
      src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml
  23. 312
      src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml
  24. 143
      src/Avalonia.Themes.Simple/Controls/PipsPager.xaml
  25. 1
      src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml
  26. 578
      tests/Avalonia.Controls.UnitTests/PipsPagerTests.cs
  27. 168
      tests/Avalonia.RenderTests/Controls/PipsPagerTests.cs
  28. BIN
      tests/TestFiles/Skia/Controls/PipsPager/PipsPager_Default.expected.png
  29. BIN
      tests/TestFiles/Skia/Controls/PipsPager/PipsPager_Preselected_Index.expected.png

3
samples/ControlCatalog/MainView.xaml

@ -161,6 +161,9 @@
<TabItem Header="OpenGL Lease">
<pages:OpenGlLeasePage />
</TabItem>
<TabItem Header="PipsPager">
<pages:PipsPagerPage />
</TabItem>
<TabItem Header="Platform Information">
<pages:PlatformInfoPage />
</TabItem>

50
samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml

@ -0,0 +1,50 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ControlCatalog.Pages.PipsPagerCarouselPage">
<DockPanel>
<ScrollViewer DockPanel.Dock="Right" Width="220">
<StackPanel Margin="12" Spacing="8">
<TextBlock Text="Carousel Integration" FontSize="16" FontWeight="SemiBold"
Foreground="{DynamicResource SystemControlHighlightAccentBrush}" />
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.7"
Text="Bind SelectedPageIndex to a Carousel's SelectedIndex for two-way synchronized page navigation." />
<Separator />
<TextBlock Text="Binding" FontSize="13" FontWeight="SemiBold" />
<TextBlock FontSize="12" TextWrapping="Wrap"
Text="SelectedIndex='{Binding #Pager.SelectedPageIndex, Mode=TwoWay}'" />
</StackPanel>
</ScrollViewer>
<Border DockPanel.Dock="Right" Width="1"
Background="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" />
<Grid RowDefinitions="*,Auto" Margin="24">
<Carousel Name="GalleryCarousel"
SelectedIndex="{Binding #GalleryPager.SelectedPageIndex, Mode=TwoWay}">
<Carousel.Items>
<Border Background="#E3F2FD" CornerRadius="8">
<TextBlock Text="Page 1" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="30" Opacity="0.6" />
</Border>
<Border Background="#C8E6C9" CornerRadius="8">
<TextBlock Text="Page 2" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="30" Opacity="0.6" />
</Border>
<Border Background="#FFE0B2" CornerRadius="8">
<TextBlock Text="Page 3" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="30" Opacity="0.6" />
</Border>
<Border Background="#E1BEE7" CornerRadius="8">
<TextBlock Text="Page 4" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="30" Opacity="0.6" />
</Border>
<Border Background="#FFCDD2" CornerRadius="8">
<TextBlock Text="Page 5" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="30" Opacity="0.6" />
</Border>
</Carousel.Items>
</Carousel>
<PipsPager Name="GalleryPager"
Grid.Row="1"
NumberOfPages="5"
HorizontalAlignment="Center"
Margin="0,12,0,0" />
</Grid>
</DockPanel>
</UserControl>

11
samples/ControlCatalog/Pages/PipsPager/PipsPagerCarouselPage.xaml.cs

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ControlCatalog.Pages;
public partial class PipsPagerCarouselPage : UserControl
{
public PipsPagerCarouselPage()
{
InitializeComponent();
}
}

78
samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml

@ -0,0 +1,78 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ControlCatalog.Pages.PipsPagerCustomButtonsPage">
<DockPanel>
<ScrollViewer DockPanel.Dock="Right" Width="220">
<StackPanel Margin="12" Spacing="8">
<TextBlock Text="Custom Buttons" FontSize="16" FontWeight="SemiBold"
Foreground="{DynamicResource SystemControlHighlightAccentBrush}" />
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.7"
Text="Replace the default chevron navigation buttons with custom styled buttons using PreviousButtonStyle and NextButtonStyle." />
<Separator />
<TextBlock Text="Properties" FontSize="13" FontWeight="SemiBold" />
<TextBlock FontSize="12" TextWrapping="Wrap" Text="PreviousButtonStyle" />
<TextBlock FontSize="12" TextWrapping="Wrap" Text="NextButtonStyle" />
<TextBlock FontSize="12" TextWrapping="Wrap" Text="IsPreviousButtonVisible" />
<TextBlock FontSize="12" TextWrapping="Wrap" Text="IsNextButtonVisible" />
</StackPanel>
</ScrollViewer>
<Border DockPanel.Dock="Right" Width="1"
Background="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" />
<StackPanel Spacing="24" Margin="24">
<StackPanel Spacing="8">
<TextBlock Text="Text Buttons" FontWeight="SemiBold" FontSize="14" />
<PipsPager NumberOfPages="5" MaxVisiblePips="5"
IsPreviousButtonVisible="True" IsNextButtonVisible="True">
<PipsPager.Resources>
<ControlTheme x:Key="CustomPreviousButtonStyle" TargetType="Button">
<Setter Property="Content" Value="Prev" />
<Setter Property="Background" Value="LightGray" />
<Setter Property="Foreground" Value="Black" />
<Setter Property="Padding" Value="8,2" />
<Setter Property="Margin" Value="0,0,8,0" />
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}" CornerRadius="4">
<ContentPresenter Content="{TemplateBinding Content}" Margin="{TemplateBinding Padding}" />
</Border>
</ControlTemplate>
</Setter>
</ControlTheme>
<ControlTheme x:Key="CustomNextButtonStyle" TargetType="Button">
<Setter Property="Content" Value="Next" />
<Setter Property="Background" Value="LightGray" />
<Setter Property="Foreground" Value="Black" />
<Setter Property="Padding" Value="8,2" />
<Setter Property="Margin" Value="8,0,0,0" />
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}" CornerRadius="4">
<ContentPresenter Content="{TemplateBinding Content}" Margin="{TemplateBinding Padding}" />
</Border>
</ControlTemplate>
</Setter>
</ControlTheme>
</PipsPager.Resources>
<PipsPager.PreviousButtonStyle>
<StaticResource ResourceKey="CustomPreviousButtonStyle" />
</PipsPager.PreviousButtonStyle>
<PipsPager.NextButtonStyle>
<StaticResource ResourceKey="CustomNextButtonStyle" />
</PipsPager.NextButtonStyle>
</PipsPager>
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="Hidden Buttons" FontWeight="SemiBold" FontSize="14" />
<PipsPager NumberOfPages="7"
MaxVisiblePips="7"
IsPreviousButtonVisible="False"
IsNextButtonVisible="False" />
</StackPanel>
</StackPanel>
</DockPanel>
</UserControl>

11
samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomButtonsPage.xaml.cs

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ControlCatalog.Pages;
public partial class PipsPagerCustomButtonsPage : UserControl
{
public PipsPagerCustomButtonsPage()
{
InitializeComponent();
}
}

59
samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml

@ -0,0 +1,59 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ControlCatalog.Pages.PipsPagerCustomColorsPage">
<DockPanel>
<ScrollViewer DockPanel.Dock="Right" Width="220">
<StackPanel Margin="12" Spacing="8">
<TextBlock Text="Custom Colors" FontSize="16" FontWeight="SemiBold"
Foreground="{DynamicResource SystemControlHighlightAccentBrush}" />
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.7"
Text="Override pip indicator colors using resource keys." />
<Separator />
<TextBlock Text="Resource Keys" FontSize="13" FontWeight="SemiBold" />
<TextBlock FontSize="11" TextWrapping="Wrap" Text="PipsPagerSelectionIndicatorForeground" />
<TextBlock FontSize="11" TextWrapping="Wrap" Text="PipsPagerSelectionIndicatorForegroundSelected" />
<TextBlock FontSize="11" TextWrapping="Wrap" Text="PipsPagerSelectionIndicatorForegroundPointerOver" />
</StackPanel>
</ScrollViewer>
<Border DockPanel.Dock="Right" Width="1"
Background="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" />
<StackPanel Spacing="24" Margin="24">
<StackPanel Spacing="8">
<TextBlock Text="Orange / Blue" FontWeight="SemiBold" FontSize="14" />
<PipsPager NumberOfPages="5" MaxVisiblePips="5">
<PipsPager.Resources>
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForeground" Color="Orange" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundSelected" Color="Blue" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundPointerOver" Color="Gold" />
</PipsPager.Resources>
</PipsPager>
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="Green / Red" FontWeight="SemiBold" FontSize="14" />
<PipsPager NumberOfPages="5" MaxVisiblePips="5">
<PipsPager.Resources>
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForeground" Color="#81C784" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundSelected" Color="#E53935" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundPointerOver" Color="#A5D6A7" />
</PipsPager.Resources>
</PipsPager>
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="Purple / Teal" FontWeight="SemiBold" FontSize="14" />
<PipsPager NumberOfPages="5" MaxVisiblePips="5">
<PipsPager.Resources>
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForeground" Color="#CE93D8" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundSelected" Color="#00897B" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundPointerOver" Color="#BA68C8" />
</PipsPager.Resources>
</PipsPager>
</StackPanel>
</StackPanel>
</DockPanel>
</UserControl>

11
samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomColorsPage.xaml.cs

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ControlCatalog.Pages;
public partial class PipsPagerCustomColorsPage : UserControl
{
public PipsPagerCustomColorsPage()
{
InitializeComponent();
}
}

197
samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml

@ -0,0 +1,197 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ControlCatalog.Pages.PipsPagerCustomTemplatesPage">
<DockPanel>
<ScrollViewer DockPanel.Dock="Right" Width="220">
<StackPanel Margin="12" Spacing="8">
<TextBlock Text="Custom Templates" FontSize="16" FontWeight="SemiBold"
Foreground="{DynamicResource SystemControlHighlightAccentBrush}" />
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.7"
Text="Override pip item templates using Style selectors targeting the inner ListBoxItem to create squares, pills, numbers, or any custom shape." />
<Separator />
<TextBlock Text="Technique" FontSize="13" FontWeight="SemiBold" />
<TextBlock FontSize="12" TextWrapping="Wrap"
Text="Target: PipsPager /template/ ListBox ListBoxItem" />
<TextBlock FontSize="12" TextWrapping="Wrap"
Text="States: :selected, :pointerover, :pressed" />
</StackPanel>
</ScrollViewer>
<Border DockPanel.Dock="Right" Width="1"
Background="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" />
<StackPanel Spacing="32" Margin="24">
<!-- Squares -->
<StackPanel Spacing="8">
<TextBlock Text="Squares" FontWeight="SemiBold" FontSize="14" />
<PipsPager NumberOfPages="5">
<PipsPager.Styles>
<Style Selector="PipsPager /template/ ListBox ListBoxItem">
<Setter Property="Template">
<ControlTemplate>
<Grid Background="Transparent">
<Rectangle Name="Pip"
Width="4" Height="4"
HorizontalAlignment="Center" VerticalAlignment="Center"
Fill="{DynamicResource PipsPagerSelectionIndicatorForeground}">
<Rectangle.Transitions>
<Transitions>
<DoubleTransition Property="Width" Duration="0:0:0.167" />
<DoubleTransition Property="Height" Duration="0:0:0.167" />
<BrushTransition Property="Fill" Duration="0:0:0.167" />
</Transitions>
</Rectangle.Transitions>
</Rectangle>
</Grid>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="PipsPager /template/ ListBox ListBoxItem:pointerover /template/ Rectangle#Pip">
<Setter Property="Width" Value="6" />
<Setter Property="Height" Value="6" />
<Setter Property="Fill" Value="{DynamicResource PipsPagerSelectionIndicatorForegroundPointerOver}" />
</Style>
<Style Selector="PipsPager /template/ ListBox ListBoxItem:selected /template/ Rectangle#Pip">
<Setter Property="Width" Value="6" />
<Setter Property="Height" Value="6" />
<Setter Property="Fill" Value="{DynamicResource PipsPagerSelectionIndicatorForegroundSelected}" />
</Style>
<Style Selector="PipsPager /template/ ListBox ListBoxItem:selected:pointerover /template/ Rectangle#Pip">
<Setter Property="Width" Value="6" />
<Setter Property="Height" Value="6" />
<Setter Property="Fill" Value="{DynamicResource PipsPagerSelectionIndicatorForegroundSelected}" />
</Style>
<Style Selector="PipsPager /template/ ListBox ListBoxItem:pressed /template/ Rectangle#Pip">
<Setter Property="Width" Value="4" />
<Setter Property="Height" Value="4" />
<Setter Property="Fill" Value="{DynamicResource PipsPagerSelectionIndicatorForegroundPressed}" />
</Style>
</PipsPager.Styles>
</PipsPager>
</StackPanel>
<!-- Pill-shaped -->
<StackPanel Spacing="8">
<TextBlock Text="Pill-shaped Selected" FontWeight="SemiBold" FontSize="14" />
<PipsPager NumberOfPages="5"
IsPreviousButtonVisible="False"
IsNextButtonVisible="False">
<PipsPager.Styles>
<Style Selector="PipsPager /template/ ListBox ListBoxItem">
<Setter Property="Width" Value="24" />
<Setter Property="Height" Value="24" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="ClipToBounds" Value="False" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate>
<Grid Background="Transparent">
<Border Name="Pip"
Width="8" Height="8" CornerRadius="4"
HorizontalAlignment="Center" VerticalAlignment="Center"
Background="#C0C0C0">
<Border.Transitions>
<Transitions>
<DoubleTransition Property="Width" Duration="0:0:0.2" Easing="CubicEaseOut" />
<DoubleTransition Property="Height" Duration="0:0:0.2" Easing="CubicEaseOut" />
<CornerRadiusTransition Property="CornerRadius" Duration="0:0:0.2" Easing="CubicEaseOut" />
<BrushTransition Property="Background" Duration="0:0:0.2" />
</Transitions>
</Border.Transitions>
</Border>
</Grid>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="PipsPager /template/ ListBox ListBoxItem:pointerover /template/ Border#Pip">
<Setter Property="Width" Value="10" />
<Setter Property="Height" Value="10" />
<Setter Property="CornerRadius" Value="5" />
<Setter Property="Background" Value="#909090" />
</Style>
<Style Selector="PipsPager /template/ ListBox ListBoxItem:selected /template/ Border#Pip">
<Setter Property="Width" Value="24" />
<Setter Property="Height" Value="8" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Background" Value="#FF6B35" />
</Style>
<Style Selector="PipsPager /template/ ListBox ListBoxItem:selected:pointerover /template/ Border#Pip">
<Setter Property="Width" Value="24" />
<Setter Property="Height" Value="8" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Background" Value="#E85A2A" />
</Style>
<Style Selector="PipsPager /template/ ListBox ListBoxItem:pressed /template/ Border#Pip">
<Setter Property="Width" Value="8" />
<Setter Property="Height" Value="8" />
<Setter Property="Background" Value="#707070" />
</Style>
</PipsPager.Styles>
</PipsPager>
</StackPanel>
<!-- Numbers -->
<StackPanel Spacing="8">
<TextBlock Text="Numbers" FontWeight="SemiBold" FontSize="14" />
<PipsPager NumberOfPages="10"
MaxVisiblePips="4"
IsNextButtonVisible="True"
IsPreviousButtonVisible="True"
ClipToBounds="False">
<PipsPager.Styles>
<Style Selector="PipsPager /template/ ListBox ListBoxItem">
<Setter Property="Width" Value="44" />
<Setter Property="Height" Value="44" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="2" />
<Setter Property="ClipToBounds" Value="False" />
<Setter Property="Template">
<ControlTemplate>
<Border Name="PipBorder"
Width="30" Height="30"
VerticalAlignment="Center" HorizontalAlignment="Center"
CornerRadius="10" ClipToBounds="False"
Background="LightGray">
<Border.Transitions>
<Transitions>
<DoubleTransition Property="Width" Duration="0:0:0.2" Easing="CubicEaseOut" />
<DoubleTransition Property="Height" Duration="0:0:0.2" Easing="CubicEaseOut" />
<BrushTransition Property="Background" Duration="0:0:0.2" />
</Transitions>
</Border.Transitions>
<TextBlock Text="{TemplateBinding Content}"
VerticalAlignment="Center" HorizontalAlignment="Center"
Foreground="Black" FontWeight="SemiBold" />
</Border>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="PipsPager /template/ ListBox ListBoxItem:pointerover /template/ Border#PipBorder">
<Setter Property="Background" Value="DarkGray" />
</Style>
<Style Selector="PipsPager /template/ ListBox ListBoxItem:pressed /template/ Border#PipBorder">
<Setter Property="Width" Value="28" />
<Setter Property="Height" Value="28" />
</Style>
<Style Selector="PipsPager /template/ ListBox ListBoxItem:selected /template/ Border#PipBorder">
<Setter Property="Background" Value="#0078D7" />
<Setter Property="Width" Value="36" />
<Setter Property="Height" Value="36" />
</Style>
<Style Selector="PipsPager /template/ ListBox ListBoxItem:selected /template/ TextBlock">
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="PipsPager /template/ ListBox ListBoxItem:selected:pointerover /template/ Border#PipBorder">
<Setter Property="Background" Value="#106EBE" />
</Style>
</PipsPager.Styles>
</PipsPager>
</StackPanel>
</StackPanel>
</DockPanel>
</UserControl>

11
samples/ControlCatalog/Pages/PipsPager/PipsPagerCustomTemplatesPage.xaml.cs

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ControlCatalog.Pages;
public partial class PipsPagerCustomTemplatesPage : UserControl
{
public PipsPagerCustomTemplatesPage()
{
InitializeComponent();
}
}

35
samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml

@ -0,0 +1,35 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ControlCatalog.Pages.PipsPagerEventsPage">
<DockPanel>
<ScrollViewer DockPanel.Dock="Right" Width="220">
<StackPanel Margin="12" Spacing="8">
<TextBlock Text="Events" FontSize="16" FontWeight="SemiBold"
Foreground="{DynamicResource SystemControlHighlightAccentBrush}" />
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.7"
Text="Monitor SelectedPageIndex changes to react to user navigation." />
<Separator />
<TextBlock Text="Event Log" FontSize="13" FontWeight="SemiBold" />
<ItemsControl Name="EventLog">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontSize="11" TextWrapping="Wrap" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<Border DockPanel.Dock="Right" Width="1"
Background="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" />
<StackPanel Spacing="16" Margin="24">
<TextBlock Text="Tap a pip and watch the event log" FontSize="14" Opacity="0.6" />
<PipsPager Name="EventPager"
NumberOfPages="8"
MaxVisiblePips="8" />
<TextBlock Name="StatusText" FontSize="13" Opacity="0.7"
Text="Selected: 0" />
</StackPanel>
</DockPanel>
</UserControl>

29
samples/ControlCatalog/Pages/PipsPager/PipsPagerEventsPage.xaml.cs

@ -0,0 +1,29 @@
using System.Collections.ObjectModel;
using Avalonia.Controls;
namespace ControlCatalog.Pages;
public partial class PipsPagerEventsPage : UserControl
{
private readonly ObservableCollection<string> _events = new();
public PipsPagerEventsPage()
{
InitializeComponent();
EventLog.ItemsSource = _events;
EventPager.PropertyChanged += (_, e) =>
{
if (e.Property != PipsPager.SelectedPageIndexProperty)
return;
var newIndex = (int)e.NewValue!;
StatusText.Text = $"Selected: {newIndex}";
_events.Insert(0, $"SelectedPageIndex changed to {newIndex}");
if (_events.Count > 20)
_events.RemoveAt(_events.Count - 1);
};
}
}

46
samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml

@ -0,0 +1,46 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ControlCatalog.Pages.PipsPagerGettingStartedPage">
<DockPanel>
<ScrollViewer DockPanel.Dock="Right" Width="220">
<StackPanel Margin="12" Spacing="8">
<TextBlock Text="Getting Started" FontSize="16" FontWeight="SemiBold"
Foreground="{DynamicResource SystemControlHighlightAccentBrush}" />
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.7"
Text="PipsPager lets users navigate a paginated collection using configurable dot indicators with optional navigation buttons." />
<Separator />
<TextBlock Text="Features" FontSize="13" FontWeight="SemiBold" />
<TextBlock FontSize="12" TextWrapping="Wrap" Text="Configurable maximum visible pips" />
<TextBlock FontSize="12" TextWrapping="Wrap" Text="Automatic scrolling for large collections" />
<TextBlock FontSize="12" TextWrapping="Wrap" Text="Horizontal and Vertical orientation" />
<TextBlock FontSize="12" TextWrapping="Wrap" Text="Previous/Next navigation buttons" />
<TextBlock FontSize="12" TextWrapping="Wrap" Text="Customizable pips and buttons" />
</StackPanel>
</ScrollViewer>
<Border DockPanel.Dock="Right" Width="1"
Background="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" />
<StackPanel Spacing="24" Margin="24">
<StackPanel Spacing="8">
<TextBlock Text="Default" FontWeight="SemiBold" FontSize="14" />
<PipsPager NumberOfPages="5" />
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="Without Navigation Buttons" FontWeight="SemiBold" FontSize="14" />
<PipsPager NumberOfPages="10"
IsPreviousButtonVisible="False"
IsNextButtonVisible="False"
MaxVisiblePips="5" />
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="Vertical" FontWeight="SemiBold" FontSize="14" />
<PipsPager NumberOfPages="5" Orientation="Vertical" />
</StackPanel>
</StackPanel>
</DockPanel>
</UserControl>

11
samples/ControlCatalog/Pages/PipsPager/PipsPagerGettingStartedPage.xaml.cs

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ControlCatalog.Pages;
public partial class PipsPagerGettingStartedPage : UserControl
{
public PipsPagerGettingStartedPage()
{
InitializeComponent();
}
}

52
samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml

@ -0,0 +1,52 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ControlCatalog.Pages.PipsPagerLargeCollectionPage">
<DockPanel>
<ScrollViewer DockPanel.Dock="Right" Width="220">
<StackPanel Margin="12" Spacing="8">
<TextBlock Text="Large Collections" FontSize="16" FontWeight="SemiBold"
Foreground="{DynamicResource SystemControlHighlightAccentBrush}" />
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.7"
Text="Use MaxVisiblePips to limit visible indicators when the page count is large. The pips scroll automatically to keep the selected pip visible." />
<Separator />
<TextBlock Text="Properties" FontSize="13" FontWeight="SemiBold" />
<TextBlock FontSize="12" TextWrapping="Wrap" Text="NumberOfPages: Total page count" />
<TextBlock FontSize="12" TextWrapping="Wrap" Text="MaxVisiblePips: Visible indicator limit" />
<TextBlock FontSize="12" TextWrapping="Wrap" Text="SelectedPageIndex: Current selection" />
</StackPanel>
</ScrollViewer>
<Border DockPanel.Dock="Right" Width="1"
Background="{DynamicResource SystemControlForegroundBaseMediumLowBrush}" />
<StackPanel Spacing="24" Margin="24">
<StackPanel Spacing="8">
<TextBlock Text="50 Pages, MaxVisiblePips=7" FontWeight="SemiBold" FontSize="14" />
<PipsPager Name="LargePager"
NumberOfPages="50"
MaxVisiblePips="7"
SelectedPageIndex="25" />
<TextBlock HorizontalAlignment="Left" FontSize="12">
<Run Text="Selected: " FontWeight="SemiBold" />
<Run Text="{Binding #LargePager.SelectedPageIndex}" />
<Run Text=" / " />
<Run Text="{Binding #LargePager.NumberOfPages}" />
</TextBlock>
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="20 Pages, MaxVisiblePips=5" FontWeight="SemiBold" FontSize="14" />
<PipsPager NumberOfPages="20"
MaxVisiblePips="5" />
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="100 Pages, MaxVisiblePips=9" FontWeight="SemiBold" FontSize="14" />
<PipsPager NumberOfPages="100"
MaxVisiblePips="9" />
</StackPanel>
</StackPanel>
</DockPanel>
</UserControl>

11
samples/ControlCatalog/Pages/PipsPager/PipsPagerLargeCollectionPage.xaml.cs

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ControlCatalog.Pages;
public partial class PipsPagerLargeCollectionPage : UserControl
{
public PipsPagerLargeCollectionPage()
{
InitializeComponent();
}
}

11
samples/ControlCatalog/Pages/PipsPagerPage.xaml

@ -0,0 +1,11 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ControlCatalog.Pages.PipsPagerPage">
<NavigationPage x:Name="SampleNav">
<NavigationPage.Styles>
<Style Selector="NavigationPage#SampleNav /template/ Border#PART_NavigationBar">
<Setter Property="Background" Value="Transparent" />
</Style>
</NavigationPage.Styles>
</NavigationPage>
</UserControl>

47
samples/ControlCatalog/Pages/PipsPagerPage.xaml.cs

@ -0,0 +1,47 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace ControlCatalog.Pages
{
public partial class PipsPagerPage : UserControl
{
private static readonly (string Group, string Title, string Description, Func<UserControl> Factory)[] Demos =
{
("Getting Started", "First Look",
"Default PipsPager with horizontal and vertical orientation, with and without navigation buttons.",
() => new PipsPagerGettingStartedPage()),
("Features", "Carousel Integration",
"Bind SelectedPageIndex to a Carousel's SelectedIndex for two-way synchronized page navigation.",
() => new PipsPagerCarouselPage()),
("Features", "Large Collections",
"Use MaxVisiblePips to limit visible indicators when the page count is large. Pips scroll automatically.",
() => new PipsPagerLargeCollectionPage()),
("Features", "Events",
"Monitor SelectedPageIndex changes to react to user navigation.",
() => new PipsPagerEventsPage()),
("Appearance", "Custom Colors",
"Override pip indicator colors using resource keys for normal, selected, and hover states.",
() => new PipsPagerCustomColorsPage()),
("Appearance", "Custom Buttons",
"Replace the default chevron navigation buttons with custom styled buttons.",
() => new PipsPagerCustomButtonsPage()),
("Appearance", "Custom Templates",
"Override pip item templates to create squares, pills, numbers, or any custom shape.",
() => new PipsPagerCustomTemplatesPage()),
};
public PipsPagerPage()
{
InitializeComponent();
Loaded += OnLoaded;
}
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
await SampleNav.PushAsync(NavigationDemoHelper.CreateGalleryHomePage(SampleNav, Demos), null);
}
}
}

85
src/Avalonia.Controls/Automation/Peers/PipsPagerAutomationPeer.cs

@ -0,0 +1,85 @@
using System.Collections.Generic;
using Avalonia.Automation.Provider;
using Avalonia.Controls;
namespace Avalonia.Automation.Peers
{
/// <summary>
/// An automation peer for <see cref="PipsPager"/>.
/// </summary>
public class PipsPagerAutomationPeer : ControlAutomationPeer, ISelectionProvider
{
private ListBox? _pipsList;
/// <summary>
/// Initializes a new instance of the <see cref="PipsPagerAutomationPeer"/> class.
/// </summary>
/// <param name="owner">The control associated with this peer.</param>
public PipsPagerAutomationPeer(PipsPager owner) : base(owner)
{
owner.SelectedIndexChanged += OnSelectionChanged;
}
/// <summary>
/// Gets the owner as a <see cref="PipsPager"/>.
/// </summary>
private new PipsPager Owner => (PipsPager)base.Owner;
/// <inheritdoc/>
public bool CanSelectMultiple => false;
/// <inheritdoc/>
public bool IsSelectionRequired => true;
/// <inheritdoc/>
public IReadOnlyList<AutomationPeer> GetSelection()
{
var result = new List<AutomationPeer>();
var owner = Owner;
if (owner.SelectedPageIndex >= 0 && owner.SelectedPageIndex < owner.NumberOfPages)
{
_pipsList ??= owner.FindNameScope()?.Find<ListBox>("PART_PipsPagerList");
if (_pipsList != null)
{
var container = _pipsList.ContainerFromIndex(owner.SelectedPageIndex);
if (container is Control c)
{
var peer = GetOrCreate(c);
result.Add(peer);
}
}
}
return result;
}
/// <inheritdoc/>
protected override AutomationControlType GetAutomationControlTypeCore()
{
return AutomationControlType.List;
}
/// <inheritdoc/>
protected override string GetClassNameCore()
{
return nameof(PipsPager);
}
/// <inheritdoc/>
protected override string? GetNameCore()
{
var name = base.GetNameCore();
return string.IsNullOrWhiteSpace(name) ? "Pips Pager" : name;
}
private void OnSelectionChanged(object? sender, Controls.PipsPagerSelectedIndexChangedEventArgs e)
{
RaisePropertyChangedEvent(
SelectionPatternIdentifiers.SelectionProperty,
e.OldIndex,
e.NewIndex);
}
}
}

662
src/Avalonia.Controls/PipsPager/PipsPager.cs

@ -0,0 +1,662 @@
using System;
using System.Threading;
using Avalonia.Threading;
using Avalonia.Controls.Metadata;
using Avalonia.Automation;
using Avalonia.Automation.Peers;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Styling;
using System.Collections.Generic;
namespace Avalonia.Controls
{
/// <summary>
/// Represents a control that lets the user navigate through a paginated collection using a set of pips.
/// </summary>
[TemplatePart(PART_PreviousButton, typeof(Button))]
[TemplatePart(PART_NextButton, typeof(Button))]
[TemplatePart(PART_PipsPagerList, typeof(ListBox))]
[PseudoClasses(PC_FirstPage, PC_LastPage, PC_Vertical, PC_Horizontal)]
public class PipsPager : TemplatedControl
{
private const string PART_PreviousButton = "PART_PreviousButton";
private const string PART_NextButton = "PART_NextButton";
private const string PART_PipsPagerList = "PART_PipsPagerList";
private const string PC_FirstPage = ":first-page";
private const string PC_LastPage = ":last-page";
private const string PC_Vertical = ":vertical";
private const string PC_Horizontal = ":horizontal";
private Button? _previousButton;
private Button? _nextButton;
private ListBox? _pipsPagerList;
private bool _scrollPending;
private bool _updatingPagerSize;
private bool _isInitialLoad;
private int _lastSelectedPageIndex;
private CancellationTokenSource? _scrollAnimationCts;
private PipsPagerTemplateSettings _templateSettings = new PipsPagerTemplateSettings();
/// <summary>
/// Defines the <see cref="MaxVisiblePips"/> property.
/// </summary>
public static readonly StyledProperty<int> MaxVisiblePipsProperty =
AvaloniaProperty.Register<PipsPager, int>(nameof(MaxVisiblePips), 5);
/// <summary>
/// Defines the <see cref="IsNextButtonVisible"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsNextButtonVisibleProperty =
AvaloniaProperty.Register<PipsPager, bool>(nameof(IsNextButtonVisible), true);
/// <summary>
/// Defines the <see cref="NumberOfPages"/> property.
/// </summary>
public static readonly StyledProperty<int> NumberOfPagesProperty =
AvaloniaProperty.Register<PipsPager, int>(nameof(NumberOfPages));
/// <summary>
/// Defines the <see cref="Orientation"/> property.
/// </summary>
public static readonly StyledProperty<Orientation> OrientationProperty =
AvaloniaProperty.Register<PipsPager, Orientation>(nameof(Orientation), Orientation.Horizontal);
/// <summary>
/// Defines the <see cref="IsPreviousButtonVisible"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsPreviousButtonVisibleProperty =
AvaloniaProperty.Register<PipsPager, bool>(nameof(IsPreviousButtonVisible), true);
/// <summary>
/// Defines the <see cref="SelectedPageIndex"/> property.
/// </summary>
public static readonly StyledProperty<int> SelectedPageIndexProperty =
AvaloniaProperty.Register<PipsPager, int>(nameof(SelectedPageIndex),
defaultBindingMode: BindingMode.TwoWay);
/// <summary>
/// Defines the <see cref="TemplateSettings"/> property.
/// </summary>
public static readonly DirectProperty<PipsPager, PipsPagerTemplateSettings> TemplateSettingsProperty =
AvaloniaProperty.RegisterDirect<PipsPager, PipsPagerTemplateSettings>(nameof(TemplateSettings),
x => x.TemplateSettings);
/// <summary>
/// Defines the <see cref="PreviousButtonStyle"/> property.
/// </summary>
public static readonly StyledProperty<ControlTheme?> PreviousButtonStyleProperty =
AvaloniaProperty.Register<PipsPager, ControlTheme?>(nameof(PreviousButtonStyle));
/// <summary>
/// Defines the <see cref="NextButtonStyle"/> property.
/// </summary>
public static readonly StyledProperty<ControlTheme?> NextButtonStyleProperty =
AvaloniaProperty.Register<PipsPager, ControlTheme?>(nameof(NextButtonStyle));
/// <summary>
/// Defines the <see cref="SelectedIndexChanged"/> event.
/// </summary>
public static readonly RoutedEvent<PipsPagerSelectedIndexChangedEventArgs> SelectedIndexChangedEvent =
RoutedEvent.Register<PipsPager, PipsPagerSelectedIndexChangedEventArgs>(nameof(SelectedIndexChanged), RoutingStrategies.Bubble);
/// <summary>
/// Occurs when the selected index has changed.
/// </summary>
public event EventHandler<PipsPagerSelectedIndexChangedEventArgs>? SelectedIndexChanged
{
add => AddHandler(SelectedIndexChangedEvent, value);
remove => RemoveHandler(SelectedIndexChangedEvent, value);
}
static PipsPager()
{
SelectedPageIndexProperty.Changed.AddClassHandler<PipsPager>((x, e) => x.OnSelectedPageIndexChanged(e));
NumberOfPagesProperty.Changed.AddClassHandler<PipsPager>((x, e) => x.OnNumberOfPagesChanged(e));
IsPreviousButtonVisibleProperty.Changed.AddClassHandler<PipsPager>((x, e) => x.OnIsPreviousButtonVisibleChanged(e));
IsNextButtonVisibleProperty.Changed.AddClassHandler<PipsPager>((x, e) => x.OnIsNextButtonVisibleChanged(e));
OrientationProperty.Changed.AddClassHandler<PipsPager>((x, e) => x.OnOrientationChanged(e));
MaxVisiblePipsProperty.Changed.AddClassHandler<PipsPager>((x, e) => x.OnMaxVisiblePipsChanged(e));
}
/// <summary>
/// Initializes a new instance of <see cref="PipsPager"/>.
/// </summary>
public PipsPager()
{
UpdatePseudoClasses();
}
/// <summary>
/// Gets or sets the maximum number of visible pips.
/// </summary>
public int MaxVisiblePips
{
get => GetValue(MaxVisiblePipsProperty);
set => SetValue(MaxVisiblePipsProperty, value);
}
/// <summary>
/// Gets or sets the visibility of the next button.
/// </summary>
public bool IsNextButtonVisible
{
get => GetValue(IsNextButtonVisibleProperty);
set => SetValue(IsNextButtonVisibleProperty, value);
}
/// <summary>
/// Gets or sets the number of pages.
/// </summary>
public int NumberOfPages
{
get => GetValue(NumberOfPagesProperty);
set => SetValue(NumberOfPagesProperty, value);
}
/// <summary>
/// Gets or sets the orientation of the pips.
/// </summary>
public Orientation Orientation
{
get => GetValue(OrientationProperty);
set => SetValue(OrientationProperty, value);
}
/// <summary>
/// Gets or sets the visibility of the previous button.
/// </summary>
public bool IsPreviousButtonVisible
{
get => GetValue(IsPreviousButtonVisibleProperty);
set => SetValue(IsPreviousButtonVisibleProperty, value);
}
/// <summary>
/// Gets or sets the current selected page index.
/// </summary>
public int SelectedPageIndex
{
get => GetValue(SelectedPageIndexProperty);
set => SetValue(SelectedPageIndexProperty, value);
}
/// <summary>
/// Gets the template settings.
/// </summary>
public PipsPagerTemplateSettings TemplateSettings
{
get => _templateSettings;
private set => SetAndRaise(TemplateSettingsProperty, ref _templateSettings, value);
}
/// <summary>
/// Gets or sets the style for the previous button.
/// </summary>
public ControlTheme? PreviousButtonStyle
{
get => GetValue(PreviousButtonStyleProperty);
set => SetValue(PreviousButtonStyleProperty, value);
}
/// <summary>
/// Gets or sets the style for the next button.
/// </summary>
public ControlTheme? NextButtonStyle
{
get => GetValue(NextButtonStyleProperty);
set => SetValue(NextButtonStyleProperty, value);
}
/// <inheritdoc/>
protected override AutomationPeer OnCreateAutomationPeer()
{
return new PipsPagerAutomationPeer(this);
}
/// <inheritdoc/>
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
_scrollAnimationCts?.Cancel();
_scrollAnimationCts?.Dispose();
_scrollAnimationCts = null;
_isInitialLoad = true;
// Unsubscribe from previous button events
if (_previousButton != null)
{
_previousButton.Click -= PreviousButton_Click;
}
if (_nextButton != null)
{
_nextButton.Click -= NextButton_Click;
}
// Unsubscribe from previous list events
if (_pipsPagerList != null)
{
_pipsPagerList.SizeChanged -= OnPipsPagerListSizeChanged;
_pipsPagerList.ContainerPrepared -= OnContainerPrepared;
_pipsPagerList.ContainerIndexChanged -= OnContainerIndexChanged;
}
// Get template parts
_previousButton = e.NameScope.Find<Button>(PART_PreviousButton);
_nextButton = e.NameScope.Find<Button>(PART_NextButton);
_pipsPagerList = e.NameScope.Find<ListBox>(PART_PipsPagerList);
// Set up previous button
if (_previousButton != null)
{
_previousButton.Click += PreviousButton_Click;
AutomationProperties.SetName(_previousButton, "Previous page");
}
// Set up next button
if (_nextButton != null)
{
_nextButton.Click += NextButton_Click;
AutomationProperties.SetName(_nextButton, "Next page");
}
// Set up pips list
if (_pipsPagerList != null)
{
_pipsPagerList.SizeChanged += OnPipsPagerListSizeChanged;
_pipsPagerList.ContainerPrepared += OnContainerPrepared;
_pipsPagerList.ContainerIndexChanged += OnContainerIndexChanged;
}
UpdateButtonsState();
UpdatePseudoClasses();
UpdatePagerSize();
}
/// <inheritdoc/>
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (e.Handled)
return;
var isHorizontal = Orientation == Orientation.Horizontal;
switch (e.Key)
{
case Key.Left when isHorizontal:
case Key.Up when !isHorizontal:
if (SelectedPageIndex > 0)
{
SetCurrentValue(SelectedPageIndexProperty, SelectedPageIndex - 1);
e.Handled = true;
}
break;
case Key.Right when isHorizontal:
case Key.Down when !isHorizontal:
if (SelectedPageIndex < NumberOfPages - 1)
{
SetCurrentValue(SelectedPageIndexProperty, SelectedPageIndex + 1);
e.Handled = true;
}
break;
case Key.Home:
SetCurrentValue(SelectedPageIndexProperty, 0);
e.Handled = true;
break;
case Key.End:
if (NumberOfPages > 0)
{
SetCurrentValue(SelectedPageIndexProperty, NumberOfPages - 1);
e.Handled = true;
}
break;
}
}
private void OnSelectedPageIndexChanged(AvaloniaPropertyChangedEventArgs e)
{
var newIndex = e.GetNewValue<int>();
var oldIndex = e.GetOldValue<int>();
if (newIndex < 0)
{
SetCurrentValue(SelectedPageIndexProperty, 0);
return;
}
if (NumberOfPages > 0)
{
if (newIndex >= NumberOfPages)
{
SetCurrentValue(SelectedPageIndexProperty, NumberOfPages - 1);
return;
}
}
else
{
if (newIndex > 0)
{
SetCurrentValue(SelectedPageIndexProperty, 0);
return;
}
}
_lastSelectedPageIndex = oldIndex;
UpdateButtonsState();
UpdatePseudoClasses();
RequestScrollToSelectedPip();
RaiseEvent(new PipsPagerSelectedIndexChangedEventArgs(oldIndex, newIndex));
}
private void OnNumberOfPagesChanged(AvaloniaPropertyChangedEventArgs e)
{
var newValue = e.GetNewValue<int>();
if (newValue < 0)
{
SetCurrentValue(NumberOfPagesProperty, 0);
return;
}
var pips = TemplateSettings.Pips;
if (pips.Count < newValue)
{
var start = pips.Count + 1;
var count = newValue - pips.Count;
var toAdd = new List<int>(count);
for (int i = 0; i < count; i++)
{
toAdd.Add(start + i);
}
pips.AddRange(toAdd);
}
else if (pips.Count > newValue)
{
pips.RemoveRange(newValue, pips.Count - newValue);
}
var indexClamped = false;
if (newValue > 0 && SelectedPageIndex >= newValue)
{
SetCurrentValue(SelectedPageIndexProperty, newValue - 1);
indexClamped = true;
}
else if (newValue == 0 && SelectedPageIndex > 0)
{
SetCurrentValue(SelectedPageIndexProperty, 0);
indexClamped = true;
}
if (!indexClamped)
{
UpdateButtonsState();
UpdatePseudoClasses();
}
UpdatePagerSize();
}
private void OnIsPreviousButtonVisibleChanged(AvaloniaPropertyChangedEventArgs e)
{
UpdateButtonsState();
}
private void OnIsNextButtonVisibleChanged(AvaloniaPropertyChangedEventArgs e)
{
UpdateButtonsState();
}
private void OnOrientationChanged(AvaloniaPropertyChangedEventArgs e)
{
UpdatePseudoClasses();
UpdatePagerSize();
}
private void OnMaxVisiblePipsChanged(AvaloniaPropertyChangedEventArgs e)
{
var newValue = e.GetNewValue<int>();
if (newValue < 1)
{
SetCurrentValue(MaxVisiblePipsProperty, 1);
return;
}
UpdatePagerSize();
}
private void PreviousButton_Click(object? sender, RoutedEventArgs e)
{
if (SelectedPageIndex > 0)
{
SetCurrentValue(SelectedPageIndexProperty, SelectedPageIndex - 1);
}
}
private void NextButton_Click(object? sender, RoutedEventArgs e)
{
if (SelectedPageIndex < NumberOfPages - 1)
{
SetCurrentValue(SelectedPageIndexProperty, SelectedPageIndex + 1);
}
}
private void UpdateButtonsState()
{
if (_previousButton != null)
_previousButton.IsEnabled = SelectedPageIndex > 0;
if (_nextButton != null)
_nextButton.IsEnabled = SelectedPageIndex < NumberOfPages - 1;
}
private void UpdatePseudoClasses()
{
PseudoClasses.Set(PC_FirstPage, SelectedPageIndex == 0);
PseudoClasses.Set(PC_LastPage, NumberOfPages > 0 && SelectedPageIndex >= NumberOfPages - 1);
PseudoClasses.Set(PC_Vertical, Orientation == Orientation.Vertical);
PseudoClasses.Set(PC_Horizontal, Orientation == Orientation.Horizontal);
}
private void OnPipsPagerListSizeChanged(object? sender, SizeChangedEventArgs e)
{
if (!_updatingPagerSize)
UpdatePagerSize();
}
private void OnContainerPrepared(object? sender, ContainerPreparedEventArgs e)
{
UpdateContainerAutomationProperties(e.Container, e.Index);
}
private void OnContainerIndexChanged(object? sender, ContainerIndexChangedEventArgs e)
{
UpdateContainerAutomationProperties(e.Container, e.NewIndex);
}
private void UpdateContainerAutomationProperties(Control container, int index)
{
AutomationProperties.SetName(container, $"Page {index + 1}");
AutomationProperties.SetPositionInSet(container, index + 1);
AutomationProperties.SetSizeOfSet(container, NumberOfPages);
}
private void RequestScrollToSelectedPip()
{
if (_scrollPending)
return;
_scrollPending = true;
Dispatcher.UIThread.Post(() =>
{
_scrollPending = false;
ScrollToSelectedPip();
}, DispatcherPriority.Input);
}
private void ScrollToSelectedPip()
{
if (_pipsPagerList == null)
return;
if (NumberOfPages <= MaxVisiblePips)
return;
var scrollViewer = _pipsPagerList.Scroll as ScrollViewer;
if (scrollViewer == null)
return;
var container = _pipsPagerList.ContainerFromIndex(SelectedPageIndex) as Layoutable;
if (container == null)
return;
var isHorizontal = Orientation == Orientation.Horizontal;
var pipSize = isHorizontal
? container.Bounds.Width + container.Margin.Left + container.Margin.Right
: container.Bounds.Height + container.Margin.Top + container.Margin.Bottom;
if (pipSize <= 0)
return;
var maxVisiblePips = MaxVisiblePips;
var evenOffset = maxVisiblePips % 2 == 0 && SelectedPageIndex > _lastSelectedPageIndex ? 1 : 0;
var offsetElements = SelectedPageIndex + evenOffset - maxVisiblePips / 2;
var targetOffset = Math.Max(0.0, offsetElements * pipSize);
var maxOffset = isHorizontal
? scrollViewer.Extent.Width - scrollViewer.Viewport.Width
: scrollViewer.Extent.Height - scrollViewer.Viewport.Height;
targetOffset = Math.Min(targetOffset, Math.Max(0, maxOffset));
if (_isInitialLoad)
{
_isInitialLoad = false;
scrollViewer.Offset = isHorizontal
? new Vector(targetOffset, scrollViewer.Offset.Y)
: new Vector(scrollViewer.Offset.X, targetOffset);
return;
}
AnimateScrollOffset(scrollViewer, targetOffset, isHorizontal);
}
private void AnimateScrollOffset(ScrollViewer scrollViewer, double targetOffset, bool isHorizontal)
{
_scrollAnimationCts?.Cancel();
_scrollAnimationCts?.Dispose();
_scrollAnimationCts = new CancellationTokenSource();
var token = _scrollAnimationCts.Token;
var startOffset = isHorizontal ? scrollViewer.Offset.X : scrollViewer.Offset.Y;
var delta = targetOffset - startOffset;
if (Math.Abs(delta) < 0.5)
{
scrollViewer.Offset = isHorizontal
? new Vector(targetOffset, scrollViewer.Offset.Y)
: new Vector(scrollViewer.Offset.X, targetOffset);
return;
}
const int durationMs = 200;
const int frameMs = 16;
var startTime = Environment.TickCount64;
DispatcherTimer.RunOnce(() => AnimateStep(scrollViewer, startOffset, delta, startTime, durationMs, isHorizontal, token),
TimeSpan.FromMilliseconds(frameMs), DispatcherPriority.Render);
}
private void AnimateStep(ScrollViewer scrollViewer, double startOffset, double delta,
long startTime, int durationMs, bool isHorizontal, CancellationToken token)
{
if (token.IsCancellationRequested)
return;
var elapsed = Environment.TickCount64 - startTime;
var t = Math.Min(1.0, (double)elapsed / durationMs);
var eased = 1.0 - Math.Pow(1.0 - t, 3);
var current = startOffset + (delta * eased);
scrollViewer.Offset = isHorizontal
? new Vector(current, scrollViewer.Offset.Y)
: new Vector(scrollViewer.Offset.X, current);
if (t < 1.0)
{
DispatcherTimer.RunOnce(() => AnimateStep(scrollViewer, startOffset, delta, startTime, durationMs, isHorizontal, token),
TimeSpan.FromMilliseconds(16), DispatcherPriority.Render);
}
}
private void UpdatePagerSize()
{
if (_pipsPagerList == null)
return;
_updatingPagerSize = true;
try
{
double pipSize = 12.0;
var container = _pipsPagerList.ContainerFromIndex(SelectedPageIndex) as Layoutable;
if (container == null && _pipsPagerList.Items.Count > 0)
container = _pipsPagerList.ContainerFromIndex(0);
if (container != null)
{
var margin = container.Margin;
var size = Orientation == Orientation.Horizontal
? container.Bounds.Width + margin.Left + margin.Right
: container.Bounds.Height + margin.Top + margin.Bottom;
if (size > 0)
pipSize = size;
}
double spacing = 0.0;
if (_pipsPagerList.ItemsPanelRoot is StackPanel itemsPanel)
{
spacing = itemsPanel.Spacing;
}
var visibleCount = Math.Min(NumberOfPages, MaxVisiblePips);
if (visibleCount <= 0)
return;
var extent = (visibleCount * pipSize) + ((visibleCount - 1) * spacing);
if (Orientation == Orientation.Horizontal)
{
_pipsPagerList.Width = extent;
_pipsPagerList.Height = double.NaN;
}
else
{
_pipsPagerList.Height = extent;
_pipsPagerList.Width = double.NaN;
}
RequestScrollToSelectedPip();
}
finally
{
_updatingPagerSize = false;
}
}
}
}

27
src/Avalonia.Controls/PipsPager/PipsPagerSelectedIndexChangedEventArgs.cs

@ -0,0 +1,27 @@
using Avalonia.Interactivity;
namespace Avalonia.Controls
{
/// <summary>
/// Provides data for the <see cref="PipsPager.SelectedIndexChanged"/> event.
/// </summary>
public class PipsPagerSelectedIndexChangedEventArgs : RoutedEventArgs
{
public PipsPagerSelectedIndexChangedEventArgs(int oldIndex, int newIndex)
: base(PipsPager.SelectedIndexChangedEvent)
{
OldIndex = oldIndex;
NewIndex = newIndex;
}
/// <summary>
/// Gets the previous selected index.
/// </summary>
public int OldIndex { get; }
/// <summary>
/// Gets the new selected index.
/// </summary>
public int NewIndex { get; }
}
}

30
src/Avalonia.Controls/PipsPager/PipsPagerTemplateSettings.cs

@ -0,0 +1,30 @@
using Avalonia.Collections;
namespace Avalonia.Controls.Primitives
{
/// <summary>
/// Provides calculated values for use with the <see cref="PipsPager"/>'s control theme or template.
/// </summary>
public class PipsPagerTemplateSettings : AvaloniaObject
{
private AvaloniaList<int> _pips;
internal PipsPagerTemplateSettings()
{
_pips = new AvaloniaList<int>();
}
public static readonly DirectProperty<PipsPagerTemplateSettings, AvaloniaList<int>> PipsProperty =
AvaloniaProperty.RegisterDirect<PipsPagerTemplateSettings, AvaloniaList<int>>(
nameof(Pips),
o => o.Pips);
/// <summary>
/// Gets the collection of pips indices.
/// </summary>
public AvaloniaList<int> Pips
{
get => _pips;
}
}
}

1
src/Avalonia.Themes.Fluent/Controls/FluentControls.xaml

@ -42,6 +42,7 @@
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/OverlayPopupHost.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/PopupRoot.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/PathIcon.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/PipsPager.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/RepeatButton.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/ScrollBar.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml" />

312
src/Avalonia.Themes.Fluent/Controls/PipsPager.xaml

@ -0,0 +1,312 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<PipsPager NumberOfPages="5" SelectedPageIndex="2" />
</Design.PreviewWith>
<!-- Icon path data for navigation buttons -->
<!-- Horizontal (left/right arrows) -->
<StreamGeometry x:Key="PipsPagerPreviousPageButtonData">M 8.12,2.29 L 3.41,7.00 C 3.14,7.27 3.14,7.71 3.41,7.98 L 8.12,12.69 C 8.57,13.14 9.33,12.82 9.33,12.19 L 9.33,2.79 C 9.33,2.16 8.57,1.84 8.12,2.29 Z</StreamGeometry>
<StreamGeometry x:Key="PipsPagerNextPageButtonData">M 3.88,2.29 L 8.59,7.00 C 8.86,7.27 8.86,7.71 8.59,7.98 L 3.88,12.69 C 3.43,13.14 2.67,12.82 2.67,12.19 L 2.67,2.79 C 2.67,2.16 3.43,1.84 3.88,2.29 Z</StreamGeometry>
<!-- Vertical (up/down arrows) -->
<StreamGeometry x:Key="PipsPagerPreviousPageButtonVerticalData">M 2.29,8.12 L 7.00,3.41 C 7.27,3.14 7.71,3.14 7.98,3.41 L 12.69,8.12 C 13.14,8.57 12.82,9.33 12.19,9.33 L 2.79,9.33 C 2.16,9.33 1.84,8.57 2.29,8.12 Z</StreamGeometry>
<StreamGeometry x:Key="PipsPagerNextPageButtonVerticalData">M 2.29,3.88 L 7.00,8.59 C 7.27,8.86 7.71,8.86 7.98,8.59 L 12.69,3.88 C 13.14,3.43 12.82,2.67 12.19,2.67 L 2.79,2.67 C 2.16,2.67 1.84,3.43 2.29,3.88 Z</StreamGeometry>
<!-- Base navigation button theme (for custom button styles to inherit from) -->
<ControlTheme x:Key="PipsPagerNavigationButtonTheme" TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PipsPagerNavigationButtonForeground}" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Width" Value="24" />
<Setter Property="Height" Value="24" />
<Setter Property="Padding" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
Padding="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PipsPagerNavigationButtonForegroundPointerOver}" />
</Style>
<Style Selector="^:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PipsPagerNavigationButtonForegroundPressed}" />
</Style>
<Style Selector="^:disabled /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource PipsPagerNavigationButtonForegroundDisabled}" />
</Style>
</ControlTheme>
<!-- Previous button theme -->
<ControlTheme x:Key="PipsPagerPreviousButtonTheme" TargetType="Button" BasedOn="{StaticResource PipsPagerNavigationButtonTheme}" />
<!-- Next button theme -->
<ControlTheme x:Key="PipsPagerNextButtonTheme" TargetType="Button" BasedOn="{StaticResource PipsPagerNavigationButtonTheme}" />
<ControlTheme x:Key="PipsPagerItemTheme" TargetType="ListBoxItem">
<Setter Property="Width" Value="12" />
<Setter Property="Height" Value="24" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="ClipToBounds" Value="False" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate>
<Grid Background="Transparent">
<Ellipse Name="Pip"
Width="4"
Height="4"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Fill="{DynamicResource PipsPagerSelectionIndicatorForeground}">
<Ellipse.Transitions>
<Transitions>
<DoubleTransition Property="Width" Duration="0:0:0.167" />
<DoubleTransition Property="Height" Duration="0:0:0.167" />
<BrushTransition Property="Fill" Duration="0:0:0.167" />
</Transitions>
</Ellipse.Transitions>
</Ellipse>
</Grid>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Ellipse#Pip">
<Setter Property="Width" Value="6" />
<Setter Property="Height" Value="6" />
<Setter Property="Fill" Value="{DynamicResource PipsPagerSelectionIndicatorForegroundPointerOver}" />
</Style>
<Style Selector="^:pressed /template/ Ellipse#Pip">
<Setter Property="Width" Value="4" />
<Setter Property="Height" Value="4" />
<Setter Property="Fill" Value="{DynamicResource PipsPagerSelectionIndicatorForegroundPressed}" />
</Style>
<Style Selector="^:selected /template/ Ellipse#Pip">
<Setter Property="Width" Value="6" />
<Setter Property="Height" Value="6" />
<Setter Property="Fill" Value="{DynamicResource PipsPagerSelectionIndicatorForegroundSelected}" />
</Style>
<Style Selector="^:selected:pointerover /template/ Ellipse#Pip">
<Setter Property="Width" Value="6" />
<Setter Property="Height" Value="6" />
<Setter Property="Fill" Value="{DynamicResource PipsPagerSelectionIndicatorForegroundPointerOver}" />
</Style>
<Style Selector="^:disabled /template/ Ellipse#Pip">
<Setter Property="Fill" Value="{DynamicResource PipsPagerSelectionIndicatorForegroundDisabled}" />
</Style>
</ControlTheme>
<ControlTheme x:Key="{x:Type PipsPager}" TargetType="PipsPager">
<Setter Property="Background" Value="Transparent" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="PreviousButtonStyle" Value="{StaticResource PipsPagerPreviousButtonTheme}" />
<Setter Property="NextButtonStyle" Value="{StaticResource PipsPagerNextButtonTheme}" />
<Setter Property="Template">
<ControlTemplate>
<StackPanel Name="PART_RootPanel"
Orientation="{TemplateBinding Orientation}"
Background="{TemplateBinding Background}"
ClipToBounds="False">
<Button Name="PART_PreviousButton"
Theme="{TemplateBinding PreviousButtonStyle}"
IsVisible="{TemplateBinding IsPreviousButtonVisible}"
VerticalAlignment="Center"
HorizontalAlignment="Center">
<PathIcon Name="PreviousButtonIcon" Width="10" Height="10"
Data="{StaticResource PipsPagerPreviousPageButtonData}" />
</Button>
<ListBox Name="PART_PipsPagerList"
Background="Transparent"
BorderThickness="0"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
ScrollViewer.HorizontalScrollBarVisibility="Hidden"
ScrollViewer.VerticalScrollBarVisibility="Hidden"
ItemsSource="{Binding TemplateSettings.Pips, RelativeSource={RelativeSource TemplatedParent}}"
SelectedIndex="{Binding SelectedPageIndex, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}"
AutoScrollToSelectedItem="False"
ClipToBounds="False"
ItemContainerTheme="{StaticResource PipsPagerItemTheme}">
<ListBox.Styles>
<Style Selector="ScrollViewer">
<Setter Property="ClipToBounds" Value="False" />
</Style>
</ListBox.Styles>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="{Binding Orientation, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=PipsPager}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="0" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
<Button Name="PART_NextButton"
Theme="{TemplateBinding NextButtonStyle}"
IsVisible="{TemplateBinding IsNextButtonVisible}"
VerticalAlignment="Center"
HorizontalAlignment="Center">
<PathIcon Name="NextButtonIcon" Width="10" Height="10"
Data="{StaticResource PipsPagerNextPageButtonData}" />
</Button>
</StackPanel>
</ControlTemplate>
</Setter>
<Style Selector="^:horizontal /template/ StackPanel#PART_RootPanel">
<Setter Property="Orientation" Value="Horizontal" />
</Style>
<Style Selector="^:vertical /template/ ListBox ListBoxItem">
<Setter Property="Width" Value="24" />
<Setter Property="Height" Value="12" />
</Style>
<Style Selector="^:vertical /template/ StackPanel#PART_RootPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style Selector="^:vertical /template/ Button#PART_PreviousButton PathIcon#PreviousButtonIcon">
<Setter Property="Data" Value="{StaticResource PipsPagerPreviousPageButtonVerticalData}" />
</Style>
<Style Selector="^:vertical /template/ Button#PART_NextButton PathIcon#NextButtonIcon">
<Setter Property="Data" Value="{StaticResource PipsPagerNextPageButtonVerticalData}" />
</Style>
<Style Selector="^:first-page /template/ Button#PART_PreviousButton">
<Setter Property="Opacity" Value="0" />
<Setter Property="IsHitTestVisible" Value="False" />
</Style>
<Style Selector="^:last-page /template/ Button#PART_NextButton">
<Setter Property="Opacity" Value="0" />
<Setter Property="IsHitTestVisible" Value="False" />
</Style>
<Style Selector="^ /template/ Button#PART_PreviousButton:pressed">
<Setter Property="RenderTransformOrigin" Value="0.5, 0.5" />
<Setter Property="RenderTransform" Value="scale(0.875)" />
</Style>
<Style Selector="^ /template/ Button#PART_NextButton:pressed">
<Setter Property="RenderTransformOrigin" Value="0.5, 0.5" />
<Setter Property="RenderTransform" Value="scale(0.875)" />
</Style>
</ControlTheme>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackground" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackgroundPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackgroundPressed" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackgroundSelected" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackgroundDisabled" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrush" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrushPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrushPressed" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrushSelected" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrushDisabled" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForeground" Color="#72000000" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundPointerOver" Color="#9E000000" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundPressed" Color="#9E000000" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundSelected" Color="#FF000000" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundDisabled" Color="#51000000" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBackground" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBackgroundPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBackgroundPressed" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBackgroundDisabled" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBorderBrush" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBorderBrushPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBorderBrushPressed" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBorderBrushDisabled" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonForeground" Color="#72000000" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonForegroundPointerOver" Color="#9E000000" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonForegroundPressed" Color="#9E000000" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonForegroundDisabled" Color="#51000000" />
</ResourceDictionary>
<ResourceDictionary x:Key="Default">
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackground" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackgroundPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackgroundPressed" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackgroundSelected" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackgroundDisabled" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrush" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrushPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrushPressed" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrushSelected" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrushDisabled" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForeground" Color="#72000000" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundPointerOver" Color="#9E000000" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundPressed" Color="#9E000000" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundSelected" Color="#FF000000" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundDisabled" Color="#51000000" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBackground" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBackgroundPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBackgroundPressed" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBackgroundDisabled" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBorderBrush" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBorderBrushPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBorderBrushPressed" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBorderBrushDisabled" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonForeground" Color="#72000000" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonForegroundPointerOver" Color="#9E000000" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonForegroundPressed" Color="#9E000000" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonForegroundDisabled" Color="#51000000" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackground" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackgroundPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackgroundPressed" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackgroundSelected" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBackgroundDisabled" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrush" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrushPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrushPressed" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrushSelected" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorBorderBrushDisabled" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForeground" Color="#8BFFFFFF" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundPointerOver" Color="#C5FFFFFF" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundPressed" Color="#C5FFFFFF" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundSelected" Color="#FFFFFFFF" />
<SolidColorBrush x:Key="PipsPagerSelectionIndicatorForegroundDisabled" Color="#3FFFFFFF" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBackground" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBackgroundPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBackgroundPressed" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBackgroundDisabled" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBorderBrush" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBorderBrushPointerOver" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBorderBrushPressed" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonBorderBrushDisabled" Color="Transparent" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonForeground" Color="#8BFFFFFF" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonForegroundPointerOver" Color="#C5FFFFFF" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonForegroundPressed" Color="#C5FFFFFF" />
<SolidColorBrush x:Key="PipsPagerNavigationButtonForegroundDisabled" Color="#3FFFFFFF" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>

143
src/Avalonia.Themes.Simple/Controls/PipsPager.xaml

@ -0,0 +1,143 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<PipsPager NumberOfPages="5" SelectedPageIndex="2" />
</Design.PreviewWith>
<ControlTheme x:Key="{x:Type PipsPager}" TargetType="PipsPager">
<Setter Property="Foreground" Value="{DynamicResource ThemeForegroundBrush}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="Template">
<ControlTemplate>
<StackPanel Name="PART_RootPanel"
Orientation="{TemplateBinding Orientation}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
ClipToBounds="False">
<Button Name="PART_PreviousButton"
Width="24"
Height="24"
Padding="0"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
IsVisible="{TemplateBinding IsPreviousButtonVisible}"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Margin="4">
<PathIcon Width="12" Height="12" Data="M 8.12,2.29 L 3.41,7.00 C 3.14,7.27 3.14,7.71 3.41,7.98 L 8.12,12.69 C 8.57,13.14 9.33,12.82 9.33,12.19 L 9.33,2.79 C 9.33,2.16 8.57,1.84 8.12,2.29 Z" />
</Button>
<ListBox Name="PART_PipsPagerList"
Background="Transparent"
BorderThickness="0"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
ScrollViewer.HorizontalScrollBarVisibility="Hidden"
ScrollViewer.VerticalScrollBarVisibility="Hidden"
ItemsSource="{Binding TemplateSettings.Pips, RelativeSource={RelativeSource TemplatedParent}}"
SelectedIndex="{Binding SelectedPageIndex, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}"
AutoScrollToSelectedItem="False"
ClipToBounds="False">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="{Binding Orientation, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=PipsPager}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="4" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.Styles>
<Style Selector="ScrollViewer">
<Setter Property="ClipToBounds" Value="False" />
</Style>
<Style Selector="ListBoxItem">
<Setter Property="Width" Value="12" />
<Setter Property="Height" Value="24" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="ClipToBounds" Value="False" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate>
<Panel Background="Transparent">
<Ellipse Name="Pip" Width="12" Height="12" Fill="{DynamicResource ThemeControlLowBrush}">
<Ellipse.Transitions>
<Transitions>
<BrushTransition Property="Fill" Duration="0:0:0.1" />
</Transitions>
</Ellipse.Transitions>
</Ellipse>
</Panel>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="ListBoxItem:pointerover /template/ Ellipse#Pip">
<Setter Property="Fill" Value="{DynamicResource ThemeControlHighBrush}" />
</Style>
<Style Selector="ListBoxItem:selected /template/ Ellipse#Pip">
<Setter Property="Fill" Value="{DynamicResource ThemeAccentBrush}" />
</Style>
<Style Selector="PipsPager:vertical ListBoxItem">
<Setter Property="Width" Value="24" />
<Setter Property="Height" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected:pointerover /template/ Ellipse#Pip">
<Setter Property="Fill" Value="{DynamicResource ThemeAccentBrush2}" />
</Style>
<Style Selector="ListBoxItem:pressed /template/ Ellipse#Pip">
<Setter Property="Fill" Value="{DynamicResource ThemeAccentBrush3}" />
</Style>
</ListBox.Styles>
</ListBox>
<Button Name="PART_NextButton"
Width="24"
Height="24"
Padding="0"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
IsVisible="{TemplateBinding IsNextButtonVisible}"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Margin="4">
<PathIcon Width="12" Height="12" Data="M 3.88,2.29 L 8.59,7.00 C 8.86,7.27 8.86,7.71 8.59,7.98 L 3.88,12.69 C 3.43,13.14 2.67,12.82 2.67,12.19 L 2.67,2.79 C 2.67,2.16 3.43,1.84 3.88,2.29 Z" />
</Button>
</StackPanel>
</ControlTemplate>
</Setter>
<Style Selector="^:vertical /template/ Button#PART_PreviousButton PathIcon">
<Setter Property="Data" Value="M 2.29,9.33 L 7.00,4.62 C 7.27,4.35 7.71,4.35 7.98,4.62 L 12.69,9.33 C 13.14,9.78 12.82,10.54 12.19,10.54 L 2.79,10.54 C 2.16,10.54 1.84,9.78 2.29,9.33 Z" />
</Style>
<Style Selector="^:vertical /template/ Button#PART_NextButton PathIcon">
<Setter Property="Data" Value="M 2.29,4.46 L 7.00,9.17 C 7.27,9.44 7.71,9.44 7.98,9.17 L 12.69,4.46 C 13.14,4.01 12.82,3.25 12.19,3.25 L 2.79,3.25 C 2.16,3.25 1.84,4.01 2.29,4.46 Z" />
</Style>
<Style Selector="^ /template/ Button">
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.1" />
</Transitions>
</Setter>
</Style>
<Style Selector="^:first-page /template/ Button#PART_PreviousButton">
<Setter Property="Opacity" Value="0" />
<Setter Property="IsHitTestVisible" Value="False" />
</Style>
<Style Selector="^:last-page /template/ Button#PART_NextButton">
<Setter Property="Opacity" Value="0" />
<Setter Property="IsHitTestVisible" Value="False" />
</Style>
</ControlTheme>
</ResourceDictionary>

1
src/Avalonia.Themes.Simple/Controls/SimpleControls.xaml

@ -40,6 +40,7 @@
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/TabStripItem.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/TabControl.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/TabItem.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/PipsPager.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/PopupRoot.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/OverlayPopupHost.xaml" />
<MergeResourceInclude Source="avares://Avalonia.Themes.Simple/Controls/WindowNotificationManager.xaml" />

578
tests/Avalonia.Controls.UnitTests/PipsPagerTests.cs

@ -0,0 +1,578 @@
using Avalonia.Input;
using Avalonia.UnitTests;
using Avalonia.VisualTree;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using System.Linq;
using Xunit;
namespace Avalonia.Controls.UnitTests
{
public class PipsPagerTests : ScopedTestBase
{
[Fact]
public void NumberOfPages_Should_Update_Pips()
{
var target = new PipsPager();
target.NumberOfPages = 5;
Assert.Equal(5, target.TemplateSettings.Pips.Count);
Assert.Equal(1, target.TemplateSettings.Pips[0]);
Assert.Equal(5, target.TemplateSettings.Pips[4]);
}
[Fact]
public void Decreasing_NumberOfPages_Should_Update_Pips()
{
var target = new PipsPager();
target.NumberOfPages = 5;
target.NumberOfPages = 3;
Assert.Equal(3, target.TemplateSettings.Pips.Count);
}
[Fact]
public void Decreasing_NumberOfPages_Should_Update_SelectedPageIndex()
{
var target = new PipsPager();
target.NumberOfPages = 5;
target.SelectedPageIndex = 4;
target.NumberOfPages = 3;
Assert.Equal(2, target.SelectedPageIndex);
}
[Fact]
public void SelectedPageIndex_Should_Be_Clamped_To_Zero()
{
var target = new PipsPager();
target.NumberOfPages = 5;
target.SelectedPageIndex = -1;
Assert.Equal(0, target.SelectedPageIndex);
}
[Fact]
public void SelectedPageIndex_Change_Should_Raise_Event()
{
var target = new PipsPager();
target.NumberOfPages = 5;
var raised = false;
target.SelectedIndexChanged += (s, e) => raised = true;
target.SelectedPageIndex = 2;
Assert.True(raised);
}
[Fact]
public void Next_Button_Should_Increment_Index()
{
using var unittestApplication = UnitTestApplication.Start(TestServices.StyledWindow);
var target = new PipsPager
{
NumberOfPages = 5,
SelectedPageIndex = 1,
IsNextButtonVisible = true,
Template = GetTemplate()
};
var root = new TestRoot(target);
target.ApplyTemplate();
var nextButton = target.GetVisualDescendants().OfType<Button>().FirstOrDefault(b => b.Name == "PART_NextButton");
Assert.NotNull(nextButton);
nextButton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
Assert.Equal(2, target.SelectedPageIndex);
}
[Fact]
public void Previous_Button_Should_Decrement_Index()
{
using var unittestApplication = UnitTestApplication.Start(TestServices.StyledWindow);
var target = new PipsPager
{
NumberOfPages = 5,
SelectedPageIndex = 3,
IsPreviousButtonVisible = true,
Template = GetTemplate()
};
var root = new TestRoot(target);
target.ApplyTemplate();
var prevButton = target.GetVisualDescendants().OfType<Button>().FirstOrDefault(b => b.Name == "PART_PreviousButton");
Assert.NotNull(prevButton);
prevButton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
Assert.Equal(2, target.SelectedPageIndex);
}
[Fact]
public void Keyboard_Navigation_Should_Work()
{
using var unittestApplication = UnitTestApplication.Start(TestServices.StyledWindow);
var target = new PipsPager
{
NumberOfPages = 5,
SelectedPageIndex = 1,
Orientation = Orientation.Horizontal
};
var root = new TestRoot(target);
target.ApplyTemplate();
target.RaiseEvent(new KeyEventArgs { Key = Key.Right, RoutedEvent = InputElement.KeyDownEvent });
Assert.Equal(2, target.SelectedPageIndex);
target.RaiseEvent(new KeyEventArgs { Key = Key.Left, RoutedEvent = InputElement.KeyDownEvent });
Assert.Equal(1, target.SelectedPageIndex);
target.Orientation = Orientation.Vertical;
target.RaiseEvent(new KeyEventArgs { Key = Key.Down, RoutedEvent = InputElement.KeyDownEvent });
Assert.Equal(2, target.SelectedPageIndex);
target.RaiseEvent(new KeyEventArgs { Key = Key.Up, RoutedEvent = InputElement.KeyDownEvent });
Assert.Equal(1, target.SelectedPageIndex);
}
[Fact]
public void Orientation_PseudoClasses_Should_Be_Set()
{
var target = new PipsPager();
target.Orientation = Orientation.Horizontal;
Assert.True(target.Classes.Contains(":horizontal"));
Assert.False(target.Classes.Contains(":vertical"));
target.Orientation = Orientation.Vertical;
Assert.False(target.Classes.Contains(":horizontal"));
Assert.True(target.Classes.Contains(":vertical"));
}
[Fact]
public void Clamping_Logic_Works()
{
var target = new PipsPager();
target.NumberOfPages = 5;
target.SelectedPageIndex = 10;
Assert.Equal(4, target.SelectedPageIndex);
target.SelectedPageIndex = -5;
Assert.Equal(0, target.SelectedPageIndex);
}
[Fact]
public void Manual_Button_Visibility_Should_Be_Respected()
{
using var unittestApplication = UnitTestApplication.Start(TestServices.StyledWindow);
var target = new PipsPager
{
NumberOfPages = 5,
IsPreviousButtonVisible = false,
IsNextButtonVisible = false,
Template = GetTemplate()
};
var root = new TestRoot(target);
target.ApplyTemplate();
Assert.False(target.IsPreviousButtonVisible);
Assert.False(target.IsNextButtonVisible);
target.IsPreviousButtonVisible = true;
target.IsNextButtonVisible = true;
Assert.True(target.IsPreviousButtonVisible);
Assert.True(target.IsNextButtonVisible);
}
[Fact]
public void Rapid_Page_Changes_Should_Maintain_Integrity()
{
var target = new PipsPager { NumberOfPages = 100 };
var list = new System.Collections.Generic.List<int>();
target.SelectedIndexChanged += (s, e) => list.Add(e.NewIndex);
for (int i = 1; i <= 50; i++)
{
target.SelectedPageIndex = i;
}
Assert.Equal(50, list.Count);
Assert.Equal(50, target.SelectedPageIndex);
Assert.Equal(50, list.Last());
}
[Fact]
public void SelectedIndexChanged_Event_Should_Have_Correct_Args()
{
var target = new PipsPager { NumberOfPages = 5, SelectedPageIndex = 1 };
int oldIdx = -1;
int newIdx = -1;
target.SelectedIndexChanged += (s, e) =>
{
oldIdx = e.OldIndex;
newIdx = e.NewIndex;
};
target.SelectedPageIndex = 3;
Assert.Equal(1, oldIdx);
Assert.Equal(3, newIdx);
}
[Fact]
public void Pager_Size_Should_Update_Based_On_Orientation_And_MaxVisiblePips()
{
using var unittestApplication = UnitTestApplication.Start(TestServices.StyledWindow);
var target = new PipsPager
{
NumberOfPages = 10,
MaxVisiblePips = 5,
Orientation = Orientation.Horizontal,
Template = GetTemplate()
};
var root = new TestRoot(target);
target.ApplyTemplate();
var pipsList = target.GetVisualDescendants().OfType<ListBox>().First(i => i.Name == "PART_PipsPagerList");
Assert.Equal(60, pipsList.Width);
target.Orientation = Orientation.Vertical;
Assert.Equal(60, pipsList.Height);
}
[Fact]
public void NumberOfPages_Reduction_Should_Clamp_SelectedPageIndex()
{
var target = new PipsPager();
target.NumberOfPages = 10;
target.SelectedPageIndex = 8;
target.NumberOfPages = 5;
Assert.Equal(4, target.SelectedPageIndex);
}
[Fact]
public void Page_PseudoClasses_Should_Be_Set()
{
var target = new PipsPager();
target.NumberOfPages = 5;
target.SelectedPageIndex = 0;
Assert.True(target.Classes.Contains(":first-page"));
Assert.False(target.Classes.Contains(":last-page"));
target.SelectedPageIndex = 2;
Assert.False(target.Classes.Contains(":first-page"));
Assert.False(target.Classes.Contains(":last-page"));
target.SelectedPageIndex = 4;
Assert.False(target.Classes.Contains(":first-page"));
Assert.True(target.Classes.Contains(":last-page"));
}
[Fact]
public void Navigation_Buttons_IsEnabled_Should_Update()
{
using var unittestApplication = UnitTestApplication.Start(TestServices.StyledWindow);
var target = new PipsPager
{
NumberOfPages = 3,
Template = GetTemplate()
};
var root = new TestRoot(target);
target.ApplyTemplate();
var prevButton = target.GetVisualDescendants().OfType<Button>().First(b => b.Name == "PART_PreviousButton");
var nextButton = target.GetVisualDescendants().OfType<Button>().First(b => b.Name == "PART_NextButton");
target.SelectedPageIndex = 0;
Assert.False(prevButton.IsEnabled);
Assert.True(nextButton.IsEnabled);
target.SelectedPageIndex = 1;
Assert.True(prevButton.IsEnabled);
Assert.True(nextButton.IsEnabled);
target.SelectedPageIndex = 2;
Assert.True(prevButton.IsEnabled);
Assert.False(nextButton.IsEnabled);
}
[Fact]
public void Horizontal_Keyboard_Navigation_Should_Work()
{
var target = new PipsPager
{
NumberOfPages = 5,
SelectedPageIndex = 1,
Orientation = Orientation.Horizontal
};
target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Right });
Assert.Equal(2, target.SelectedPageIndex);
target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Left });
Assert.Equal(1, target.SelectedPageIndex);
}
[Fact]
public void Vertical_Keyboard_Navigation_Should_Work()
{
var target = new PipsPager
{
NumberOfPages = 5,
SelectedPageIndex = 1,
Orientation = Orientation.Vertical
};
target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Down });
Assert.Equal(2, target.SelectedPageIndex);
target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Up });
Assert.Equal(1, target.SelectedPageIndex);
}
[Fact]
public void NumberOfPages_Zero_Should_Clamp_Index()
{
var target = new PipsPager();
target.NumberOfPages = 0;
target.SelectedPageIndex = 5;
Assert.Equal(0, target.SelectedPageIndex);
}
[Fact]
public void Home_Key_Should_Navigate_To_First_Page()
{
var target = new PipsPager
{
NumberOfPages = 10,
SelectedPageIndex = 7,
Orientation = Orientation.Horizontal
};
target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Home });
Assert.Equal(0, target.SelectedPageIndex);
}
[Fact]
public void End_Key_Should_Navigate_To_Last_Page()
{
var target = new PipsPager
{
NumberOfPages = 10,
SelectedPageIndex = 3,
Orientation = Orientation.Horizontal
};
target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.End });
Assert.Equal(9, target.SelectedPageIndex);
}
[Fact]
public void Home_End_Keys_Should_Work_In_Vertical_Orientation()
{
var target = new PipsPager
{
NumberOfPages = 10,
SelectedPageIndex = 5,
Orientation = Orientation.Vertical
};
target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Home });
Assert.Equal(0, target.SelectedPageIndex);
target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.End });
Assert.Equal(9, target.SelectedPageIndex);
}
[Fact]
public void Negative_NumberOfPages_Should_Be_Coerced_To_Zero()
{
var target = new PipsPager();
target.NumberOfPages = -5;
Assert.Equal(0, target.NumberOfPages);
Assert.Equal(0, target.TemplateSettings.Pips.Count);
}
[Fact]
public void Next_Button_At_Last_Page_Should_Not_Change_Index()
{
using var unittestApplication = UnitTestApplication.Start(TestServices.StyledWindow);
var target = new PipsPager
{
NumberOfPages = 3,
SelectedPageIndex = 2,
Template = GetTemplate()
};
var root = new TestRoot(target);
target.ApplyTemplate();
var nextButton = target.GetVisualDescendants().OfType<Button>().First(b => b.Name == "PART_NextButton");
nextButton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
Assert.Equal(2, target.SelectedPageIndex);
}
[Fact]
public void Previous_Button_At_First_Page_Should_Not_Change_Index()
{
using var unittestApplication = UnitTestApplication.Start(TestServices.StyledWindow);
var target = new PipsPager
{
NumberOfPages = 3,
SelectedPageIndex = 0,
Template = GetTemplate()
};
var root = new TestRoot(target);
target.ApplyTemplate();
var prevButton = target.GetVisualDescendants().OfType<Button>().First(b => b.Name == "PART_PreviousButton");
prevButton.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
Assert.Equal(0, target.SelectedPageIndex);
}
[Fact]
public void Arrow_Keys_At_Boundaries_Should_Not_Change_Index()
{
var target = new PipsPager
{
NumberOfPages = 5,
SelectedPageIndex = 0,
Orientation = Orientation.Horizontal
};
target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Left });
Assert.Equal(0, target.SelectedPageIndex);
target.SelectedPageIndex = 4;
target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Right });
Assert.Equal(4, target.SelectedPageIndex);
}
[Fact]
public void SelectedPageIndex_Default_Binding_Mode_Should_Be_TwoWay()
{
Assert.Equal(Data.BindingMode.TwoWay, PipsPager.SelectedPageIndexProperty.GetMetadata(typeof(PipsPager)).DefaultBindingMode);
}
[Fact]
public void TemplateSettings_Should_Not_Be_Externally_Settable()
{
var target = new PipsPager();
// TemplateSettings property should have a private setter (compile-time enforcement).
// Verify the property is readable and initialized.
Assert.NotNull(target.TemplateSettings);
Assert.IsType<PipsPagerTemplateSettings>(target.TemplateSettings);
}
[Fact]
public void NumberOfPages_To_Zero_Should_Clamp_SelectedPageIndex()
{
var target = new PipsPager();
target.NumberOfPages = 5;
target.SelectedPageIndex = 3;
target.NumberOfPages = 0;
Assert.Equal(0, target.SelectedPageIndex);
Assert.Equal(0, target.TemplateSettings.Pips.Count);
}
[Fact]
public void Negative_NumberOfPages_After_Having_Pages_Should_Coerce()
{
var target = new PipsPager();
target.NumberOfPages = 5;
Assert.Equal(5, target.TemplateSettings.Pips.Count);
target.NumberOfPages = -1;
Assert.Equal(0, target.NumberOfPages);
Assert.Equal(0, target.TemplateSettings.Pips.Count);
}
[Fact]
public void Preselected_Index_Should_Be_Preserved_After_Template_Apply()
{
using var app = UnitTestApplication.Start(TestServices.StyledWindow);
var target = new PipsPager
{
NumberOfPages = 20,
MaxVisiblePips = 5,
SelectedPageIndex = 15,
Template = GetTemplate()
};
var root = new TestRoot(target);
target.ApplyTemplate();
Assert.Equal(15, target.SelectedPageIndex);
Assert.True(target.Classes.Contains(":last-page") == false);
Assert.True(target.Classes.Contains(":first-page") == false);
}
[Fact]
public void Preselected_Last_Index_Should_Set_LastPage_PseudoClass()
{
var target = new PipsPager
{
NumberOfPages = 10,
SelectedPageIndex = 9
};
Assert.Equal(9, target.SelectedPageIndex);
Assert.True(target.Classes.Contains(":last-page"));
Assert.False(target.Classes.Contains(":first-page"));
}
private static FuncControlTemplate<PipsPager> GetTemplate()
{
return new FuncControlTemplate<PipsPager>((parent, scope) =>
{
return new StackPanel
{
Children =
{
new Button { Name = "PART_PreviousButton" }.RegisterInNameScope(scope),
new ListBox { Name = "PART_PipsPagerList" }.RegisterInNameScope(scope),
new Button { Name = "PART_NextButton" }.RegisterInNameScope(scope)
}
};
});
}
}
}

168
tests/Avalonia.RenderTests/Controls/PipsPagerTests.cs

@ -0,0 +1,168 @@
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Controls.Shapes;
using Avalonia.Styling;
using Avalonia.Threading;
using Xunit;
namespace Avalonia.Skia.RenderTests
{
public class PipsPagerTests : TestBase
{
public PipsPagerTests()
: base(@"Controls/PipsPager")
{
}
private static IControlTemplate CreatePipsPagerTemplate()
{
return new FuncControlTemplate<PipsPager>((control, scope) =>
{
var stackPanel = new StackPanel
{
Name = "PART_RootPanel",
Spacing = 5,
[!StackPanel.OrientationProperty] = control[!PipsPager.OrientationProperty],
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
var buttonTemplate = new FuncControlTemplate<Button>((b, s) =>
new Border
{
Background = Brushes.LightGray,
Child = new TextBlock
{
[!TextBlock.TextProperty] = b[!Button.ContentProperty],
FontFamily = TestFontFamily,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center
}
});
var prevButton = new Button
{
Name = "PART_PreviousButton",
Content = "<",
Template = buttonTemplate,
Width = 20,
Height = 20,
[!Button.IsVisibleProperty] = control[!PipsPager.IsPreviousButtonVisibleProperty]
}.RegisterInNameScope(scope);
var nextButton = new Button
{
Name = "PART_NextButton",
Content = ">",
Template = buttonTemplate,
Width = 20,
Height = 20,
[!Button.IsVisibleProperty] = control[!PipsPager.IsNextButtonVisibleProperty]
}.RegisterInNameScope(scope);
// Simple ListBox Template (ItemsPresenter)
var listBoxTemplate = new FuncControlTemplate<ListBox>((lb, s) =>
new ItemsPresenter
{
Name = "PART_ItemsPresenter",
[~ItemsPresenter.ItemsPanelProperty] = lb[~ListBox.ItemsPanelProperty],
}.RegisterInNameScope(s));
var pipsList = new ListBox
{
Name = "PART_PipsPagerList",
Template = listBoxTemplate,
[!ListBox.ItemsSourceProperty] = new Binding("TemplateSettings.Pips") { Source = control },
[!ListBox.SelectedIndexProperty] = control[!PipsPager.SelectedPageIndexProperty],
ItemsPanel = new FuncTemplate<Panel?>(() => new StackPanel
{
Spacing = 2,
[!StackPanel.OrientationProperty] = control[!PipsPager.OrientationProperty]
})
}.RegisterInNameScope(scope);
// Default Item Style
var itemStyle = new Style(x => x.OfType<ListBoxItem>());
itemStyle.Setters.Add(new Setter(ListBoxItem.TemplateProperty, new FuncControlTemplate<ListBoxItem>((item, s) =>
new Ellipse { Name="Pip", Width = 10, Height = 10 }.RegisterInNameScope(s))));
// Default Pip Fill Style
var defaultPipStyle = new Style(x => x.OfType<ListBoxItem>().Template().Name("Pip"));
defaultPipStyle.Setters.Add(new Setter(Ellipse.FillProperty, Brushes.Gray));
// Selected Item Style
var selectedStyle = new Style(x => x.OfType<ListBoxItem>().Class(":selected").Template().Name("Pip"));
selectedStyle.Setters.Add(new Setter(Ellipse.FillProperty, Brushes.Red));
pipsList.Styles.Add(itemStyle);
pipsList.Styles.Add(defaultPipStyle);
pipsList.Styles.Add(selectedStyle);
stackPanel.Children.Add(prevButton);
stackPanel.Children.Add(pipsList);
stackPanel.Children.Add(nextButton);
return stackPanel;
});
}
[Fact]
public async Task PipsPager_Default()
{
var pipsPager = new PipsPager
{
Template = CreatePipsPagerTemplate(),
NumberOfPages = 5,
SelectedPageIndex = 1
};
var target = new Border
{
Padding = new Thickness(20),
Background = Brushes.White,
Child = pipsPager,
Width = 400,
Height = 150
};
target.Measure(new Size(400, 150));
target.Arrange(new Rect(0, 0, 400, 150));
Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
await RenderToFile(target);
CompareImages();
}
[Fact]
public async Task PipsPager_Preselected_Index()
{
var pipsPager = new PipsPager
{
Template = CreatePipsPagerTemplate(),
NumberOfPages = 5,
SelectedPageIndex = 3
};
var target = new Border
{
Padding = new Thickness(20),
Background = Brushes.White,
Child = pipsPager,
Width = 400,
Height = 150
};
target.Measure(new Size(400, 150));
target.Arrange(new Rect(0, 0, 400, 150));
Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
await RenderToFile(target);
CompareImages();
}
}
}

BIN
tests/TestFiles/Skia/Controls/PipsPager/PipsPager_Default.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
tests/TestFiles/Skia/Controls/PipsPager/PipsPager_Preselected_Index.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Loading…
Cancel
Save