diff --git a/src/Avalonia.Controls/IScrollAnchorProvider.cs b/src/Avalonia.Controls/IScrollAnchorProvider.cs index 6b5cb2ee25..93f3a0abb8 100644 --- a/src/Avalonia.Controls/IScrollAnchorProvider.cs +++ b/src/Avalonia.Controls/IScrollAnchorProvider.cs @@ -1,9 +1,29 @@ namespace Avalonia.Controls { + /// + /// Specifies a contract for a scrolling control that supports scroll anchoring. + /// public interface IScrollAnchorProvider { + /// + /// The currently chosen anchor element to use for scroll anchoring. + /// IControl CurrentAnchor { get; } + + /// + /// Registers a control as a potential scroll anchor candidate. + /// + /// + /// A control within the subtree of the . + /// void RegisterAnchorCandidate(IControl element); + + /// + /// Unregisters a control as a potential scroll anchor candidate. + /// + /// + /// A control within the subtree of the . + /// void UnregisterAnchorCandidate(IControl element); } } diff --git a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs index 98a5b10023..5dae0043e1 100644 --- a/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs @@ -14,7 +14,7 @@ namespace Avalonia.Controls.Presenters /// /// Presents a scrolling view of content inside a . /// - public class ScrollContentPresenter : ContentPresenter, IPresenter, IScrollable + public class ScrollContentPresenter : ContentPresenter, IPresenter, IScrollable, IScrollAnchorProvider { /// /// Defines the property. @@ -65,6 +65,8 @@ namespace Avalonia.Controls.Presenters private IDisposable _logicalScrollSubscription; private Size _viewport; private Dictionary _activeLogicalGestureScrolls; + private List _anchorCandidates; + private (IControl control, Rect bounds) _anchor; /// /// Initializes static members of the class. @@ -132,6 +134,9 @@ namespace Avalonia.Controls.Presenters private set { SetAndRaise(ViewportProperty, ref _viewport, value); } } + /// + IControl IScrollAnchorProvider.CurrentAnchor => _anchor.Item1; + /// /// Attempts to bring a portion of the target visual into view by scrolling the content. /// @@ -196,6 +201,24 @@ namespace Avalonia.Controls.Presenters return result; } + /// + void IScrollAnchorProvider.RegisterAnchorCandidate(IControl element) + { + _anchorCandidates ??= new List(); + _anchorCandidates.Add(element); + } + + /// + void IScrollAnchorProvider.UnregisterAnchorCandidate(IControl element) + { + _anchorCandidates?.Remove(element); + + if (_anchor.Item1 == element) + { + _anchor = default; + } + } + /// protected override Size MeasureOverride(Size availableSize) { @@ -223,9 +246,44 @@ namespace Avalonia.Controls.Presenters var size = new Size( CanHorizontallyScroll ? Math.Max(Child.DesiredSize.Width, finalSize.Width) : finalSize.Width, CanVerticallyScroll ? Math.Max(Child.DesiredSize.Height, finalSize.Height) : finalSize.Height); + + Vector TrackAnchor() + { + // 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) + { + return updatedBounds.Position - _anchor.bounds.Position; + } + + return default; + } + + // Check whether our previous anchor (if any) has moved relative to Child since the last + // arrange, and if so adjust the offset to bring it back into place. + Offset += TrackAnchor(); + + // Calculate the new anchor element. + _anchor = CalculateCurrentAnchor(); + + // Do the arrange. ArrangeOverrideImpl(size, -Offset); + + // If the anchor moved during the arrange, we need to adjust the offset and do another arrange. + var postOffset = TrackAnchor(); + + if (postOffset != default) + { + Offset += postOffset; + ArrangeOverrideImpl(size, -Offset); + } + Viewport = finalSize; Extent = Child.Bounds.Size.Inflate(Child.Margin); + return finalSize; } @@ -390,5 +448,70 @@ namespace Avalonia.Controls.Presenters Offset = scrollable.Offset; } } + + (IControl, Rect) CalculateCurrentAnchor() + { + if (_anchorCandidates == null) + { + return default; + } + + var thisBounds = new Rect(Bounds.Size); + var bestCandidate = default(IControl); + var bestCandidateDistance = double.MaxValue; + + // Find the anchor candidate that is scrolled closest to the top-left of this + // ScrollContentPresenter. + foreach (var element in _anchorCandidates) + { + if (element.IsVisible && + TranslateBounds(element, this, out var bounds) && + bounds.Intersects(thisBounds)) + { + var distance = (Vector)bounds.Position; + var candidateDistance = Math.Abs(distance.Length); + + if (candidateDistance < bestCandidateDistance) + { + bestCandidate = element; + bestCandidateDistance = candidateDistance; + } + } + } + + if (bestCandidate != null) + { + // We have a candidate, calculate its bounds relative to Child. Because these + // 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); + } + + return default; + } + + private Rect TranslateBounds(IControl control, IControl to) + { + if (TranslateBounds(control, to, out var bounds)) + { + return bounds; + } + + throw new InvalidOperationException("The control's bounds could not be translated to the requested control."); + } + + private bool TranslateBounds(IControl control, IControl to, out Rect bounds) + { + if (!control.IsVisible) + { + bounds = default; + return false; + } + + var p = control.TranslatePoint(default, to); + bounds = p.HasValue ? new Rect(p.Value, control.Bounds.Size) : default; + return p.HasValue; + } } } diff --git a/src/Avalonia.Controls/Repeater/ViewportManager.cs b/src/Avalonia.Controls/Repeater/ViewportManager.cs index b1af4f0ecc..7f7b56909e 100644 --- a/src/Avalonia.Controls/Repeater/ViewportManager.cs +++ b/src/Avalonia.Controls/Repeater/ViewportManager.cs @@ -236,14 +236,12 @@ namespace Avalonia.Controls public void OnElementPrepared(IControl element) { - // If we have an anchor element, we do not want the - // scroll anchor provider to start anchoring some other element. - ////element.CanBeScrollAnchor(true); + _scroller.RegisterAnchorCandidate(element); } - public void OnElementCleared(ILayoutable element) + public void OnElementCleared(IControl element) { - ////element.CanBeScrollAnchor(false); + _scroller.UnregisterAnchorCandidate(element); } public void OnOwnerMeasuring() diff --git a/src/Avalonia.Controls/ScrollViewer.cs b/src/Avalonia.Controls/ScrollViewer.cs index 058c11c9ee..f2c6f85996 100644 --- a/src/Avalonia.Controls/ScrollViewer.cs +++ b/src/Avalonia.Controls/ScrollViewer.cs @@ -316,6 +316,9 @@ namespace Avalonia.Controls get { return VerticalScrollBarVisibility != ScrollBarVisibility.Disabled; } } + /// + public IControl CurrentAnchor => (Presenter as IScrollAnchorProvider)?.CurrentAnchor; + /// /// Gets the maximum horizontal scrollbar value. /// @@ -382,9 +385,6 @@ namespace Avalonia.Controls get { return _viewport.Height; } } - /// - IControl IScrollAnchorProvider.CurrentAnchor => null; // TODO: Implement - /// /// Scrolls to the top-left corner of the content. /// @@ -441,14 +441,16 @@ namespace Avalonia.Controls control.SetValue(VerticalScrollBarVisibilityProperty, value); } - void IScrollAnchorProvider.RegisterAnchorCandidate(IControl element) + /// + public void RegisterAnchorCandidate(IControl element) { - // TODO: Implement + (Presenter as IScrollAnchorProvider)?.RegisterAnchorCandidate(element); } - void IScrollAnchorProvider.UnregisterAnchorCandidate(IControl element) + /// + public void UnregisterAnchorCandidate(IControl element) { - // TODO: Implement + (Presenter as IScrollAnchorProvider)?.UnregisterAnchorCandidate(element); } protected override bool RegisterContentPresenter(IContentPresenter presenter)