Browse Source

Merge branch 'master' into scroll_inertia

pull/10071/head
Max Katz 3 years ago
committed by GitHub
parent
commit
3346f6dcef
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      samples/ControlCatalog/MainView.xaml
  2. 222
      samples/ControlCatalog/Pages/ScrollSnapPage.xaml
  3. 68
      samples/ControlCatalog/Pages/ScrollSnapPage.xaml.cs
  4. 5
      samples/ControlCatalog/Pages/ScrollViewerPage.xaml
  5. 10
      src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs
  6. 4
      src/Avalonia.Base/Input/Gestures.cs
  7. 12
      src/Avalonia.Base/Input/ScrollGestureEventArgs.cs
  8. 91
      src/Avalonia.Controls/ItemsControl.cs
  9. 123
      src/Avalonia.Controls/Presenters/ItemsPresenter.cs
  10. 368
      src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs
  11. 50
      src/Avalonia.Controls/Primitives/IScrollSnapPointsInfo.cs
  12. 23
      src/Avalonia.Controls/Primitives/SnapPointsAlignment.cs
  13. 23
      src/Avalonia.Controls/Primitives/SnapPointsType.cs
  14. 64
      src/Avalonia.Controls/ScrollViewer.cs
  15. 191
      src/Avalonia.Controls/StackPanel.cs
  16. 232
      src/Avalonia.Controls/VirtualizingStackPanel.cs
  17. 2
      src/Avalonia.Themes.Fluent/Controls/ItemsControl.xaml
  18. 4
      src/Avalonia.Themes.Fluent/Controls/ListBox.xaml
  19. 4
      src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml
  20. 2
      src/Avalonia.Themes.Simple/Controls/ItemsControl.xaml
  21. 6
      src/Avalonia.Themes.Simple/Controls/ListBox.xaml
  22. 6
      src/Avalonia.Themes.Simple/Controls/ScrollViewer.xaml

5
samples/ControlCatalog/MainView.xaml

@ -144,9 +144,12 @@
<TabItem Header="RelativePanel">
<pages:RelativePanelPage />
</TabItem>
<TabItem Header="ScrollViewer">
<TabItem Header="ScrollViewer">
<pages:ScrollViewerPage />
</TabItem>
<TabItem Header="ScrollViewer Snapping">
<pages:ScrollSnapPage />
</TabItem>
<TabItem Header="Slider">
<pages:SliderPage />
</TabItem>

222
samples/ControlCatalog/Pages/ScrollSnapPage.xaml

@ -0,0 +1,222 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
d:DesignHeight="800"
d:DesignWidth="400"
x:Class="ControlCatalog.Pages.ScrollSnapPage"
xmlns:pages="using:ControlCatalog.Pages"
x:DataType="pages:ScrollSnapPageViewModel">
<StackPanel Orientation="Vertical" Spacing="4">
<TextBlock TextWrapping="Wrap"
Classes="h2">Scrollviewer can snap supported content both vertically and horizontally. Snapping occurs from scrolling with touch or pen, or using the pointer wheel.</TextBlock>
<Grid RowDefinitions="Auto, Auto, Auto, Auto, Auto">
<StackPanel Orientation="Horizontal"
Spacing="4">
<StackPanel Orientation="Vertical"
Spacing="4">
<TextBlock Text="Snap Point Type" />
<ComboBox Items="{Binding AvailableSnapPointsType}"
SelectedItem="{Binding SnapPointsType}" />
</StackPanel>
<StackPanel Orientation="Vertical"
Spacing="4">
<TextBlock Text="Snap Point Alignment" />
<ComboBox Items="{Binding AvailableSnapPointsAlignment}"
SelectedItem="{Binding SnapPointsAlignment}" />
</StackPanel>
<ToggleSwitch IsChecked="{Binding AreSnapPointsRegular}"
OffContent="No"
OnContent="Yes"
Content="Are Snap Points regular?" />
</StackPanel>
<TextBlock TextWrapping="Wrap"
Grid.Row="1"
Margin="0,10"
Classes="h2">Vertical Snapping</TextBlock>
<Border
BorderBrush="Green"
BorderThickness="1"
Padding="0"
Grid.Row="2"
Margin="10, 5">
<ScrollViewer x:Name="VerticalSnapsScrollViewer"
VerticalSnapPointsType="{Binding SnapPointsType}"
VerticalSnapPointsAlignment="{Binding SnapPointsAlignment}"
HorizontalAlignment="Stretch"
Height="350"
HorizontalScrollBarVisibility="Disabled">
<StackPanel AreVerticalSnapPointsRegular="{Binding AreSnapPointsRegular}"
Orientation="Vertical"
HorizontalAlignment="Stretch">
<Border Padding="5, 30"
BorderBrush="Red"
HorizontalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
Text="Child 1"/>
</Border>
<Border Padding="5, 30"
BorderBrush="Red"
HorizontalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
Text="Child 2"/>
</Border>
<Border Padding="5, 20"
BorderBrush="Red"
HorizontalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
Text="Child 3"/>
</Border>
<Border Padding="5, 30"
BorderBrush="Red"
HorizontalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
Text="Child 4"/>
</Border>
<Border Padding="5, 30"
BorderBrush="Red"
HorizontalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
Text="Child 5"/>
</Border>
<Border Padding="5, 30"
BorderBrush="Red"
HorizontalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
Text="Child 6"/>
</Border>
<Border Padding="5,8"
BorderBrush="Red"
HorizontalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
Text="Child 7"/>
</Border>
<Border Padding="5, 30"
BorderBrush="Red"
HorizontalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
Text="Child 8"/>
</Border>
<Border Padding="5,4"
BorderBrush="Red"
HorizontalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
Text="Child 9"/>
</Border>
<Border Padding="5, 30"
BorderBrush="Red"
HorizontalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
Text="Child 20"/>
</Border>
<Border Padding="5, 30"
BorderBrush="Red"
HorizontalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
Text="Child 11"/>
</Border>
<Border Padding="5, 30"
BorderBrush="Red"
HorizontalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
Text="Child 12"/>
</Border>
</StackPanel>
</ScrollViewer>
</Border>
<TextBlock TextWrapping="Wrap"
Grid.Row="3"
Margin="0,10"
Classes="h2">Horizontal Snapping</TextBlock>
<Border
BorderBrush="Green"
BorderThickness="1"
Padding="0"
Grid.Row="4"
Margin="10, 10">
<ScrollViewer x:Name="HorizontalSnapsScrollViewer"
HorizontalSnapPointsType="{Binding SnapPointsType}"
HorizontalSnapPointsAlignment="{Binding SnapPointsAlignment}"
HorizontalAlignment="Stretch"
Height="350"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<StackPanel AreHorizontalSnapPointsRegular="{Binding AreSnapPointsRegular}"
Orientation="Horizontal"
HorizontalAlignment="Stretch">
<Border Padding="5, 30"
Width="300"
BorderBrush="Red"
HorizontalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
VerticalAlignment="Center"
Text="Child 1"/>
</Border>
<Border Padding="5, 30"
Width="300"
BorderBrush="Red"
VerticalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
VerticalAlignment="Center"
Text="Child 2"/>
</Border>
<Border Padding="5, 20"
Width="300"
BorderBrush="Red"
VerticalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
VerticalAlignment="Center"
Text="Child 3"/>
</Border>
<Border Padding="5, 30"
Width="300"
BorderBrush="Red"
VerticalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
VerticalAlignment="Center"
Text="Child 4"/>
</Border>
<Border Padding="5, 30"
Width="300"
BorderBrush="Red"
VerticalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
VerticalAlignment="Center"
Text="Child 5"/>
</Border>
<Border Padding="5, 30"
Width="300"
BorderBrush="Red"
VerticalAlignment="Stretch"
BorderThickness="1">
<TextBlock FontWeight="Bold"
VerticalAlignment="Center"
Text="Child 6"/>
</Border>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</StackPanel>
</UserControl>

68
samples/ControlCatalog/Pages/ScrollSnapPage.xaml.cs

@ -0,0 +1,68 @@
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Markup.Xaml;
using MiniMvvm;
namespace ControlCatalog.Pages
{
public class ScrollSnapPageViewModel : ViewModelBase
{
private SnapPointsType _snapPointsType;
private SnapPointsAlignment _snapPointsAlignment;
private bool _areSnapPointsRegular;
public ScrollSnapPageViewModel()
{
AvailableSnapPointsType = new List<SnapPointsType>()
{
SnapPointsType.None,
SnapPointsType.Mandatory,
SnapPointsType.MandatorySingle
};
AvailableSnapPointsAlignment = new List<SnapPointsAlignment>()
{
SnapPointsAlignment.Near,
SnapPointsAlignment.Center,
SnapPointsAlignment.Far,
};
}
public bool AreSnapPointsRegular
{
get => _areSnapPointsRegular;
set => this.RaiseAndSetIfChanged(ref _areSnapPointsRegular, value);
}
public SnapPointsType SnapPointsType
{
get => _snapPointsType;
set => this.RaiseAndSetIfChanged(ref _snapPointsType, value);
}
public SnapPointsAlignment SnapPointsAlignment
{
get => _snapPointsAlignment;
set => this.RaiseAndSetIfChanged(ref _snapPointsAlignment, value);
}
public List<SnapPointsType> AvailableSnapPointsType { get; }
public List<SnapPointsAlignment> AvailableSnapPointsAlignment { get; }
}
public class ScrollSnapPage : UserControl
{
public ScrollSnapPage()
{
this.InitializeComponent();
DataContext = new ScrollSnapPageViewModel();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}
}

5
samples/ControlCatalog/Pages/ScrollViewerPage.xaml

@ -3,8 +3,8 @@
xmlns:pages="using:ControlCatalog.Pages"
x:Class="ControlCatalog.Pages.ScrollViewerPage"
x:DataType="pages:ScrollViewerPageViewModel">
<StackPanel Orientation="Vertical" Spacing="4">
<TextBlock Classes="h2">Allows for horizontal and vertical content scrolling.</TextBlock>
<StackPanel Orientation="Vertical" Spacing="20">
<TextBlock TextWrapping="Wrap" Classes="h2">Allows for horizontal and vertical content scrolling. Supports snapping on touch and pointer wheel scrolling.</TextBlock>
<Grid ColumnDefinitions="Auto, *">
<StackPanel Orientation="Vertical" Spacing="4">
@ -33,6 +33,5 @@
Source="/Assets/delicate-arch-896885_640.jpg" />
</ScrollViewer>
</Grid>
</StackPanel>
</UserControl>

10
src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs

@ -8,6 +8,10 @@ namespace Avalonia.Input.GestureRecognizers
: StyledElement, // It's not an "element" in any way, shape or form, but TemplateBinding refuse to work otherwise
IGestureRecognizer
{
// Pixels per second speed that is considered to be the stop of inertial scroll
internal const double InertialScrollSpeedEnd = 5;
public const double InertialResistance = 0.15;
private bool _scrolling;
private Point _trackedRootPoint;
private IPointer? _tracking;
@ -116,9 +120,6 @@ namespace Avalonia.Input.GestureRecognizers
}
}
// Pixels per second speed that is considered to be the stop of inertial scroll
private const double InertialScrollSpeedEnd = 5;
public void PointerMoved(PointerEventArgs e)
{
if (e.Pointer == _tracking)
@ -196,6 +197,7 @@ namespace Avalonia.Input.GestureRecognizers
var savedGestureId = _gestureId;
var st = Stopwatch.StartNew();
var lastTime = TimeSpan.Zero;
_target!.RaiseEvent(new ScrollGestureInertiaStartingEventArgs(_gestureId, _inertia));
DispatcherTimer.Run(() =>
{
// Another gesture has started, finish the current one
@ -207,7 +209,7 @@ namespace Avalonia.Input.GestureRecognizers
var elapsedSinceLastTick = st.Elapsed - lastTime;
lastTime = st.Elapsed;
var speed = _inertia * Math.Pow(0.15, st.Elapsed.TotalSeconds);
var speed = _inertia * Math.Pow(InertialResistance, st.Elapsed.TotalSeconds);
var distance = speed * elapsedSinceLastTick.TotalSeconds;
var scrollGestureEventArgs = new ScrollGestureEventArgs(_gestureId, distance);
_target!.RaiseEvent(scrollGestureEventArgs);

4
src/Avalonia.Base/Input/Gestures.cs

@ -45,6 +45,10 @@ namespace Avalonia.Input
RoutedEvent.Register<ScrollGestureEventArgs>(
"ScrollGesture", RoutingStrategies.Bubble, typeof(Gestures));
public static readonly RoutedEvent<ScrollGestureInertiaStartingEventArgs> ScrollGestureInertiaStartingEvent =
RoutedEvent.Register<ScrollGestureInertiaStartingEventArgs>(
"ScrollGestureInertiaStarting", RoutingStrategies.Bubble, typeof(Gestures));
public static readonly RoutedEvent<ScrollGestureEndedEventArgs> ScrollGestureEndedEvent =
RoutedEvent.Register<ScrollGestureEndedEventArgs>(
"ScrollGestureEnded", RoutingStrategies.Bubble, typeof(Gestures));

12
src/Avalonia.Base/Input/ScrollGestureEventArgs.cs

@ -30,4 +30,16 @@ namespace Avalonia.Input
Id = id;
}
}
public class ScrollGestureInertiaStartingEventArgs : RoutedEventArgs
{
public int Id { get; }
public Vector Inertia { get; }
internal ScrollGestureInertiaStartingEventArgs(int id, Vector inertia) : base(Gestures.ScrollGestureInertiaStartingEvent)
{
Id = id;
Inertia = inertia;
}
}
}

91
src/Avalonia.Controls/ItemsControl.cs

@ -12,6 +12,8 @@ using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.Metadata;
using Avalonia.Styling;
@ -23,7 +25,7 @@ namespace Avalonia.Controls
/// Displays a collection of items.
/// </summary>
[PseudoClasses(":empty", ":singleitem")]
public class ItemsControl : TemplatedControl, IChildIndexProvider
public class ItemsControl : TemplatedControl, IChildIndexProvider, IScrollSnapPointsInfo
{
/// <summary>
/// The default value for the <see cref="ItemsPanel"/> property.
@ -72,6 +74,18 @@ namespace Avalonia.Controls
/// </summary>
public static readonly StyledProperty<IBinding?> DisplayMemberBindingProperty =
AvaloniaProperty.Register<ItemsControl, IBinding?>(nameof(DisplayMemberBinding));
/// <summary>
/// Defines the <see cref="AreHorizontalSnapPointsRegular"/> property.
/// </summary>
public static readonly StyledProperty<bool> AreHorizontalSnapPointsRegularProperty =
AvaloniaProperty.Register<ItemsControl, bool>(nameof(AreHorizontalSnapPointsRegular));
/// <summary>
/// Defines the <see cref="AreVerticalSnapPointsRegular"/> property.
/// </summary>
public static readonly StyledProperty<bool> AreVerticalSnapPointsRegularProperty =
AvaloniaProperty.Register<ItemsControl, bool>(nameof(AreVerticalSnapPointsRegular));
/// <summary>
/// Gets or sets the <see cref="IBinding"/> to use for binding to the display member of each item.
@ -91,6 +105,8 @@ namespace Avalonia.Controls
private IDataTemplate? _displayMemberItemTemplate;
private Tuple<int, Control>? _containerBeingPrepared;
private ScrollViewer? _scrollViewer;
private ItemsPresenter? _itemsPresenter;
private IScrollSnapPointsInfo? _scrolSnapPointInfo;
/// <summary>
/// Initializes a new instance of the <see cref="ItemsControl"/> class.
@ -203,6 +219,63 @@ namespace Avalonia.Controls
remove => _childIndexChanged -= value;
}
public event EventHandler<RoutedEventArgs> HorizontalSnapPointsChanged
{
add
{
if (_itemsPresenter != null)
{
_itemsPresenter.HorizontalSnapPointsChanged += value;
}
}
remove
{
if (_itemsPresenter != null)
{
_itemsPresenter.HorizontalSnapPointsChanged -= value;
}
}
}
public event EventHandler<RoutedEventArgs> VerticalSnapPointsChanged
{
add
{
if (_itemsPresenter != null)
{
_itemsPresenter.VerticalSnapPointsChanged += value;
}
}
remove
{
if (_itemsPresenter != null)
{
_itemsPresenter.VerticalSnapPointsChanged -= value;
}
}
}
/// <summary>
/// Gets or sets whether the horizontal snap points for the <see cref="ItemsControl"/> are equidistant from each other.
/// </summary>
public bool AreHorizontalSnapPointsRegular
{
get { return GetValue(AreHorizontalSnapPointsRegularProperty); }
set { SetValue(AreHorizontalSnapPointsRegularProperty, value); }
}
/// <summary>
/// Gets or sets whether the vertical snap points for the <see cref="ItemsControl"/> are equidistant from each other.
/// </summary>
public bool AreVerticalSnapPointsRegular
{
get { return GetValue(AreVerticalSnapPointsRegularProperty); }
set { SetValue(AreVerticalSnapPointsRegularProperty, value); }
}
/// <summary>
/// Returns the container for the item at the specified index.
/// </summary>
@ -255,7 +328,6 @@ namespace Avalonia.Controls
/// </summary>
public IEnumerable<Control> GetRealizedContainers() => Presenter?.GetRealizedContainers() ?? Array.Empty<Control>();
/// <summary>
/// Creates or a container that can be used to display an item.
/// </summary>
@ -355,6 +427,9 @@ namespace Avalonia.Controls
{
base.OnApplyTemplate(e);
_scrollViewer = e.NameScope.Find<ScrollViewer>("PART_ScrollViewer");
_itemsPresenter = e.NameScope.Find<ItemsPresenter>("PART_ItemsPresenter");
_scrolSnapPointInfo = _itemsPresenter as IScrollSnapPointsInfo;
}
/// <summary>
@ -671,5 +746,17 @@ namespace Avalonia.Controls
count = ItemsView.Count;
return true;
}
public IReadOnlyList<double> GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment)
{
return _itemsPresenter?.GetIrregularSnapPoints(orientation, snapPointsAlignment) ?? new List<double>();
}
public double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset)
{
offset = 0;
return _itemsPresenter?.GetRegularSnapPoints(orientation, snapPointsAlignment, out offset) ?? 0;
}
}
}

123
src/Avalonia.Controls/Presenters/ItemsPresenter.cs

@ -3,13 +3,15 @@ using System.Collections.Generic;
using System.Diagnostics;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
namespace Avalonia.Controls.Presenters
{
/// <summary>
/// Presents items inside an <see cref="Avalonia.Controls.ItemsControl"/>.
/// </summary>
public class ItemsPresenter : Control, ILogicalScrollable
public class ItemsPresenter : Control, ILogicalScrollable, IScrollSnapPointsInfo
{
/// <summary>
/// Defines the <see cref="ItemsPanel"/> property.
@ -19,8 +21,37 @@ namespace Avalonia.Controls.Presenters
private PanelContainerGenerator? _generator;
private ILogicalScrollable? _logicalScrollable;
private IScrollSnapPointsInfo? _scrollSnapPointsInfo;
private EventHandler? _scrollInvalidated;
/// <summary>
/// Defines the <see cref="AreHorizontalSnapPointsRegular"/> property.
/// </summary>
public static readonly StyledProperty<bool> AreHorizontalSnapPointsRegularProperty =
AvaloniaProperty.Register<ItemsControl, bool>(nameof(AreHorizontalSnapPointsRegular));
/// <summary>
/// Defines the <see cref="AreVerticalSnapPointsRegular"/> property.
/// </summary>
public static readonly StyledProperty<bool> AreVerticalSnapPointsRegularProperty =
AvaloniaProperty.Register<ItemsControl, bool>(nameof(AreVerticalSnapPointsRegular));
/// <summary>
/// Defines the <see cref="HorizontalSnapPointsChanged"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> HorizontalSnapPointsChangedEvent =
RoutedEvent.Register<StackPanel, RoutedEventArgs>(
nameof(HorizontalSnapPointsChanged),
RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="VerticalSnapPointsChanged"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> VerticalSnapPointsChangedEvent =
RoutedEvent.Register<StackPanel, RoutedEventArgs>(
nameof(VerticalSnapPointsChanged),
RoutingStrategies.Bubble);
static ItemsPresenter()
{
KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue(
@ -83,12 +114,48 @@ namespace Avalonia.Controls.Presenters
}
}
/// <summary>
/// Occurs when the measurements for horizontal snap points change.
/// </summary>
public event EventHandler<RoutedEventArgs>? HorizontalSnapPointsChanged
{
add => AddHandler(HorizontalSnapPointsChangedEvent, value);
remove => RemoveHandler(HorizontalSnapPointsChangedEvent, value);
}
/// <summary>
/// Occurs when the measurements for vertical snap points change.
/// </summary>
public event EventHandler<RoutedEventArgs>? VerticalSnapPointsChanged
{
add => AddHandler(VerticalSnapPointsChangedEvent, value);
remove => RemoveHandler(VerticalSnapPointsChangedEvent, value);
}
bool ILogicalScrollable.IsLogicalScrollEnabled => _logicalScrollable?.IsLogicalScrollEnabled ?? false;
Size ILogicalScrollable.ScrollSize => _logicalScrollable?.ScrollSize ?? default;
Size ILogicalScrollable.PageScrollSize => _logicalScrollable?.PageScrollSize ?? default;
Size IScrollable.Extent => _logicalScrollable?.Extent ?? default;
Size IScrollable.Viewport => _logicalScrollable?.Viewport ?? default;
/// <summary>
/// Gets or sets whether the horizontal snap points for the <see cref="ItemsControl"/> are equidistant from each other.
/// </summary>
public bool AreHorizontalSnapPointsRegular
{
get { return GetValue(AreHorizontalSnapPointsRegularProperty); }
set { SetValue(AreHorizontalSnapPointsRegularProperty, value); }
}
/// <summary>
/// Gets or sets whether the vertical snap points for the <see cref="ItemsControl"/> are equidistant from each other.
/// </summary>
public bool AreVerticalSnapPointsRegular
{
get { return GetValue(AreVerticalSnapPointsRegularProperty); }
set { SetValue(AreVerticalSnapPointsRegularProperty, value); }
}
public override sealed void ApplyTemplate()
{
if (Panel is null && ItemsControl is not null)
@ -100,14 +167,36 @@ namespace Avalonia.Controls.Presenters
Panel = ItemsPanel.Build();
Panel.SetValue(TemplatedParentProperty, TemplatedParent);
_scrollSnapPointsInfo = Panel as IScrollSnapPointsInfo;
LogicalChildren.Add(Panel);
VisualChildren.Add(Panel);
if (_scrollSnapPointsInfo != null)
{
_scrollSnapPointsInfo.AreVerticalSnapPointsRegular = AreVerticalSnapPointsRegular;
_scrollSnapPointsInfo.AreHorizontalSnapPointsRegular = AreHorizontalSnapPointsRegular;
}
if (Panel is VirtualizingPanel v)
v.Attach(ItemsControl);
else
CreateSimplePanelGenerator();
if(Panel is IScrollSnapPointsInfo scrollSnapPointsInfo)
{
scrollSnapPointsInfo.VerticalSnapPointsChanged += (s, e) =>
{
e.RoutedEvent = VerticalSnapPointsChangedEvent;
RaiseEvent(e);
};
scrollSnapPointsInfo.HorizontalSnapPointsChanged += (s, e) =>
{
e.RoutedEvent = HorizontalSnapPointsChangedEvent;
RaiseEvent(e);
};
}
_logicalScrollable = Panel as ILogicalScrollable;
if (_logicalScrollable is not null)
@ -151,6 +240,16 @@ namespace Avalonia.Controls.Presenters
ResetState();
InvalidateMeasure();
}
else if(change.Property == AreHorizontalSnapPointsRegularProperty)
{
if (_scrollSnapPointsInfo != null)
_scrollSnapPointsInfo.AreHorizontalSnapPointsRegular = AreHorizontalSnapPointsRegular;
}
else if (change.Property == AreVerticalSnapPointsRegularProperty)
{
if (_scrollSnapPointsInfo != null)
_scrollSnapPointsInfo.AreVerticalSnapPointsRegular = AreVerticalSnapPointsRegular;
}
}
internal void Refresh()
@ -204,5 +303,27 @@ namespace Avalonia.Controls.Presenters
}
private void OnLogicalScrollInvalidated(object? sender, EventArgs e) => _scrollInvalidated?.Invoke(this, e);
public IReadOnlyList<double> GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment)
{
if(Panel is IScrollSnapPointsInfo scrollSnapPointsInfo)
{
return scrollSnapPointsInfo.GetIrregularSnapPoints(orientation, snapPointsAlignment);
}
return new List<double>();
}
public double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset)
{
if (Panel is IScrollSnapPointsInfo scrollSnapPointsInfo)
{
return scrollSnapPointsInfo.GetRegularSnapPoints(orientation, snapPointsAlignment, out offset);
}
offset = 0;
return 0;
}
}
}

368
src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs

@ -3,6 +3,7 @@ using System.Collections.Generic;
using Avalonia.Reactive;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Input.GestureRecognizers;
using Avalonia.Utilities;
using Avalonia.VisualTree;
@ -14,6 +15,7 @@ namespace Avalonia.Controls.Presenters
public class ScrollContentPresenter : ContentPresenter, IPresenter, IScrollable, IScrollAnchorProvider
{
private const double EdgeDetectionTolerance = 0.1;
private const int ProximityPoints = 10;
/// <summary>
/// Defines the <see cref="CanHorizontallyScroll"/> property.
@ -57,6 +59,30 @@ namespace Avalonia.Controls.Presenters
o => o.Viewport,
(o, v) => o.Viewport = v);
/// <summary>
/// Defines the <see cref="HorizontalSnapPointsType"/> property.
/// </summary>
public static readonly StyledProperty<SnapPointsType> HorizontalSnapPointsTypeProperty =
ScrollViewer.HorizontalSnapPointsTypeProperty.AddOwner<ScrollContentPresenter>();
/// <summary>
/// Defines the <see cref="VerticalSnapPointsType"/> property.
/// </summary>
public static readonly StyledProperty<SnapPointsType> VerticalSnapPointsTypeProperty =
ScrollViewer.VerticalSnapPointsTypeProperty.AddOwner<ScrollContentPresenter>();
/// <summary>
/// Defines the <see cref="HorizontalSnapPointsAlignment"/> property.
/// </summary>
public static readonly StyledProperty<SnapPointsAlignment> HorizontalSnapPointsAlignmentProperty =
ScrollViewer.HorizontalSnapPointsAlignmentProperty.AddOwner<ScrollContentPresenter>();
/// <summary>
/// Defines the <see cref="VerticalSnapPointsAlignment"/> property.
/// </summary>
public static readonly StyledProperty<SnapPointsAlignment> VerticalSnapPointsAlignmentProperty =
ScrollViewer.VerticalSnapPointsAlignmentProperty.AddOwner<ScrollContentPresenter>();
/// <summary>
/// Defines the <see cref="IsScrollChainingEnabled"/> property.
/// </summary>
@ -71,10 +97,19 @@ namespace Avalonia.Controls.Presenters
private IDisposable? _logicalScrollSubscription;
private Size _viewport;
private Dictionary<int, Vector>? _activeLogicalGestureScrolls;
private Dictionary<int, Vector>? _scrollGestureSnapPoints;
private List<Control>? _anchorCandidates;
private Control? _anchorElement;
private Rect _anchorElementBounds;
private bool _isAnchorElementDirty;
private bool _areVerticalSnapPointsRegular;
private bool _areHorizontalSnapPointsRegular;
private IReadOnlyList<double>? _horizontalSnapPoints;
private double _horizontalSnapPoint;
private IReadOnlyList<double>? _verticalSnapPoints;
private double _verticalSnapPoint;
private double _verticalSnapPointOffset;
private double _horizontalSnapPointOffset;
/// <summary>
/// Initializes static members of the <see cref="ScrollContentPresenter"/> class.
@ -93,6 +128,7 @@ namespace Avalonia.Controls.Presenters
AddHandler(RequestBringIntoViewEvent, BringIntoViewRequested);
AddHandler(Gestures.ScrollGestureEvent, OnScrollGesture);
AddHandler(Gestures.ScrollGestureEndedEvent, OnScrollGestureEnded);
AddHandler(Gestures.ScrollGestureInertiaStartingEvent, OnScrollGestureInertiaStartingEnded);
this.GetObservable(ChildProperty).Subscribe(UpdateScrollableSubscription);
}
@ -142,6 +178,42 @@ namespace Avalonia.Controls.Presenters
private set { SetAndRaise(ViewportProperty, ref _viewport, value); }
}
/// <summary>
/// Gets or sets how scroll gesture reacts to the snap points along the horizontal axis.
/// </summary>
public SnapPointsType HorizontalSnapPointsType
{
get => GetValue(HorizontalSnapPointsTypeProperty);
set => SetValue(HorizontalSnapPointsTypeProperty, value);
}
/// <summary>
/// Gets or sets how scroll gesture reacts to the snap points along the vertical axis.
/// </summary>
public SnapPointsType VerticalSnapPointsType
{
get => GetValue(VerticalSnapPointsTypeProperty);
set => SetValue(VerticalSnapPointsTypeProperty, value);
}
/// <summary>
/// Gets or sets how the existing snap points are horizontally aligned versus the initial viewport.
/// </summary>
public SnapPointsAlignment HorizontalSnapPointsAlignment
{
get => GetValue(HorizontalSnapPointsAlignmentProperty);
set => SetValue(HorizontalSnapPointsAlignmentProperty, value);
}
/// <summary>
/// Gets or sets how the existing snap points are vertically aligned versus the initial viewport.
/// </summary>
public SnapPointsAlignment VerticalSnapPointsAlignment
{
get => GetValue(VerticalSnapPointsAlignmentProperty);
set => SetValue(VerticalSnapPointsAlignmentProperty, value);
}
/// <summary>
/// Gets or sets if scroll chaining is enabled. The default value is true.
/// </summary>
@ -424,6 +496,25 @@ namespace Avalonia.Controls.Presenters
}
Vector newOffset = new Vector(x, y);
if (_scrollGestureSnapPoints?.TryGetValue(e.Id, out var snapPoint) == true)
{
double xOffset = x;
double yOffset = y;
if (HorizontalSnapPointsType != SnapPointsType.None)
{
xOffset = delta.X < 0 ? Math.Max(snapPoint.X, newOffset.X) : Math.Min(snapPoint.X, newOffset.X);
}
if (VerticalSnapPointsType != SnapPointsType.None)
{
yOffset = delta.Y < 0 ? Math.Max(snapPoint.Y, newOffset.Y) : Math.Min(snapPoint.Y, newOffset.Y);
}
newOffset = new Vector(xOffset, yOffset);
}
bool offsetChanged = newOffset != Offset;
Offset = newOffset;
@ -434,7 +525,65 @@ namespace Avalonia.Controls.Presenters
}
private void OnScrollGestureEnded(object? sender, ScrollGestureEndedEventArgs e)
=> _activeLogicalGestureScrolls?.Remove(e.Id);
{
_activeLogicalGestureScrolls?.Remove(e.Id);
_scrollGestureSnapPoints?.Remove(e.Id);
Offset = SnapOffset(Offset);
}
private void OnScrollGestureInertiaStartingEnded(object? sender, ScrollGestureInertiaStartingEventArgs e)
{
if (Content is not IScrollSnapPointsInfo)
return;
if (_scrollGestureSnapPoints == null)
_scrollGestureSnapPoints = new Dictionary<int, Vector>();
var offset = Offset;
if (HorizontalSnapPointsType != SnapPointsType.None && VerticalSnapPointsType != SnapPointsType.None)
{
return;
}
double xDistance = 0;
double yDistance = 0;
if (HorizontalSnapPointsType != SnapPointsType.None)
{
xDistance = HorizontalSnapPointsType == SnapPointsType.Mandatory ? GetDistance(e.Inertia.X) : 0;
}
if (VerticalSnapPointsType != SnapPointsType.None)
{
yDistance = VerticalSnapPointsType == SnapPointsType.Mandatory ? GetDistance(e.Inertia.Y) : 0;
}
offset = new Vector(offset.X + xDistance, offset.Y + yDistance);
System.Diagnostics.Debug.WriteLine($"{offset}");
_scrollGestureSnapPoints.Add(e.Id, SnapOffset(offset));
double GetDistance(double speed)
{
var time = Math.Log(ScrollGestureRecognizer.InertialScrollSpeedEnd / Math.Abs(speed)) / Math.Log(ScrollGestureRecognizer.InertialResistance);
double timeElapsed = 0, distance = 0, step = 0;
while (timeElapsed <= time)
{
double s = speed * Math.Pow(ScrollGestureRecognizer.InertialResistance, timeElapsed);
distance += (s * step);
timeElapsed += 0.016f;
step = 0.016f;
}
return distance;
}
}
/// <inheritdoc/>
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
@ -458,6 +607,30 @@ namespace Avalonia.Controls.Presenters
if (Extent.Height > Viewport.Height)
{
double height = isLogical ? scrollable!.ScrollSize.Height : 50;
if(VerticalSnapPointsType == SnapPointsType.MandatorySingle && Content is IScrollSnapPointsInfo)
{
if(_areVerticalSnapPointsRegular)
{
height = _verticalSnapPoint;
}
else if(_verticalSnapPoints != null)
{
double yOffset = Offset.Y;
switch (VerticalSnapPointsAlignment)
{
case SnapPointsAlignment.Center:
yOffset += Viewport.Height / 2;
break;
case SnapPointsAlignment.Far:
yOffset += Viewport.Height;
break;
}
var snapPoint = FindNearestSnapPoint(_verticalSnapPoints, yOffset, out var lowerSnapPoint);
height = snapPoint - lowerSnapPoint;
}
}
y += -delta.Y * height;
y = Math.Max(y, 0);
y = Math.Min(y, Extent.Height - Viewport.Height);
@ -466,12 +639,37 @@ namespace Avalonia.Controls.Presenters
if (Extent.Width > Viewport.Width)
{
double width = isLogical ? scrollable!.ScrollSize.Width : 50;
if (HorizontalSnapPointsType == SnapPointsType.MandatorySingle && Content is IScrollSnapPointsInfo)
{
if (_areHorizontalSnapPointsRegular)
{
width = _horizontalSnapPoint;
}
else if(_horizontalSnapPoints != null)
{
double xOffset = Offset.X;
switch (VerticalSnapPointsAlignment)
{
case SnapPointsAlignment.Center:
xOffset += Viewport.Width / 2;
break;
case SnapPointsAlignment.Far:
xOffset += Viewport.Width;
break;
}
var snapPoint = FindNearestSnapPoint(_horizontalSnapPoints, xOffset, out var lowerSnapPoint);
width = snapPoint - lowerSnapPoint;
}
}
x += -delta.X * width;
x = Math.Max(x, 0);
x = Math.Min(x, Extent.Width - Viewport.Width);
}
Vector newOffset = new Vector(x, y);
Vector newOffset = SnapOffset(new Vector(x, y));
bool offsetChanged = newOffset != Offset;
Offset = newOffset;
@ -485,10 +683,36 @@ namespace Avalonia.Controls.Presenters
{
InvalidateArrange();
}
else if (change.Property == ContentProperty)
{
if (change.OldValue is IScrollSnapPointsInfo oldSnapPointsInfo)
{
oldSnapPointsInfo.VerticalSnapPointsChanged -= ScrollSnapPointsInfoSnapPointsChanged;
oldSnapPointsInfo.HorizontalSnapPointsChanged += ScrollSnapPointsInfoSnapPointsChanged;
}
if (Content is IScrollSnapPointsInfo scrollSnapPointsInfo)
{
scrollSnapPointsInfo.VerticalSnapPointsChanged += ScrollSnapPointsInfoSnapPointsChanged;
scrollSnapPointsInfo.HorizontalSnapPointsChanged += ScrollSnapPointsInfoSnapPointsChanged;
}
UpdateSnapPoints();
}
else if (change.Property == HorizontalSnapPointsAlignmentProperty ||
change.Property == VerticalSnapPointsAlignmentProperty)
{
UpdateSnapPoints();
}
base.OnPropertyChanged(change);
}
private void ScrollSnapPointsInfoSnapPointsChanged(object? sender, Interactivity.RoutedEventArgs e)
{
UpdateSnapPoints();
}
private void BringIntoViewRequested(object? sender, RequestBringIntoViewEventArgs e)
{
if (e.TargetObject is not null)
@ -635,5 +859,145 @@ namespace Avalonia.Controls.Presenters
bounds = p.HasValue ? new Rect(p.Value, control.Bounds.Size) : default;
return p.HasValue;
}
private void UpdateSnapPoints()
{
if (Content is IScrollSnapPointsInfo scrollSnapPointsInfo)
{
_areVerticalSnapPointsRegular = scrollSnapPointsInfo.AreVerticalSnapPointsRegular;
_areHorizontalSnapPointsRegular = scrollSnapPointsInfo.AreHorizontalSnapPointsRegular;
if (!_areVerticalSnapPointsRegular)
{
_verticalSnapPoints = scrollSnapPointsInfo.GetIrregularSnapPoints(Layout.Orientation.Vertical, VerticalSnapPointsAlignment);
}
else
{
_verticalSnapPoints = new List<double>();
_verticalSnapPoint = scrollSnapPointsInfo.GetRegularSnapPoints(Layout.Orientation.Vertical, VerticalSnapPointsAlignment, out _verticalSnapPointOffset);
}
if (!_areHorizontalSnapPointsRegular)
{
_horizontalSnapPoints = scrollSnapPointsInfo.GetIrregularSnapPoints(Layout.Orientation.Horizontal, HorizontalSnapPointsAlignment);
}
else
{
_horizontalSnapPoints = new List<double>();
_horizontalSnapPoint = scrollSnapPointsInfo.GetRegularSnapPoints(Layout.Orientation.Vertical, VerticalSnapPointsAlignment, out _horizontalSnapPointOffset);
}
}
else
{
_horizontalSnapPoints = new List<double>();
_verticalSnapPoints = new List<double>();
}
}
private Vector SnapOffset(Vector offset)
{
if(Content is not IScrollSnapPointsInfo)
return offset;
var diff = GetAlignedDiff();
if (VerticalSnapPointsType != SnapPointsType.None)
{
offset = new Vector(offset.X, offset.Y + diff.Y);
double nearestSnapPoint = offset.Y;
if (_areVerticalSnapPointsRegular)
{
var minSnapPoint = (int)(offset.Y / _verticalSnapPoint) * _verticalSnapPoint + _verticalSnapPointOffset;
var maxSnapPoint = minSnapPoint + _verticalSnapPoint;
var midPoint = (minSnapPoint + maxSnapPoint) / 2;
nearestSnapPoint = offset.Y < midPoint ? minSnapPoint : maxSnapPoint;
}
else if (_verticalSnapPoints != null && _verticalSnapPoints.Count > 0)
{
var higherSnapPoint = FindNearestSnapPoint(_verticalSnapPoints, offset.Y, out var lowerSnapPoint);
var midPoint = (lowerSnapPoint + higherSnapPoint) / 2;
nearestSnapPoint = offset.Y < midPoint ? lowerSnapPoint : higherSnapPoint;
}
offset = new Vector(offset.X, nearestSnapPoint - diff.Y);
}
if (HorizontalSnapPointsType != SnapPointsType.None)
{
offset = new Vector(offset.X + diff.X, offset.Y);
double nearestSnapPoint = offset.X;
if (_areHorizontalSnapPointsRegular)
{
var minSnapPoint = (int)(offset.X / _horizontalSnapPoint) * _horizontalSnapPoint + _horizontalSnapPointOffset;
var maxSnapPoint = minSnapPoint + _horizontalSnapPoint;
var midPoint = (minSnapPoint + maxSnapPoint) / 2;
nearestSnapPoint = offset.X < midPoint ? minSnapPoint : maxSnapPoint;
}
else if (_horizontalSnapPoints != null && _horizontalSnapPoints.Count > 0)
{
var higherSnapPoint = FindNearestSnapPoint(_horizontalSnapPoints, offset.X, out var lowerSnapPoint);
var midPoint = (lowerSnapPoint + higherSnapPoint) / 2;
nearestSnapPoint = offset.X < midPoint ? lowerSnapPoint : higherSnapPoint;
}
offset = new Vector(nearestSnapPoint - diff.X, offset.Y);
}
Vector GetAlignedDiff()
{
var vector = offset;
switch (VerticalSnapPointsAlignment)
{
case SnapPointsAlignment.Center:
vector += new Vector(0, Viewport.Height / 2);
break;
case SnapPointsAlignment.Far:
vector += new Vector(0, Viewport.Height);
break;
}
switch (HorizontalSnapPointsAlignment)
{
case SnapPointsAlignment.Center:
vector += new Vector(Viewport.Width / 2, 0);
break;
case SnapPointsAlignment.Far:
vector += new Vector(Viewport.Width, 0);
break;
}
return vector - offset;
}
return offset;
}
private static double FindNearestSnapPoint(IReadOnlyList<double> snapPoints, double value, out double lowerSnapPoint)
{
var point = snapPoints.BinarySearch(value, Comparer<double>.Default);
if (point < 0)
{
point = ~point;
lowerSnapPoint = snapPoints[Math.Max(0, point - 1)];
}
else
{
lowerSnapPoint = snapPoints[point];
point += 1;
}
return snapPoints[Math.Min(point, snapPoints.Count - 1)];
}
}
}

50
src/Avalonia.Controls/Primitives/IScrollSnapPointsInfo.cs

@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using Avalonia.Interactivity;
using Avalonia.Layout;
namespace Avalonia.Controls.Primitives
{
/// <summary>
/// Describes snap point behavior for objects that contain and present items.
/// </summary>
public interface IScrollSnapPointsInfo
{
/// <summary>
/// Gets or sets a value that indicates whether the horizontal snap points for the container are equidistant from each other.
/// </summary>
bool AreHorizontalSnapPointsRegular { get; set; }
/// <summary>
/// Gets or sets a value that indicates whether the vertical snap points for the container are equidistant from each other.
/// </summary>
bool AreVerticalSnapPointsRegular { get; set; }
/// <summary>
/// Returns the set of distances between irregular snap points for a specified orientation and alignment.
/// </summary>
/// <param name="orientation">The orientation for the desired snap point set.</param>
/// <param name="snapPointsAlignment">The alignment to use when applying the snap points.</param>
/// <returns>The read-only collection of snap point distances. Returns an empty collection when no snap points are present.</returns>
IReadOnlyList<double> GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment);
/// <summary>
/// Gets the distance between regular snap points for a specified orientation and alignment.
/// </summary>
/// <param name="orientation">The orientation for the desired snap point set.</param>
/// <param name="snapPointsAlignment">The alignment to use when applying the snap points.</param>
/// <param name="offset">Out parameter. The offset of the first snap point.</param>
/// <returns>The distance between the equidistant snap points. Returns 0 when no snap points are present.</returns>
double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset);
/// <summary>
/// Occurs when the measurements for horizontal snap points change.
/// </summary>
event EventHandler<RoutedEventArgs> HorizontalSnapPointsChanged;
/// <summary>
/// Occurs when the measurements for vertical snap points change.
/// </summary>
event EventHandler<RoutedEventArgs> VerticalSnapPointsChanged;
}
}

23
src/Avalonia.Controls/Primitives/SnapPointsAlignment.cs

@ -0,0 +1,23 @@
namespace Avalonia.Controls.Primitives
{
/// <summary>
/// Specify options for snap point alignment relative to an edge. Which edge depends on the orientation of the object where the alignment is applied
/// </summary>
public enum SnapPointsAlignment
{
/// <summary>
/// Use snap points grouped closer to the orientation edge.
/// </summary>
Near,
/// <summary>
/// Use snap points that are centered in the orientation.
/// </summary>
Center,
/// <summary>
/// Use snap points grouped farther from the orientation edge.
/// </summary>
Far
}
}

23
src/Avalonia.Controls/Primitives/SnapPointsType.cs

@ -0,0 +1,23 @@
namespace Avalonia.Controls.Primitives
{
/// <summary>
/// Specify how panning snap points are processed for gesture input.
/// </summary>
public enum SnapPointsType
{
/// <summary>
/// No snapping behavior.
/// </summary>
None,
/// <summary>
/// Content always stops at the snap point closest to where inertia would naturally stop along the direction of inertia.
/// </summary>
Mandatory,
/// <summary>
/// Content always stops at the snap point closest to the release point along the direction of inertia.
/// </summary>
MandatorySingle
}
}

64
src/Avalonia.Controls/ScrollViewer.cs

@ -151,6 +151,34 @@ namespace Avalonia.Controls
o => o.VerticalScrollBarValue,
(o, v) => o.VerticalScrollBarValue = v);
/// <summary>
/// Defines the <see cref="HorizontalSnapPointsType"/> property.
/// </summary>
public static readonly StyledProperty<SnapPointsType> HorizontalSnapPointsTypeProperty =
AvaloniaProperty.Register<ScrollViewer, SnapPointsType>(
nameof(HorizontalSnapPointsType));
/// <summary>
/// Defines the <see cref="VerticalSnapPointsType"/> property.
/// </summary>
public static readonly StyledProperty<SnapPointsType> VerticalSnapPointsTypeProperty =
AvaloniaProperty.Register<ScrollViewer, SnapPointsType>(
nameof(VerticalSnapPointsType));
/// <summary>
/// Defines the <see cref="HorizontalSnapPointsAlignment"/> property.
/// </summary>
public static readonly AttachedProperty<SnapPointsAlignment> HorizontalSnapPointsAlignmentProperty =
AvaloniaProperty.RegisterAttached<ScrollViewer, Control, SnapPointsAlignment>(
nameof(HorizontalSnapPointsAlignment));
/// <summary>
/// Defines the <see cref="VerticalSnapPointsAlignment"/> property.
/// </summary>
public static readonly AttachedProperty<SnapPointsAlignment> VerticalSnapPointsAlignmentProperty =
AvaloniaProperty.RegisterAttached<ScrollViewer, Control, SnapPointsAlignment>(
nameof(VerticalSnapPointsAlignment));
/// <summary>
/// Defines the VerticalScrollBarViewportSize property.
/// </summary>
@ -429,6 +457,42 @@ namespace Avalonia.Controls
private set => SetAndRaise(ScrollBar.IsExpandedProperty, ref _isExpanded, value);
}
/// <summary>
/// Gets or sets how scroll gesture reacts to the snap points along the horizontal axis.
/// </summary>
public SnapPointsType HorizontalSnapPointsType
{
get => GetValue(HorizontalSnapPointsTypeProperty);
set => SetValue(HorizontalSnapPointsTypeProperty, value);
}
/// <summary>
/// Gets or sets how scroll gesture reacts to the snap points along the vertical axis.
/// </summary>
public SnapPointsType VerticalSnapPointsType
{
get => GetValue(VerticalSnapPointsTypeProperty);
set => SetValue(VerticalSnapPointsTypeProperty, value);
}
/// <summary>
/// Gets or sets how the existing snap points are horizontally aligned versus the initial viewport.
/// </summary>
public SnapPointsAlignment HorizontalSnapPointsAlignment
{
get => GetValue(HorizontalSnapPointsAlignmentProperty);
set => SetValue(HorizontalSnapPointsAlignmentProperty, value);
}
/// <summary>
/// Gets or sets how the existing snap points are vertically aligned versus the initial viewport.
/// </summary>
public SnapPointsAlignment VerticalSnapPointsAlignment
{
get => GetValue(VerticalSnapPointsAlignmentProperty);
set => SetValue(VerticalSnapPointsAlignmentProperty, value);
}
/// <summary>
/// Gets a value that indicates whether scrollbars can hide itself when user is not interacting with it.
/// </summary>

191
src/Avalonia.Controls/StackPanel.cs

@ -4,7 +4,11 @@
// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
namespace Avalonia.Controls
@ -12,7 +16,7 @@ namespace Avalonia.Controls
/// <summary>
/// A panel which lays out its children horizontally or vertically.
/// </summary>
public class StackPanel : Panel, INavigableContainer
public class StackPanel : Panel, INavigableContainer, IScrollSnapPointsInfo
{
/// <summary>
/// Defines the <see cref="Spacing"/> property.
@ -26,6 +30,34 @@ namespace Avalonia.Controls
public static readonly StyledProperty<Orientation> OrientationProperty =
StackLayout.OrientationProperty.AddOwner<StackPanel>();
/// <summary>
/// Defines the <see cref="AreHorizontalSnapPointsRegular"/> property.
/// </summary>
public static readonly StyledProperty<bool> AreHorizontalSnapPointsRegularProperty =
AvaloniaProperty.Register<StackPanel, bool>(nameof(AreHorizontalSnapPointsRegular));
/// <summary>
/// Defines the <see cref="AreVerticalSnapPointsRegular"/> property.
/// </summary>
public static readonly StyledProperty<bool> AreVerticalSnapPointsRegularProperty =
AvaloniaProperty.Register<StackPanel, bool>(nameof(AreVerticalSnapPointsRegular));
/// <summary>
/// Defines the <see cref="HorizontalSnapPointsChanged"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> HorizontalSnapPointsChangedEvent =
RoutedEvent.Register<StackPanel, RoutedEventArgs>(
nameof(HorizontalSnapPointsChanged),
RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="VerticalSnapPointsChanged"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> VerticalSnapPointsChangedEvent =
RoutedEvent.Register<StackPanel, RoutedEventArgs>(
nameof(VerticalSnapPointsChanged),
RoutingStrategies.Bubble);
/// <summary>
/// Initializes static members of the <see cref="StackPanel"/> class.
/// </summary>
@ -53,6 +85,42 @@ namespace Avalonia.Controls
set { SetValue(OrientationProperty, value); }
}
/// <summary>
/// Occurs when the measurements for horizontal snap points change.
/// </summary>
public event EventHandler<RoutedEventArgs>? HorizontalSnapPointsChanged
{
add => AddHandler(HorizontalSnapPointsChangedEvent, value);
remove => RemoveHandler(HorizontalSnapPointsChangedEvent, value);
}
/// <summary>
/// Occurs when the measurements for vertical snap points change.
/// </summary>
public event EventHandler<RoutedEventArgs>? VerticalSnapPointsChanged
{
add => AddHandler(VerticalSnapPointsChangedEvent, value);
remove => RemoveHandler(VerticalSnapPointsChangedEvent, value);
}
/// <summary>
/// Gets or sets whether the horizontal snap points for the <see cref="StackPanel"/> are equidistant from each other.
/// </summary>
public bool AreHorizontalSnapPointsRegular
{
get { return GetValue(AreHorizontalSnapPointsRegularProperty); }
set { SetValue(AreHorizontalSnapPointsRegularProperty, value); }
}
/// <summary>
/// Gets or sets whether the vertical snap points for the <see cref="StackPanel"/> are equidistant from each other.
/// </summary>
public bool AreVerticalSnapPointsRegular
{
get { return GetValue(AreVerticalSnapPointsRegularProperty); }
set { SetValue(AreVerticalSnapPointsRegularProperty, value); }
}
/// <summary>
/// Gets the next control in the specified direction.
/// </summary>
@ -274,6 +342,8 @@ namespace Avalonia.Controls
ArrangeChild(child, rcChild, finalSize, Orientation);
}
RaiseEvent(new RoutedEventArgs(Orientation == Orientation.Horizontal ? HorizontalSnapPointsChangedEvent : VerticalSnapPointsChangedEvent));
return finalSize;
}
@ -285,5 +355,124 @@ namespace Avalonia.Controls
{
child.Arrange(rect);
}
/// <inheritdoc/>
public IReadOnlyList<double> GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment)
{
var snapPoints = new List<double>();
switch (orientation)
{
case Orientation.Horizontal:
if (AreHorizontalSnapPointsRegular)
throw new InvalidOperationException();
if (Orientation == Orientation.Horizontal)
{
foreach(var child in VisualChildren)
{
double snapPoint = 0;
switch (snapPointsAlignment)
{
case SnapPointsAlignment.Near:
snapPoint = child.Bounds.Left;
break;
case SnapPointsAlignment.Center:
snapPoint = child.Bounds.Center.X;
break;
case SnapPointsAlignment.Far:
snapPoint = child.Bounds.Right;
break;
}
snapPoints.Add(snapPoint);
}
}
break;
case Orientation.Vertical:
if (AreVerticalSnapPointsRegular)
throw new InvalidOperationException();
if (Orientation == Orientation.Vertical)
{
foreach (var child in VisualChildren)
{
double snapPoint = 0;
switch (snapPointsAlignment)
{
case SnapPointsAlignment.Near:
snapPoint = child.Bounds.Top;
break;
case SnapPointsAlignment.Center:
snapPoint = child.Bounds.Center.Y;
break;
case SnapPointsAlignment.Far:
snapPoint = child.Bounds.Bottom;
break;
}
snapPoints.Add(snapPoint);
}
}
break;
}
return snapPoints;
}
/// <inheritdoc/>
public double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset)
{
offset = 0f;
var firstChild = VisualChildren.FirstOrDefault();
if(firstChild == null)
{
return 0;
}
double snapPoint = 0;
switch (Orientation)
{
case Orientation.Horizontal:
if (!AreHorizontalSnapPointsRegular)
throw new InvalidOperationException();
snapPoint = firstChild.Bounds.Width;
switch (snapPointsAlignment)
{
case SnapPointsAlignment.Near:
offset = firstChild.Bounds.Left;
break;
case SnapPointsAlignment.Center:
offset = firstChild.Bounds.Center.X;
break;
case SnapPointsAlignment.Far:
offset = firstChild.Bounds.Right;
break;
}
break;
case Orientation.Vertical:
if (!AreVerticalSnapPointsRegular)
throw new InvalidOperationException();
snapPoint = firstChild.Bounds.Height;
switch (snapPointsAlignment)
{
case SnapPointsAlignment.Near:
offset = firstChild.Bounds.Top;
break;
case SnapPointsAlignment.Center:
offset = firstChild.Bounds.Center.Y;
break;
case SnapPointsAlignment.Far:
offset = firstChild.Bounds.Bottom;
break;
}
break;
}
return snapPoint + Spacing;
}
}
}

232
src/Avalonia.Controls/VirtualizingStackPanel.cs

@ -3,8 +3,11 @@ using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Utils;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Utilities;
using Avalonia.VisualTree;
@ -14,7 +17,7 @@ namespace Avalonia.Controls
/// <summary>
/// Arranges and virtualizes content on a single line that is oriented either horizontally or vertically.
/// </summary>
public class VirtualizingStackPanel : VirtualizingPanel
public class VirtualizingStackPanel : VirtualizingPanel, IScrollSnapPointsInfo
{
/// <summary>
/// Defines the <see cref="Orientation"/> property.
@ -22,6 +25,34 @@ namespace Avalonia.Controls
public static readonly StyledProperty<Orientation> OrientationProperty =
StackLayout.OrientationProperty.AddOwner<VirtualizingStackPanel>();
/// <summary>
/// Defines the <see cref="AreHorizontalSnapPointsRegular"/> property.
/// </summary>
public static readonly StyledProperty<bool> AreHorizontalSnapPointsRegularProperty =
AvaloniaProperty.Register<VirtualizingStackPanel, bool>(nameof(AreHorizontalSnapPointsRegular));
/// <summary>
/// Defines the <see cref="AreVerticalSnapPointsRegular"/> property.
/// </summary>
public static readonly StyledProperty<bool> AreVerticalSnapPointsRegularProperty =
AvaloniaProperty.Register<VirtualizingStackPanel, bool>(nameof(AreVerticalSnapPointsRegular));
/// <summary>
/// Defines the <see cref="HorizontalSnapPointsChanged"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> HorizontalSnapPointsChangedEvent =
RoutedEvent.Register<VirtualizingStackPanel, RoutedEventArgs>(
nameof(HorizontalSnapPointsChanged),
RoutingStrategies.Bubble);
/// <summary>
/// Defines the <see cref="VerticalSnapPointsChanged"/> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> VerticalSnapPointsChangedEvent =
RoutedEvent.Register<VirtualizingStackPanel, RoutedEventArgs>(
nameof(VerticalSnapPointsChanged),
RoutingStrategies.Bubble);
private static readonly AttachedProperty<bool> ItemIsOwnContainerProperty =
AvaloniaProperty.RegisterAttached<VirtualizingStackPanel, Control, bool>("ItemIsOwnContainer");
@ -62,6 +93,42 @@ namespace Avalonia.Controls
set => SetValue(OrientationProperty, value);
}
/// <summary>
/// Occurs when the measurements for horizontal snap points change.
/// </summary>
public event EventHandler<RoutedEventArgs>? HorizontalSnapPointsChanged
{
add => AddHandler(HorizontalSnapPointsChangedEvent, value);
remove => RemoveHandler(HorizontalSnapPointsChangedEvent, value);
}
/// <summary>
/// Occurs when the measurements for vertical snap points change.
/// </summary>
public event EventHandler<RoutedEventArgs>? VerticalSnapPointsChanged
{
add => AddHandler(VerticalSnapPointsChangedEvent, value);
remove => RemoveHandler(VerticalSnapPointsChangedEvent, value);
}
/// <summary>
/// Gets or sets whether the horizontal snap points for the <see cref="VirtualizingStackPanel"/> are equidistant from each other.
/// </summary>
public bool AreHorizontalSnapPointsRegular
{
get { return GetValue(AreHorizontalSnapPointsRegularProperty); }
set { SetValue(AreHorizontalSnapPointsRegularProperty, value); }
}
/// <summary>
/// Gets or sets whether the vertical snap points for the <see cref="VirtualizingStackPanel"/> are equidistant from each other.
/// </summary>
public bool AreVerticalSnapPointsRegular
{
get { return GetValue(AreVerticalSnapPointsRegularProperty); }
set { SetValue(AreVerticalSnapPointsRegularProperty, value); }
}
protected override Size MeasureOverride(Size availableSize)
{
if (!IsEffectivelyVisible)
@ -145,6 +212,8 @@ namespace Avalonia.Controls
finally
{
_isInLayout = false;
RaiseEvent(new RoutedEventArgs(Orientation == Orientation.Horizontal ? HorizontalSnapPointsChangedEvent : VerticalSnapPointsChangedEvent));
}
}
@ -622,6 +691,167 @@ namespace Avalonia.Controls
Invalidate(c);
}
/// <inheritdoc/>
public IReadOnlyList<double> GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment)
{
var snapPoints = new List<double>();
switch (orientation)
{
case Orientation.Horizontal:
if (AreHorizontalSnapPointsRegular)
throw new InvalidOperationException();
if (Orientation == Orientation.Horizontal)
{
var averageElementSize = EstimateElementSizeU();
double snapPoint = 0;
for (var i = 0; i < Items.Count; i++)
{
var container = ContainerFromIndex(i);
if (container != null)
{
switch (snapPointsAlignment)
{
case SnapPointsAlignment.Near:
snapPoint = container.Bounds.Left;
break;
case SnapPointsAlignment.Center:
snapPoint = container.Bounds.Center.X;
break;
case SnapPointsAlignment.Far:
snapPoint = container.Bounds.Right;
break;
}
}
else
{
if (snapPoint == 0)
{
switch (snapPointsAlignment)
{
case SnapPointsAlignment.Center:
snapPoint = averageElementSize / 2;
break;
case SnapPointsAlignment.Far:
snapPoint = averageElementSize;
break;
}
}
else
snapPoint += averageElementSize;
}
snapPoints.Add(snapPoint);
}
}
break;
case Orientation.Vertical:
if (AreVerticalSnapPointsRegular)
throw new InvalidOperationException();
if (Orientation == Orientation.Vertical)
{
var averageElementSize = EstimateElementSizeU();
double snapPoint = 0;
for (var i = 0; i < Items.Count; i++)
{
var container = ContainerFromIndex(i);
if (container != null)
{
switch (snapPointsAlignment)
{
case SnapPointsAlignment.Near:
snapPoint = container.Bounds.Top;
break;
case SnapPointsAlignment.Center:
snapPoint = container.Bounds.Center.Y;
break;
case SnapPointsAlignment.Far:
snapPoint = container.Bounds.Bottom;
break;
}
}
else
{
if (snapPoint == 0)
{
switch (snapPointsAlignment)
{
case SnapPointsAlignment.Center:
snapPoint = averageElementSize / 2;
break;
case SnapPointsAlignment.Far:
snapPoint = averageElementSize;
break;
}
}
else
snapPoint += averageElementSize;
}
snapPoints.Add(snapPoint);
}
}
break;
}
return snapPoints;
}
/// <inheritdoc/>
public double GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment, out double offset)
{
offset = 0f;
var firstRealizedChild = _realizedElements?.Elements.FirstOrDefault();
if (firstRealizedChild == null)
{
return 0;
}
double snapPoint = 0;
switch (Orientation)
{
case Orientation.Horizontal:
if (!AreHorizontalSnapPointsRegular)
throw new InvalidOperationException();
snapPoint = firstRealizedChild.Bounds.Width;
switch (snapPointsAlignment)
{
case SnapPointsAlignment.Near:
offset = 0;
break;
case SnapPointsAlignment.Center:
offset = (firstRealizedChild.Bounds.Right - firstRealizedChild.Bounds.Left) / 2;
break;
case SnapPointsAlignment.Far:
offset = firstRealizedChild.Bounds.Width;
break;
}
break;
case Orientation.Vertical:
if (!AreVerticalSnapPointsRegular)
throw new InvalidOperationException();
snapPoint = firstRealizedChild.Bounds.Height;
switch (snapPointsAlignment)
{
case SnapPointsAlignment.Near:
offset = 0;
break;
case SnapPointsAlignment.Center:
offset = (firstRealizedChild.Bounds.Bottom - firstRealizedChild.Bounds.Top) / 2;
break;
case SnapPointsAlignment.Far:
offset = firstRealizedChild.Bounds.Height;
break;
}
break;
}
return snapPoint;
}
/// <summary>
/// Stores the realized element state for a <see cref="VirtualizingStackPanel"/>.
/// </summary>

2
src/Avalonia.Themes.Fluent/Controls/ItemsControl.xaml

@ -9,6 +9,8 @@
CornerRadius="{TemplateBinding CornerRadius}"
Padding="{TemplateBinding Padding}">
<ItemsPresenter Name="PART_ItemsPresenter"
AreVerticalSnapPointsRegular="{TemplateBinding AreVerticalSnapPointsRegular}"
AreHorizontalSnapPointsRegular="{TemplateBinding AreHorizontalSnapPointsRegular}"
ItemsPanel="{TemplateBinding ItemsPanel}"/>
</Border>
</ControlTemplate>

4
src/Avalonia.Themes.Fluent/Controls/ListBox.xaml

@ -29,11 +29,15 @@
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<ScrollViewer Name="PART_ScrollViewer"
VerticalSnapPointsType="{TemplateBinding (ScrollViewer.VerticalSnapPointsType)}"
HorizontalSnapPointsType="{TemplateBinding (ScrollViewer.HorizontalSnapPointsType)}"
HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}"
VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}"
IsScrollChainingEnabled="{TemplateBinding (ScrollViewer.IsScrollChainingEnabled)}"
AllowAutoHide="{TemplateBinding (ScrollViewer.AllowAutoHide)}">
<ItemsPresenter Name="PART_ItemsPresenter"
AreVerticalSnapPointsRegular="{TemplateBinding AreVerticalSnapPointsRegular}"
AreHorizontalSnapPointsRegular="{TemplateBinding AreHorizontalSnapPointsRegular}"
ItemsPanel="{TemplateBinding ItemsPanel}"
Margin="{TemplateBinding Padding}"/>
</ScrollViewer>

4
src/Avalonia.Themes.Fluent/Controls/ScrollViewer.xaml

@ -34,6 +34,10 @@
Content="{TemplateBinding Content}"
Extent="{TemplateBinding Extent, Mode=TwoWay}"
Padding="{TemplateBinding Padding}"
HorizontalSnapPointsType="{TemplateBinding HorizontalSnapPointsType}"
VerticalSnapPointsType="{TemplateBinding VerticalSnapPointsType}"
HorizontalSnapPointsAlignment="{TemplateBinding HorizontalSnapPointsAlignment}"
VerticalSnapPointsAlignment="{TemplateBinding VerticalSnapPointsAlignment}"
Offset="{TemplateBinding Offset, Mode=TwoWay}"
Viewport="{TemplateBinding Viewport, Mode=TwoWay}"
IsScrollChainingEnabled="{TemplateBinding IsScrollChainingEnabled}">

2
src/Avalonia.Themes.Simple/Controls/ItemsControl.xaml

@ -10,6 +10,8 @@
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<ItemsPresenter Name="PART_ItemsPresenter"
AreVerticalSnapPointsRegular="{TemplateBinding AreVerticalSnapPointsRegular}"
AreHorizontalSnapPointsRegular="{TemplateBinding AreHorizontalSnapPointsRegular}"
ItemsPanel="{TemplateBinding ItemsPanel}" />
</Border>
</ControlTemplate>

6
src/Avalonia.Themes.Simple/Controls/ListBox.xaml

@ -20,9 +20,13 @@
Background="{TemplateBinding Background}"
HorizontalScrollBarVisibility="{TemplateBinding (ScrollViewer.HorizontalScrollBarVisibility)}"
IsScrollChainingEnabled="{TemplateBinding (ScrollViewer.IsScrollChainingEnabled)}"
VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}">
VerticalScrollBarVisibility="{TemplateBinding (ScrollViewer.VerticalScrollBarVisibility)}"
VerticalSnapPointsType="{TemplateBinding (ScrollViewer.VerticalSnapPointsType)}"
HorizontalSnapPointsType="{TemplateBinding (ScrollViewer.HorizontalSnapPointsType)}">
<ItemsPresenter Name="PART_ItemsPresenter"
Margin="{TemplateBinding Padding}"
AreVerticalSnapPointsRegular="{TemplateBinding AreVerticalSnapPointsRegular}"
AreHorizontalSnapPointsRegular="{TemplateBinding AreHorizontalSnapPointsRegular}"
ItemsPanel="{TemplateBinding ItemsPanel}" />
</ScrollViewer>
</Border>

6
src/Avalonia.Themes.Simple/Controls/ScrollViewer.xaml

@ -13,7 +13,11 @@
Background="{TemplateBinding Background}"
CanHorizontallyScroll="{TemplateBinding CanHorizontallyScroll}"
CanVerticallyScroll="{TemplateBinding CanVerticallyScroll}"
Content="{TemplateBinding Content}"
Content="{TemplateBinding Content}"
HorizontalSnapPointsType="{TemplateBinding HorizontalSnapPointsType}"
VerticalSnapPointsType="{TemplateBinding VerticalSnapPointsType}"
HorizontalSnapPointsAlignment="{TemplateBinding HorizontalSnapPointsAlignment}"
VerticalSnapPointsAlignment="{TemplateBinding VerticalSnapPointsAlignment}"
Extent="{TemplateBinding Extent,
Mode=TwoWay}"
IsScrollChainingEnabled="{TemplateBinding IsScrollChainingEnabled}"

Loading…
Cancel
Save