diff --git a/src/Avalonia.Controls/IScrollAnchorProvider.cs b/src/Avalonia.Controls/IScrollAnchorProvider.cs index 93f3a0abb8..7ba02e99ea 100644 --- a/src/Avalonia.Controls/IScrollAnchorProvider.cs +++ b/src/Avalonia.Controls/IScrollAnchorProvider.cs @@ -1,4 +1,6 @@ -namespace Avalonia.Controls +#nullable enable + +namespace Avalonia.Controls { /// /// Specifies a contract for a scrolling control that supports scroll anchoring. @@ -8,7 +10,7 @@ /// /// The currently chosen anchor element to use for scroll anchoring. /// - IControl CurrentAnchor { get; } + IControl? CurrentAnchor { get; } /// /// Registers a control as a potential scroll anchor candidate. diff --git a/src/Avalonia.Controls/Panel.cs b/src/Avalonia.Controls/Panel.cs index ccb92dc497..b7eeb065da 100644 --- a/src/Avalonia.Controls/Panel.cs +++ b/src/Avalonia.Controls/Panel.cs @@ -137,6 +137,11 @@ namespace Avalonia.Controls throw new NotSupportedException(); } + InvalidateMeasureOnChildrenChanged(); + } + + private protected virtual void InvalidateMeasureOnChildrenChanged() + { InvalidateMeasure(); } diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs index 3fac440c40..bdc68bee7e 100644 --- a/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs +++ b/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs @@ -512,6 +512,14 @@ namespace Avalonia.Controls.Presenters var generator = Owner.ItemContainerGenerator; var newOffset = -1.0; + if (!panel.IsMeasureValid && panel.PreviousMeasure.HasValue) + { + //before any kind of scrolling we need to make sure panel measure is valid + //or we risk get panel into not valid state + //we make a preemptive quick measure so scrolling is valid + panel.Measure(panel.PreviousMeasure.Value); + } + if (index >= 0 && index < ItemCount) { if (index <= FirstIndex) diff --git a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs index 5fcb14c858..b0b52812b9 100644 --- a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs @@ -7,6 +7,8 @@ using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.VisualTree; +#nullable enable + namespace Avalonia.Controls.Presenters { /// @@ -14,6 +16,8 @@ namespace Avalonia.Controls.Presenters /// public class ScrollContentPresenter : ContentPresenter, IPresenter, IScrollable, IScrollAnchorProvider { + private const double EdgeDetectionTolerance = 0.1; + /// /// Defines the property. /// @@ -64,11 +68,13 @@ namespace Avalonia.Controls.Presenters private bool _arranging; private Size _extent; private Vector _offset; - private IDisposable _logicalScrollSubscription; + private IDisposable? _logicalScrollSubscription; private Size _viewport; - private Dictionary _activeLogicalGestureScrolls; - private List _anchorCandidates; - private (IControl control, Rect bounds) _anchor; + private Dictionary? _activeLogicalGestureScrolls; + private List? _anchorCandidates; + private IControl? _anchorElement; + private Rect _anchorElementBounds; + private bool _isAnchorElementDirty; /// /// Initializes static members of the class. @@ -90,8 +96,6 @@ namespace Avalonia.Controls.Presenters this.GetObservable(ChildProperty).Subscribe(UpdateScrollableSubscription); } - internal event EventHandler PreArrange; - /// /// Gets or sets a value indicating whether the content can be scrolled horizontally. /// @@ -138,7 +142,14 @@ namespace Avalonia.Controls.Presenters } /// - IControl IScrollAnchorProvider.CurrentAnchor => _anchor.control; + IControl? IScrollAnchorProvider.CurrentAnchor + { + get + { + EnsureAnchorElementSelection(); + return _anchorElement; + } + } /// /// Attempts to bring a portion of the target visual into view by scrolling the content. @@ -215,16 +226,18 @@ namespace Avalonia.Controls.Presenters _anchorCandidates ??= new List(); _anchorCandidates.Add(element); + _isAnchorElementDirty = true; } /// void IScrollAnchorProvider.UnregisterAnchorCandidate(IControl element) { _anchorCandidates?.Remove(element); + _isAnchorElementDirty = true; - if (_anchor.control == element) + if (_anchorElement == element) { - _anchor = default; + _anchorElement = null; } } @@ -247,11 +260,6 @@ namespace Avalonia.Controls.Presenters /// protected override Size ArrangeOverride(Size finalSize) { - PreArrange?.Invoke(this, new VectorEventArgs - { - Vector = new Vector(finalSize.Width, finalSize.Height), - }); - if (_logicalScrollSubscription != null || Child == null) { return base.ArrangeOverride(finalSize); @@ -271,59 +279,69 @@ namespace Avalonia.Controls.Presenters // If we have an anchor and its position relative to Child has changed during the // arrange then that change wasn't just due to scrolling (as scrolling doesn't adjust // relative positions within Child). - if (_anchor.control != null && - TranslateBounds(_anchor.control, Child, out var updatedBounds) && - updatedBounds.Position != _anchor.bounds.Position) + if (_anchorElement != null && + TranslateBounds(_anchorElement, Child, out var updatedBounds) && + updatedBounds.Position != _anchorElementBounds.Position) { - var offset = updatedBounds.Position - _anchor.bounds.Position; + var offset = updatedBounds.Position - _anchorElementBounds.Position; return offset; } return default; } - // Calculate the new anchor element. - _anchor = CalculateCurrentAnchor(); + var isAnchoring = Offset.X >= EdgeDetectionTolerance || Offset.Y >= EdgeDetectionTolerance; - // Do the arrange. - ArrangeOverrideImpl(size, -Offset); + if (isAnchoring) + { + // Calculate the new anchor element if necessary. + EnsureAnchorElementSelection(); - // If the anchor moved during the arrange, we need to adjust the offset and do another arrange. - var anchorShift = TrackAnchor(); + // Do the arrange. + ArrangeOverrideImpl(size, -Offset); - if (anchorShift != default) - { - var newOffset = Offset + anchorShift; - var newExtent = Extent; - var maxOffset = new Vector(Extent.Width - Viewport.Width, Extent.Height - Viewport.Height); + // If the anchor moved during the arrange, we need to adjust the offset and do another arrange. + var anchorShift = TrackAnchor(); - if (newOffset.X > maxOffset.X) + if (anchorShift != default) { - newExtent = newExtent.WithWidth(newOffset.X + Viewport.Width); - } + var newOffset = Offset + anchorShift; + var newExtent = Extent; + var maxOffset = new Vector(Extent.Width - Viewport.Width, Extent.Height - Viewport.Height); - if (newOffset.Y > maxOffset.Y) - { - newExtent = newExtent.WithHeight(newOffset.Y + Viewport.Height); - } + if (newOffset.X > maxOffset.X) + { + newExtent = newExtent.WithWidth(newOffset.X + Viewport.Width); + } - Extent = newExtent; + if (newOffset.Y > maxOffset.Y) + { + newExtent = newExtent.WithHeight(newOffset.Y + Viewport.Height); + } - try - { - _arranging = true; - Offset = newOffset; - } - finally - { - _arranging = false; + Extent = newExtent; + + try + { + _arranging = true; + Offset = newOffset; + } + finally + { + _arranging = false; + } + + ArrangeOverrideImpl(size, -Offset); } - + } + else + { ArrangeOverrideImpl(size, -Offset); } Viewport = finalSize; Extent = Child.Bounds.Size.Inflate(Child.Margin); + _isAnchorElementDirty = true; return finalSize; } @@ -350,7 +368,7 @@ namespace Avalonia.Controls.Presenters { var logicalUnits = delta.Y / LogicalScrollItemSize; delta = delta.WithY(delta.Y - logicalUnits * LogicalScrollItemSize); - dy = logicalUnits * scrollable.ScrollSize.Height; + dy = logicalUnits * scrollable!.ScrollSize.Height; } else dy = delta.Y; @@ -368,7 +386,7 @@ namespace Avalonia.Controls.Presenters { var logicalUnits = delta.X / LogicalScrollItemSize; delta = delta.WithX(delta.X - logicalUnits * LogicalScrollItemSize); - dx = logicalUnits * scrollable.ScrollSize.Width; + dx = logicalUnits * scrollable!.ScrollSize.Width; } else dx = delta.X; @@ -405,7 +423,7 @@ namespace Avalonia.Controls.Presenters if (Extent.Height > Viewport.Height) { - double height = isLogical ? scrollable.ScrollSize.Height : 50; + double height = isLogical ? scrollable!.ScrollSize.Height : 50; y += -e.Delta.Y * height; y = Math.Max(y, 0); y = Math.Min(y, Extent.Height - Viewport.Height); @@ -413,7 +431,7 @@ namespace Avalonia.Controls.Presenters if (Extent.Width > Viewport.Width) { - double width = isLogical ? scrollable.ScrollSize.Width : 50; + double width = isLogical ? scrollable!.ScrollSize.Width : 50; x += -e.Delta.X * width; x = Math.Max(x, 0); x = Math.Min(x, Extent.Width - Viewport.Width); @@ -441,7 +459,7 @@ namespace Avalonia.Controls.Presenters private void ChildChanged(AvaloniaPropertyChangedEventArgs e) { - UpdateScrollableSubscription((IControl)e.NewValue); + UpdateScrollableSubscription((IControl?)e.NewValue); if (e.OldValue != null) { @@ -449,7 +467,7 @@ namespace Avalonia.Controls.Presenters } } - private void UpdateScrollableSubscription(IControl child) + private void UpdateScrollableSubscription(IControl? child) { var scrollable = child as ILogicalScrollable; @@ -498,13 +516,17 @@ namespace Avalonia.Controls.Presenters } } - private (IControl, Rect) CalculateCurrentAnchor() + private void EnsureAnchorElementSelection() { - if (_anchorCandidates == null) + if (!_isAnchorElementDirty || _anchorCandidates is null) { - return default; + return; } + _anchorElement = null; + _anchorElementBounds = default; + _isAnchorElementDirty = false; + var bestCandidate = default(IControl); var bestCandidateDistance = double.MaxValue; @@ -531,10 +553,9 @@ namespace Avalonia.Controls.Presenters // bounds aren't relative to the ScrollContentPresenter itself, if they change // then we know it wasn't just due to scrolling. var unscrolledBounds = TranslateBounds(bestCandidate, Child); - return (bestCandidate, unscrolledBounds); + _anchorElement = bestCandidate; + _anchorElementBounds = unscrolledBounds; } - - return default; } private bool GetViewportBounds(IControl element, out Rect bounds) diff --git a/src/Avalonia.Controls/Primitives/ToggleButton.cs b/src/Avalonia.Controls/Primitives/ToggleButton.cs index f96ca9310d..6b2c566422 100644 --- a/src/Avalonia.Controls/Primitives/ToggleButton.cs +++ b/src/Avalonia.Controls/Primitives/ToggleButton.cs @@ -94,7 +94,7 @@ namespace Avalonia.Controls.Primitives set { SetAndRaise(IsCheckedProperty, ref _isChecked, value); - UpdatePseudoClasses(value); + UpdatePseudoClasses(IsChecked); } } diff --git a/src/Avalonia.Controls/Repeater/ItemsRepeater.cs b/src/Avalonia.Controls/Repeater/ItemsRepeater.cs index 40f1b8dbb9..fb2da09e73 100644 --- a/src/Avalonia.Controls/Repeater/ItemsRepeater.cs +++ b/src/Avalonia.Controls/Repeater/ItemsRepeater.cs @@ -267,6 +267,11 @@ namespace Avalonia.Controls return result; } + private protected override void InvalidateMeasureOnChildrenChanged() + { + // Don't invalidate measure when children change. + } + protected override Size MeasureOverride(Size availableSize) { if (_isLayoutInProgress) @@ -364,6 +369,12 @@ namespace Avalonia.Controls { var newBounds = element.Bounds; virtInfo.ArrangeBounds = newBounds; + + if (!virtInfo.IsRegisteredAsAnchorCandidate) + { + _viewportManager.RegisterScrollAnchorCandidate(element); + virtInfo.IsRegisteredAsAnchorCandidate = true; + } } } @@ -515,11 +526,14 @@ namespace Avalonia.Controls return element; } - internal void OnElementPrepared(IControl element, int index) + internal void OnElementPrepared(IControl element, VirtualizationInfo virtInfo) { - _viewportManager.OnElementPrepared(element); + _viewportManager.OnElementPrepared(element, virtInfo); + if (ElementPrepared != null) { + var index = virtInfo.Index; + if (_elementPreparedArgs == null) { _elementPreparedArgs = new ItemsRepeaterElementPreparedEventArgs(element, index); diff --git a/src/Avalonia.Controls/Repeater/ViewManager.cs b/src/Avalonia.Controls/Repeater/ViewManager.cs index 416b1e2824..cf2066b373 100644 --- a/src/Avalonia.Controls/Repeater/ViewManager.cs +++ b/src/Avalonia.Controls/Repeater/ViewManager.cs @@ -661,7 +661,7 @@ namespace Avalonia.Controls children.Add(element); } - repeater.OnElementPrepared(element, index); + repeater.OnElementPrepared(element, virtInfo); // Update realized indices _firstRealizedElementIndexHeldByLayout = Math.Min(_firstRealizedElementIndexHeldByLayout, index); diff --git a/src/Avalonia.Controls/Repeater/ViewportManager.cs b/src/Avalonia.Controls/Repeater/ViewportManager.cs index bdb0fa3270..6e24408aa9 100644 --- a/src/Avalonia.Controls/Repeater/ViewportManager.cs +++ b/src/Avalonia.Controls/Repeater/ViewportManager.cs @@ -240,9 +240,14 @@ namespace Avalonia.Controls } } - public void OnElementPrepared(IControl element) + public void OnElementPrepared(IControl element, VirtualizationInfo virtInfo) { - _scroller?.RegisterAnchorCandidate(element); + // WinUI registers the element as an anchor candidate here, but I feel that's in error: + // at this point the element has not yet been positioned by the arrange pass so it will + // have its previous position, meaning that when the arrange pass moves it into its new + // position, an incorrect scroll anchoring will occur. Instead signal that it's not yet + // registered as a scroll anchor candidate. + virtInfo.IsRegisteredAsAnchorCandidate = false; } public void OnElementCleared(IControl element) @@ -373,6 +378,11 @@ namespace Avalonia.Controls } } + public void RegisterScrollAnchorCandidate(IControl element) + { + _scroller?.RegisterAnchorCandidate(element); + } + private IControl GetImmediateChildOfRepeater(IControl descendant) { var targetChild = descendant; diff --git a/src/Avalonia.Controls/Repeater/VirtualizationInfo.cs b/src/Avalonia.Controls/Repeater/VirtualizationInfo.cs index 7a639419c1..f8cfde609e 100644 --- a/src/Avalonia.Controls/Repeater/VirtualizationInfo.cs +++ b/src/Avalonia.Controls/Repeater/VirtualizationInfo.cs @@ -38,6 +38,7 @@ namespace Avalonia.Controls public bool IsInUniqueIdResetPool => Owner == ElementOwner.UniqueIdResetPool; public bool MustClearDataContext { get; set; } public bool KeepAlive { get; set; } + public bool IsRegisteredAsAnchorCandidate { get; set; } public ElementOwner Owner { get; private set; } = ElementOwner.ElementFactory; public string UniqueId { get; private set; } diff --git a/src/Avalonia.Layout/StackLayout.cs b/src/Avalonia.Layout/StackLayout.cs index 909c7bc7eb..4a93c8344f 100644 --- a/src/Avalonia.Layout/StackLayout.cs +++ b/src/Avalonia.Layout/StackLayout.cs @@ -249,8 +249,8 @@ namespace Avalonia.Layout realizationWindowOffsetInExtent + _orientation.MajorSize(realizationRect) >= 0 && realizationWindowOffsetInExtent <= majorSize) { anchorIndex = (int) (realizationWindowOffsetInExtent / averageElementSize); - offset = anchorIndex* averageElementSize + _orientation.MajorStart(lastExtent); anchorIndex = Math.Max(0, Math.Min(itemsCount - 1, anchorIndex)); + offset = anchorIndex* averageElementSize + _orientation.MajorStart(lastExtent); } } diff --git a/src/Avalonia.Themes.Fluent/Button.xaml b/src/Avalonia.Themes.Fluent/Button.xaml index 8522c933ae..573b9f337f 100644 --- a/src/Avalonia.Themes.Fluent/Button.xaml +++ b/src/Avalonia.Themes.Fluent/Button.xaml @@ -32,7 +32,6 @@ BorderThickness="{TemplateBinding BorderThickness}" Content="{TemplateBinding Content}" ContentTemplate="{TemplateBinding ContentTemplate}" - CornerRadius="{DynamicResource ControlCornerRadius}" Padding="{TemplateBinding Padding}" HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" /> @@ -95,4 +94,8 @@ + + diff --git a/src/Avalonia.Themes.Fluent/CheckBox.xaml b/src/Avalonia.Themes.Fluent/CheckBox.xaml index 678ae5c5a3..83d2779872 100644 --- a/src/Avalonia.Themes.Fluent/CheckBox.xaml +++ b/src/Avalonia.Themes.Fluent/CheckBox.xaml @@ -22,16 +22,14 @@ Grid.ColumnSpan="2" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" - BorderThickness="{TemplateBinding BorderThickness}" - CornerRadius="{DynamicResource ControlCornerRadius}" /> + BorderThickness="{TemplateBinding BorderThickness}" /> + Width="20" /> @@ -52,6 +50,14 @@ + + + + + + + + diff --git a/src/Avalonia.Themes.Fluent/RadioButton.xaml b/src/Avalonia.Themes.Fluent/RadioButton.xaml index 078f51c87a..2847d1fa5a 100644 --- a/src/Avalonia.Themes.Fluent/RadioButton.xaml +++ b/src/Avalonia.Themes.Fluent/RadioButton.xaml @@ -25,8 +25,7 @@ + BorderThickness="{TemplateBinding BorderThickness}"> @@ -77,6 +76,10 @@ + + diff --git a/src/Avalonia.Themes.Fluent/TabItem.xaml b/src/Avalonia.Themes.Fluent/TabItem.xaml index 2b0a0c1ea0..1c9574f169 100644 --- a/src/Avalonia.Themes.Fluent/TabItem.xaml +++ b/src/Avalonia.Themes.Fluent/TabItem.xaml @@ -39,7 +39,6 @@ TextBlock.FontSize="{TemplateBinding FontSize}" TextBlock.FontWeight="{TemplateBinding FontWeight}" /> @@ -53,6 +52,7 @@ diff --git a/src/Avalonia.Themes.Fluent/TabStripItem.xaml b/src/Avalonia.Themes.Fluent/TabStripItem.xaml index 628ab8dddd..78ef102705 100644 --- a/src/Avalonia.Themes.Fluent/TabStripItem.xaml +++ b/src/Avalonia.Themes.Fluent/TabStripItem.xaml @@ -38,7 +38,6 @@ TextBlock.FontSize="{TemplateBinding FontSize}" TextBlock.FontWeight="{TemplateBinding FontWeight}" /> @@ -46,6 +45,9 @@ + + + diff --git a/src/Avalonia.Themes.Fluent/ToggleButton.xaml b/src/Avalonia.Themes.Fluent/ToggleButton.xaml index 49e2280a6d..dd8e51e4e5 100644 --- a/src/Avalonia.Themes.Fluent/ToggleButton.xaml +++ b/src/Avalonia.Themes.Fluent/ToggleButton.xaml @@ -29,7 +29,6 @@ Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" - CornerRadius="{DynamicResource ControlCornerRadius}" Content="{TemplateBinding Content}" ContentTemplate="{TemplateBinding ContentTemplate}" Padding="{TemplateBinding Padding}" @@ -38,6 +37,10 @@ + +