Browse Source

Fix large/small scroll in ScrollViewer.

For non-logical scrolling:

- Use 16 for small scroll size (value taken from WPF)
- Use viewport size for large scroll

For logical scrolling, use the `ScrollSize`/`PageScrollSize` defined on `ILogicalScrollable`. Note that this required a small breaking change to `ILogicalScrollable`.

Fixed #3245
pull/3725/head
Steven Kirk 6 years ago
parent
commit
b9313c2dec
  1. 41
      src/Avalonia.Controls/Presenters/ItemVirtualizer.cs
  2. 21
      src/Avalonia.Controls/Presenters/ItemsPresenter.cs
  3. 11
      src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs
  4. 31
      src/Avalonia.Controls/Primitives/ILogicalScrollable.cs
  5. 113
      src/Avalonia.Controls/ScrollViewer.cs
  6. 4
      src/Avalonia.Themes.Default/ScrollViewer.xaml
  7. 31
      tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests_ILogicalScrollable.cs
  8. 61
      tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs

41
src/Avalonia.Controls/Presenters/ItemVirtualizer.cs

@ -6,6 +6,7 @@ using Avalonia.Controls.Primitives;
using Avalonia.Controls.Utils;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.VisualTree;
namespace Avalonia.Controls.Presenters
{
@ -99,9 +100,14 @@ namespace Avalonia.Controls.Presenters
{
get
{
return Vertical ?
new Size(Owner.Panel.DesiredSize.Width, ExtentValue) :
new Size(ExtentValue, Owner.Panel.DesiredSize.Height);
if (IsLogicalScrollEnabled)
{
return Vertical ?
new Size(Owner.Panel.DesiredSize.Width, ExtentValue) :
new Size(ExtentValue, Owner.Panel.DesiredSize.Height);
}
return default;
}
}
@ -112,9 +118,14 @@ namespace Avalonia.Controls.Presenters
{
get
{
return Vertical ?
new Size(Owner.Panel.Bounds.Width, ViewportValue) :
new Size(ViewportValue, Owner.Panel.Bounds.Height);
if (IsLogicalScrollEnabled)
{
return Vertical ?
new Size(Owner.Panel.Bounds.Width, ViewportValue) :
new Size(ViewportValue, Owner.Panel.Bounds.Height);
}
return default;
}
}
@ -125,11 +136,21 @@ namespace Avalonia.Controls.Presenters
{
get
{
return Vertical ? new Vector(_crossAxisOffset, OffsetValue) : new Vector(OffsetValue, _crossAxisOffset);
if (IsLogicalScrollEnabled)
{
return Vertical ? new Vector(_crossAxisOffset, OffsetValue) : new Vector(OffsetValue, _crossAxisOffset);
}
return default;
}
set
{
if (!IsLogicalScrollEnabled)
{
throw new NotSupportedException("Logical scrolling disabled.");
}
var oldCrossAxisOffset = _crossAxisOffset;
if (Vertical)
@ -164,10 +185,10 @@ namespace Avalonia.Controls.Presenters
}
var virtualizingPanel = owner.Panel as IVirtualizingPanel;
var scrollable = (ILogicalScrollable)owner;
var scrollContentPresenter = owner.Parent as IScrollable;
ItemVirtualizer result = null;
if (virtualizingPanel != null && scrollable.InvalidateScroll != null)
if (virtualizingPanel != null && scrollContentPresenter is object)
{
switch (owner.VirtualizationMode)
{
@ -277,6 +298,6 @@ namespace Avalonia.Controls.Presenters
/// <summary>
/// Invalidates the current scroll.
/// </summary>
protected void InvalidateScroll() => ((ILogicalScrollable)Owner).InvalidateScroll?.Invoke();
protected void InvalidateScroll() => ((ILogicalScrollable)Owner).RaiseScrollInvalidated(EventArgs.Empty);
}
}

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

@ -21,6 +21,7 @@ namespace Avalonia.Controls.Presenters
private bool _canHorizontallyScroll;
private bool _canVerticallyScroll;
EventHandler _scrollInvalidated;
/// <summary>
/// Initializes static members of the <see cref="ItemsPresenter"/> class.
@ -95,13 +96,17 @@ namespace Avalonia.Controls.Presenters
Size IScrollable.Viewport => Virtualizer?.Viewport ?? Bounds.Size;
/// <inheritdoc/>
Action ILogicalScrollable.InvalidateScroll { get; set; }
event EventHandler ILogicalScrollable.ScrollInvalidated
{
add => _scrollInvalidated += value;
remove => _scrollInvalidated -= value;
}
/// <inheritdoc/>
Size ILogicalScrollable.ScrollSize => new Size(1, 1);
Size ILogicalScrollable.ScrollSize => new Size(16, 1);
/// <inheritdoc/>
Size ILogicalScrollable.PageScrollSize => new Size(0, 1);
Size ILogicalScrollable.PageScrollSize => Virtualizer?.Viewport ?? new Size(16, 16);
internal ItemVirtualizer Virtualizer { get; private set; }
@ -117,6 +122,12 @@ namespace Avalonia.Controls.Presenters
return Virtualizer?.GetControlInDirection(direction, from);
}
/// <inheritdoc/>
void ILogicalScrollable.RaiseScrollInvalidated(EventArgs e)
{
_scrollInvalidated?.Invoke(this, e);
}
public override void ScrollIntoView(object item)
{
Virtualizer?.ScrollIntoView(item);
@ -138,7 +149,7 @@ namespace Avalonia.Controls.Presenters
{
Virtualizer?.Dispose();
Virtualizer = ItemVirtualizer.Create(this);
((ILogicalScrollable)this).InvalidateScroll?.Invoke();
_scrollInvalidated?.Invoke(this, EventArgs.Empty);
KeyboardNavigation.SetTabNavigation(
(InputElement)Panel,
@ -162,7 +173,7 @@ namespace Avalonia.Controls.Presenters
{
Virtualizer?.Dispose();
Virtualizer = ItemVirtualizer.Create(this);
((ILogicalScrollable)this).InvalidateScroll?.Invoke();
_scrollInvalidated?.Invoke(this, EventArgs.Empty);
}
}
}

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

@ -3,8 +3,10 @@ using System.Collections.Generic;
using System.Linq;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Runtime.InteropServices.ComTypes;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.LogicalTree;
using Avalonia.VisualTree;
namespace Avalonia.Controls.Presenters
@ -349,7 +351,7 @@ namespace Avalonia.Controls.Presenters
if (scrollable != null)
{
scrollable.InvalidateScroll = () => UpdateFromScrollable(scrollable);
scrollable.ScrollInvalidated += ScrollInvalidated;
if (scrollable.IsLogicalScrollEnabled)
{
@ -360,12 +362,17 @@ namespace Avalonia.Controls.Presenters
.Subscribe(x => scrollable.CanVerticallyScroll = x),
this.GetObservable(OffsetProperty)
.Skip(1).Subscribe(x => scrollable.Offset = x),
Disposable.Create(() => scrollable.InvalidateScroll = null));
Disposable.Create(() => scrollable.ScrollInvalidated -= ScrollInvalidated));
UpdateFromScrollable(scrollable);
}
}
}
private void ScrollInvalidated(object sender, EventArgs e)
{
UpdateFromScrollable((ILogicalScrollable)sender);
}
private void UpdateFromScrollable(ILogicalScrollable scrollable)
{
var logicalScroll = _logicalScrollSubscription != null;

31
src/Avalonia.Controls/Primitives/ILogicalScrollable.cs

@ -31,22 +31,6 @@ namespace Avalonia.Controls.Primitives
/// </summary>
bool IsLogicalScrollEnabled { get; }
/// <summary>
/// Gets or sets the scroll invalidation method.
/// </summary>
/// <remarks>
/// <para>
/// This method notifies the attached <see cref="ScrollViewer"/> of a change in
/// the <see cref="IScrollable.Extent"/>, <see cref="IScrollable.Offset"/> or
/// <see cref="IScrollable.Viewport"/> properties.
/// </para>
/// <para>
/// This property is set by the parent <see cref="ScrollViewer"/> when the
/// <see cref="ILogicalScrollable"/> is placed inside it.
/// </para>
/// </remarks>
Action InvalidateScroll { get; set; }
/// <summary>
/// Gets the size to scroll by, in logical units.
/// </summary>
@ -57,6 +41,15 @@ namespace Avalonia.Controls.Primitives
/// </summary>
Size PageScrollSize { get; }
/// <summary>
/// Raised when the scroll is invalidated.
/// </summary>
/// <remarks>
/// This event notifies an attached <see cref="ScrollViewer"/> of a change in
/// one of the scroll properties.
/// </remarks>
event EventHandler ScrollInvalidated;
/// <summary>
/// Attempts to bring a portion of the target visual into view by scrolling the content.
/// </summary>
@ -72,5 +65,11 @@ namespace Avalonia.Controls.Primitives
/// <param name="from">The control from which movement begins.</param>
/// <returns>The control.</returns>
IControl GetControlInDirection(NavigationDirection direction, IControl from);
/// <summary>
/// Raises the <see cref="ScrollInvalidated"/> event.
/// </summary>
/// <param name="e">The event args.</param>
void RaiseScrollInvalidated(EventArgs e);
}
}

113
src/Avalonia.Controls/ScrollViewer.cs

@ -10,6 +10,8 @@ namespace Avalonia.Controls
/// </summary>
public class ScrollViewer : ContentControl, IScrollable, IScrollAnchorProvider
{
private static readonly Size s_defaultSmallChange = new Size(16, 16);
/// <summary>
/// Defines the <see cref="CanHorizontallyScroll"/> property.
/// </summary>
@ -59,6 +61,22 @@ namespace Avalonia.Controls
o => o.Viewport,
(o, v) => o.Viewport = v);
/// <summary>
/// Defines the <see cref="LargeChange"/> property.
/// </summary>
public static readonly DirectProperty<ScrollViewer, Size> LargeChangeProperty =
AvaloniaProperty.RegisterDirect<ScrollViewer, Size>(
nameof(LargeChange),
o => o.LargeChange);
/// <summary>
/// Defines the <see cref="SmallChange"/> property.
/// </summary>
public static readonly DirectProperty<ScrollViewer, Size> SmallChangeProperty =
AvaloniaProperty.RegisterDirect<ScrollViewer, Size>(
nameof(SmallChange),
o => o.SmallChange);
/// <summary>
/// Defines the HorizontalScrollBarMaximum property.
/// </summary>
@ -149,9 +167,13 @@ namespace Avalonia.Controls
nameof(VerticalScrollBarVisibility),
ScrollBarVisibility.Auto);
private IDisposable _childSubscription;
private ILogicalScrollable _logicalScrollable;
private Size _extent;
private Vector _offset;
private Size _viewport;
private Size _largeChange;
private Size _smallChange = s_defaultSmallChange;
/// <summary>
/// Initializes static members of the <see cref="ScrollViewer"/> class.
@ -228,6 +250,16 @@ namespace Avalonia.Controls
}
}
/// <summary>
/// Gets the large (page) change value for the scroll viewer.
/// </summary>
public Size LargeChange => _largeChange;
/// <summary>
/// Gets the small (line) change value for the scroll viewer.
/// </summary>
public Size SmallChange => _smallChange;
/// <summary>
/// Gets or sets the horizontal scrollbar visibility.
/// </summary>
@ -246,22 +278,6 @@ namespace Avalonia.Controls
set { SetValue(VerticalScrollBarVisibilityProperty, value); }
}
/// <summary>
/// Scrolls to the top-left corner of the content.
/// </summary>
public void ScrollToHome()
{
Offset = new Vector(double.NegativeInfinity, double.NegativeInfinity);
}
/// <summary>
/// Scrolls to the bottom-left corner of the content.
/// </summary>
public void ScrollToEnd()
{
Offset = new Vector(double.NegativeInfinity, double.PositiveInfinity);
}
/// <summary>
/// Gets a value indicating whether the viewer can scroll horizontally.
/// </summary>
@ -347,6 +363,22 @@ namespace Avalonia.Controls
/// <inheritdoc/>
IControl IScrollAnchorProvider.CurrentAnchor => null; // TODO: Implement
/// <summary>
/// Scrolls to the top-left corner of the content.
/// </summary>
public void ScrollToHome()
{
Offset = new Vector(double.NegativeInfinity, double.NegativeInfinity);
}
/// <summary>
/// Scrolls to the bottom-left corner of the content.
/// </summary>
public void ScrollToEnd()
{
Offset = new Vector(double.NegativeInfinity, double.PositiveInfinity);
}
/// <summary>
/// Gets the value of the HorizontalScrollBarVisibility attached property.
/// </summary>
@ -397,6 +429,22 @@ namespace Avalonia.Controls
// TODO: Implement
}
protected override bool RegisterContentPresenter(IContentPresenter presenter)
{
_childSubscription?.Dispose();
_childSubscription = null;
if (base.RegisterContentPresenter(presenter))
{
_childSubscription = Presenter?
.GetObservable(ContentPresenter.ChildProperty)
.Subscribe(ChildChanged);
return true;
}
return false;
}
internal static Vector CoerceOffset(Size extent, Size viewport, Vector offset)
{
var maxX = Math.Max(extent.Width - viewport.Width, 0);
@ -431,6 +479,28 @@ namespace Avalonia.Controls
}
}
private void ChildChanged(IControl child)
{
if (_logicalScrollable is object)
{
_logicalScrollable.ScrollInvalidated -= LogicalScrollInvalidated;
_logicalScrollable = null;
}
if (child is ILogicalScrollable logical)
{
_logicalScrollable = logical;
logical.ScrollInvalidated += LogicalScrollInvalidated;
}
CalculatedPropertiesChanged();
}
private void LogicalScrollInvalidated(object sender, EventArgs e)
{
CalculatedPropertiesChanged();
}
private void ScrollBarVisibilityChanged(AvaloniaPropertyChangedEventArgs e)
{
var wasEnabled = !ScrollBarVisibility.Disabled.Equals(e.OldValue);
@ -465,6 +535,17 @@ namespace Avalonia.Controls
RaisePropertyChanged(VerticalScrollBarMaximumProperty, 0, VerticalScrollBarMaximum);
RaisePropertyChanged(VerticalScrollBarValueProperty, 0, VerticalScrollBarValue);
RaisePropertyChanged(VerticalScrollBarViewportSizeProperty, 0, VerticalScrollBarViewportSize);
if (_logicalScrollable?.IsLogicalScrollEnabled == true)
{
SetAndRaise(SmallChangeProperty, ref _smallChange, _logicalScrollable.ScrollSize);
SetAndRaise(LargeChangeProperty, ref _largeChange, _logicalScrollable.PageScrollSize);
}
else
{
SetAndRaise(SmallChangeProperty, ref _smallChange, s_defaultSmallChange);
SetAndRaise(LargeChangeProperty, ref _largeChange, Viewport);
}
}
protected override void OnKeyDown(KeyEventArgs e)

4
src/Avalonia.Themes.Default/ScrollViewer.xaml

@ -22,6 +22,8 @@
</ScrollContentPresenter>
<ScrollBar Name="horizontalScrollBar"
Orientation="Horizontal"
LargeChange="{Binding LargeChange.Width, RelativeSource={RelativeSource TemplatedParent}}"
SmallChange="{Binding SmallChange.Width, RelativeSource={RelativeSource TemplatedParent}}"
Maximum="{TemplateBinding HorizontalScrollBarMaximum}"
Value="{TemplateBinding HorizontalScrollBarValue, Mode=TwoWay}"
ViewportSize="{TemplateBinding HorizontalScrollBarViewportSize}"
@ -30,6 +32,8 @@
Focusable="False"/>
<ScrollBar Name="verticalScrollBar"
Orientation="Vertical"
LargeChange="{Binding LargeChange.Height, RelativeSource={RelativeSource TemplatedParent}}"
SmallChange="{Binding SmallChange.Height, RelativeSource={RelativeSource TemplatedParent}}"
Maximum="{TemplateBinding VerticalScrollBarMaximum}"
Value="{TemplateBinding VerticalScrollBarValue, Mode=TwoWay}"
ViewportSize="{TemplateBinding VerticalScrollBarViewportSize}"

31
tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests_ILogicalScrollable.cs

@ -101,7 +101,7 @@ namespace Avalonia.Controls.UnitTests
target.UpdateChild();
Assert.NotNull(scrollable.InvalidateScroll);
Assert.True(scrollable.HasScrollInvalidatedSubscriber);
}
[Fact]
@ -117,7 +117,7 @@ namespace Avalonia.Controls.UnitTests
target.Content = null;
target.UpdateChild();
Assert.Null(scrollable.InvalidateScroll);
Assert.False(scrollable.HasScrollInvalidatedSubscriber);
}
[Fact]
@ -217,7 +217,7 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(new Rect(0, 0, 100, 100), scrollable.Bounds);
scrollable.IsLogicalScrollEnabled = false;
scrollable.InvalidateScroll();
scrollable.RaiseScrollInvalidated(EventArgs.Empty);
target.Measure(new Size(100, 100));
target.Arrange(new Rect(0, 0, 100, 100));
@ -227,7 +227,7 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(new Rect(0, 0, 150, 150), scrollable.Bounds);
scrollable.IsLogicalScrollEnabled = true;
scrollable.InvalidateScroll();
scrollable.RaiseScrollInvalidated(EventArgs.Empty);
target.Measure(new Size(100, 100));
target.Arrange(new Rect(0, 0, 100, 100));
@ -318,12 +318,20 @@ namespace Avalonia.Controls.UnitTests
private Size _extent;
private Vector _offset;
private Size _viewport;
private EventHandler _scrollInvalidated;
public bool CanHorizontallyScroll { get; set; }
public bool CanVerticallyScroll { get; set; }
public bool IsLogicalScrollEnabled { get; set; } = true;
public Size AvailableSize { get; private set; }
public Action InvalidateScroll { get; set; }
public bool HasScrollInvalidatedSubscriber => _scrollInvalidated != null;
public event EventHandler ScrollInvalidated
{
add => _scrollInvalidated += value;
remove => _scrollInvalidated -= value;
}
public Size Extent
{
@ -331,7 +339,7 @@ namespace Avalonia.Controls.UnitTests
set
{
_extent = value;
InvalidateScroll?.Invoke();
_scrollInvalidated?.Invoke(this, EventArgs.Empty);
}
}
@ -341,7 +349,7 @@ namespace Avalonia.Controls.UnitTests
set
{
_offset = value;
InvalidateScroll?.Invoke();
_scrollInvalidated?.Invoke(this, EventArgs.Empty);
}
}
@ -351,7 +359,7 @@ namespace Avalonia.Controls.UnitTests
set
{
_viewport = value;
InvalidateScroll?.Invoke();
_scrollInvalidated?.Invoke(this, EventArgs.Empty);
}
}
@ -376,6 +384,11 @@ namespace Avalonia.Controls.UnitTests
throw new NotImplementedException();
}
public void RaiseScrollInvalidated(EventArgs e)
{
_scrollInvalidated?.Invoke(this, e);
}
protected override Size MeasureOverride(Size availableSize)
{
AvailableSize = availableSize;
@ -388,4 +401,4 @@ namespace Avalonia.Controls.UnitTests
}
}
}
}
}

61
tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs

@ -4,6 +4,8 @@ using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Layout;
using Avalonia.LogicalTree;
using Moq;
using Xunit;
namespace Avalonia.Controls.UnitTests
@ -86,6 +88,65 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(new Vector(0, 40), target.Offset);
}
[Fact]
public void SmallChange_Should_Be_16()
{
var target = new ScrollViewer();
Assert.Equal(new Size(16, 16), target.SmallChange);
}
[Fact]
public void LargeChange_Should_Be_Viewport()
{
var target = new ScrollViewer();
target.SetValue(ScrollViewer.ViewportProperty, new Size(104, 143));
Assert.Equal(new Size(104, 143), target.LargeChange);
}
[Fact]
public void SmallChange_Should_Come_From_ILogicalScrollable_If_Present()
{
var child = new Mock<Control>();
var logicalScroll = child.As<ILogicalScrollable>();
logicalScroll.Setup(x => x.IsLogicalScrollEnabled).Returns(true);
logicalScroll.Setup(x => x.ScrollSize).Returns(new Size(12, 43));
var target = new ScrollViewer
{
Template = new FuncControlTemplate<ScrollViewer>(CreateTemplate),
Content = child.Object,
};
target.ApplyTemplate();
((ContentPresenter)target.Presenter).UpdateChild();
Assert.Equal(new Size(12, 43), target.SmallChange);
}
[Fact]
public void LargeChange_Should_Come_From_ILogicalScrollable_If_Present()
{
var child = new Mock<Control>();
var logicalScroll = child.As<ILogicalScrollable>();
logicalScroll.Setup(x => x.IsLogicalScrollEnabled).Returns(true);
logicalScroll.Setup(x => x.PageScrollSize).Returns(new Size(45, 67));
var target = new ScrollViewer
{
Template = new FuncControlTemplate<ScrollViewer>(CreateTemplate),
Content = child.Object,
};
target.ApplyTemplate();
((ContentPresenter)target.Presenter).UpdateChild();
Assert.Equal(new Size(45, 67), target.LargeChange);
}
private Control CreateTemplate(ScrollViewer control, INameScope scope)
{
return new Grid

Loading…
Cancel
Save