diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs index 0b21a1f8b9..5ba6b6b9a3 100644 --- a/src/Avalonia.Controls/Presenters/ItemVirtualizer.cs +++ b/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 /// /// Invalidates the current scroll. /// - protected void InvalidateScroll() => ((ILogicalScrollable)Owner).InvalidateScroll?.Invoke(); + protected void InvalidateScroll() => ((ILogicalScrollable)Owner).RaiseScrollInvalidated(EventArgs.Empty); } } diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs index 5943309cbb..02f2e7c9d9 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs @@ -21,6 +21,7 @@ namespace Avalonia.Controls.Presenters private bool _canHorizontallyScroll; private bool _canVerticallyScroll; + EventHandler _scrollInvalidated; /// /// Initializes static members of the class. @@ -95,13 +96,17 @@ namespace Avalonia.Controls.Presenters Size IScrollable.Viewport => Virtualizer?.Viewport ?? Bounds.Size; /// - Action ILogicalScrollable.InvalidateScroll { get; set; } + event EventHandler ILogicalScrollable.ScrollInvalidated + { + add => _scrollInvalidated += value; + remove => _scrollInvalidated -= value; + } /// - Size ILogicalScrollable.ScrollSize => new Size(1, 1); + Size ILogicalScrollable.ScrollSize => new Size(16, 1); /// - 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); } + /// + 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); } } } diff --git a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs index c0391c89ca..98a5b10023 100644 --- a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs +++ b/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; diff --git a/src/Avalonia.Controls/Primitives/ILogicalScrollable.cs b/src/Avalonia.Controls/Primitives/ILogicalScrollable.cs index 6e8d097dd1..5c29945735 100644 --- a/src/Avalonia.Controls/Primitives/ILogicalScrollable.cs +++ b/src/Avalonia.Controls/Primitives/ILogicalScrollable.cs @@ -31,22 +31,6 @@ namespace Avalonia.Controls.Primitives /// bool IsLogicalScrollEnabled { get; } - /// - /// Gets or sets the scroll invalidation method. - /// - /// - /// - /// This method notifies the attached of a change in - /// the , or - /// properties. - /// - /// - /// This property is set by the parent when the - /// is placed inside it. - /// - /// - Action InvalidateScroll { get; set; } - /// /// Gets the size to scroll by, in logical units. /// @@ -57,6 +41,15 @@ namespace Avalonia.Controls.Primitives /// Size PageScrollSize { get; } + /// + /// Raised when the scroll is invalidated. + /// + /// + /// This event notifies an attached of a change in + /// one of the scroll properties. + /// + event EventHandler ScrollInvalidated; + /// /// Attempts to bring a portion of the target visual into view by scrolling the content. /// @@ -72,5 +65,11 @@ namespace Avalonia.Controls.Primitives /// The control from which movement begins. /// The control. IControl GetControlInDirection(NavigationDirection direction, IControl from); + + /// + /// Raises the event. + /// + /// The event args. + void RaiseScrollInvalidated(EventArgs e); } } diff --git a/src/Avalonia.Controls/ScrollViewer.cs b/src/Avalonia.Controls/ScrollViewer.cs index b1f340bd65..f5881a8efe 100644 --- a/src/Avalonia.Controls/ScrollViewer.cs +++ b/src/Avalonia.Controls/ScrollViewer.cs @@ -10,6 +10,8 @@ namespace Avalonia.Controls /// public class ScrollViewer : ContentControl, IScrollable, IScrollAnchorProvider { + private static readonly Size s_defaultSmallChange = new Size(16, 16); + /// /// Defines the property. /// @@ -59,6 +61,22 @@ namespace Avalonia.Controls o => o.Viewport, (o, v) => o.Viewport = v); + /// + /// Defines the property. + /// + public static readonly DirectProperty LargeChangeProperty = + AvaloniaProperty.RegisterDirect( + nameof(LargeChange), + o => o.LargeChange); + + /// + /// Defines the property. + /// + public static readonly DirectProperty SmallChangeProperty = + AvaloniaProperty.RegisterDirect( + nameof(SmallChange), + o => o.SmallChange); + /// /// Defines the HorizontalScrollBarMaximum property. /// @@ -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; /// /// Initializes static members of the class. @@ -228,6 +250,16 @@ namespace Avalonia.Controls } } + /// + /// Gets the large (page) change value for the scroll viewer. + /// + public Size LargeChange => _largeChange; + + /// + /// Gets the small (line) change value for the scroll viewer. + /// + public Size SmallChange => _smallChange; + /// /// Gets or sets the horizontal scrollbar visibility. /// @@ -246,22 +278,6 @@ namespace Avalonia.Controls set { SetValue(VerticalScrollBarVisibilityProperty, value); } } - /// - /// Scrolls to the top-left corner of the content. - /// - public void ScrollToHome() - { - Offset = new Vector(double.NegativeInfinity, double.NegativeInfinity); - } - - /// - /// Scrolls to the bottom-left corner of the content. - /// - public void ScrollToEnd() - { - Offset = new Vector(double.NegativeInfinity, double.PositiveInfinity); - } - /// /// Gets a value indicating whether the viewer can scroll horizontally. /// @@ -347,6 +363,22 @@ namespace Avalonia.Controls /// IControl IScrollAnchorProvider.CurrentAnchor => null; // TODO: Implement + /// + /// Scrolls to the top-left corner of the content. + /// + public void ScrollToHome() + { + Offset = new Vector(double.NegativeInfinity, double.NegativeInfinity); + } + + /// + /// Scrolls to the bottom-left corner of the content. + /// + public void ScrollToEnd() + { + Offset = new Vector(double.NegativeInfinity, double.PositiveInfinity); + } + /// /// Gets the value of the HorizontalScrollBarVisibility attached property. /// @@ -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) diff --git a/src/Avalonia.Themes.Default/ScrollViewer.xaml b/src/Avalonia.Themes.Default/ScrollViewer.xaml index 38f4eef964..1d893133e1 100644 --- a/src/Avalonia.Themes.Default/ScrollViewer.xaml +++ b/src/Avalonia.Themes.Default/ScrollViewer.xaml @@ -22,6 +22,8 @@ _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 } } } -} \ No newline at end of file +} diff --git a/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs b/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs index f58a4cbffe..5375a244c9 100644 --- a/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs +++ b/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(); + var logicalScroll = child.As(); + + logicalScroll.Setup(x => x.IsLogicalScrollEnabled).Returns(true); + logicalScroll.Setup(x => x.ScrollSize).Returns(new Size(12, 43)); + + var target = new ScrollViewer + { + Template = new FuncControlTemplate(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(); + var logicalScroll = child.As(); + + logicalScroll.Setup(x => x.IsLogicalScrollEnabled).Returns(true); + logicalScroll.Setup(x => x.PageScrollSize).Returns(new Size(45, 67)); + + var target = new ScrollViewer + { + Template = new FuncControlTemplate(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