Files
osu-framework/osu.Framework/Graphics/Containers/ScrollContainer.cs
Dean Herbert 8166354560 Change OffsetScrollPosition to take a double
In line with other changes, this should have been switched to `double`.
Also making `virtual` for usage within the beatmap carousel (I need to
extend what this function does for this usage).
2025-01-15 16:49:47 +09:00

708 lines
29 KiB
C#

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Diagnostics;
using JetBrains.Annotations;
using osu.Framework.Caching;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Layout;
using osu.Framework.Utils;
using osuTK;
using osuTK.Input;
namespace osu.Framework.Graphics.Containers
{
public abstract partial class ScrollContainer<T> : Container<T>, IScrollContainer, DelayedLoadWrapper.IOnScreenOptimisingContainer, IKeyBindingHandler<PlatformAction>
where T : Drawable
{
/// <summary>
/// Determines whether the scroll dragger appears on the left side. If not, then it always appears on the right side.
/// </summary>
public Anchor ScrollbarAnchor
{
get => Scrollbar.Anchor;
set
{
Scrollbar.Anchor = value;
Scrollbar.Origin = value;
updatePadding();
}
}
private bool scrollbarVisible = true;
/// <summary>
/// Whether the scrollbar is visible.
/// </summary>
public bool ScrollbarVisible
{
get => scrollbarVisible;
set
{
scrollbarVisible = value;
scrollbarCache.Invalidate();
}
}
protected readonly ScrollbarContainer Scrollbar;
private bool scrollbarOverlapsContent = true;
/// <summary>
/// Whether the scrollbar overlaps the content or resides in its own padded space.
/// </summary>
public bool ScrollbarOverlapsContent
{
get => scrollbarOverlapsContent;
set
{
scrollbarOverlapsContent = value;
updatePadding();
}
}
/// <summary>
/// Size of available content (i.e. everything that can be scrolled to) in the scroll direction.
/// </summary>
public float AvailableContent => ScrollContent.DrawSize[ScrollDim];
/// <summary>
/// Size of the viewport in the scroll direction.
/// </summary>
public float DisplayableContent => ChildSize[ScrollDim];
/// <summary>
/// Controls the distance scrolled per unit of mouse scroll.
/// </summary>
public float ScrollDistance = 80;
/// <summary>
/// This limits how far out of clamping bounds we allow the target position to be at most.
/// Effectively, larger values result in bouncier behavior as the scroll boundaries are approached
/// with high velocity.
/// </summary>
public float ClampExtension = 500;
/// <summary>
/// This corresponds to the clamping force. A larger value means more aggressive clamping. Default is 0.012.
/// </summary>
private const double distance_decay_clamping = 0.012;
/// <summary>
/// Controls the rate with which the target position is approached after ending a drag. Default is 0.0035.
/// </summary>
public double DistanceDecayDrag = 0.0035;
/// <summary>
/// Controls the rate with which the target position is approached after scrolling. Default is 0.01
/// </summary>
public double DistanceDecayScroll = 0.01;
/// <summary>
/// Controls the rate with which the target position is approached after jumping to a specific location. Default is 0.01.
/// </summary>
public double DistanceDecayJump = 0.01;
/// <summary>
/// Controls the rate with which the target position is approached. It is automatically set after
/// dragging or scrolling.
/// </summary>
private double distanceDecay;
/// <summary>
/// The current scroll position.
/// </summary>
public double Current { get; private set; }
/// <summary>
/// The target scroll position which is exponentially approached by current via a rate of distance decay.
/// </summary>
/// <remarks>
/// When not animating scroll position, this will always be equal to <see cref="Current"/>.
/// </remarks>
public double Target { get; private set; }
/// <summary>
/// The maximum distance that can be scrolled in the scroll direction.
/// </summary>
public double ScrollableExtent => Math.Max(AvailableContent - DisplayableContent, 0);
/// <summary>
/// The maximum distance that the scrollbar can move in the scroll direction.
/// </summary>
/// <remarks>
/// May not be accurate to actual display of scrollbar if <see cref="ToScrollbarPosition"/> or <see cref="FromScrollbarPosition"/> are overridden.
/// </remarks>
protected double ScrollbarMovementExtent => Math.Max(DisplayableContent - Scrollbar.DrawSize[ScrollDim], 0);
/// <summary>
/// Clamp a value to the available scroll range.
/// </summary>
/// <param name="position">The value to clamp.</param>
/// <param name="extension">An extension value beyond the normal extent.</param>
protected double Clamp(double position, double extension = 0) => Math.Max(Math.Min(position, ScrollableExtent + extension), -extension);
protected override Container<T> Content => ScrollContent;
/// <summary>
/// Whether we are currently scrolled to the beginning of content.
/// </summary>
/// <param name="lenience">How close to the extent we need to be.</param>
public bool IsScrolledToStart(float lenience = Precision.FLOAT_EPSILON) => Precision.AlmostBigger(0, Target, lenience);
/// <summary>
/// Whether we are currently scrolled as far as possible into the scroll direction.
/// </summary>
/// <param name="lenience">How close to the extent we need to be.</param>
public bool IsScrolledToEnd(float lenience = Precision.FLOAT_EPSILON) => Precision.AlmostBigger(Target, ScrollableExtent, lenience);
/// <summary>
/// The container holding all children which are getting scrolled around.
/// </summary>
public Container<T> ScrollContent { get; }
protected virtual bool IsDragging { get; private set; }
public bool IsHandlingKeyboardScrolling
{
get
{
if (IsHovered)
return true;
InputManager inputManager = GetContainingInputManager();
return inputManager != null && ReceivePositionalInputAt(inputManager.CurrentState.Mouse.Position);
}
}
public Direction ScrollDirection { get; }
/// <summary>
/// The direction in which scrolling is supported, converted to an int for array index lookups.
/// </summary>
protected int ScrollDim => ScrollDirection == Direction.Horizontal ? 0 : 1;
private readonly LayoutValue<IScrollContainer> parentScrollContainerCache = new LayoutValue<IScrollContainer>(Invalidation.Parent);
[CanBeNull]
private IScrollContainer parentScrollContainer => parentScrollContainerCache.IsValid
? parentScrollContainerCache.Value
: parentScrollContainerCache.Value = this.FindClosestParent<IScrollContainer>();
/// <summary>
/// Creates a scroll container.
/// </summary>
/// <param name="scrollDirection">The direction in which should be scrolled. Can be vertical or horizontal. Default is vertical.</param>
protected ScrollContainer(Direction scrollDirection = Direction.Vertical)
{
ScrollDirection = scrollDirection;
Masking = true;
Axes scrollAxis = scrollDirection == Direction.Horizontal ? Axes.X : Axes.Y;
AddRangeInternal(new Drawable[]
{
ScrollContent = new Container<T>
{
RelativeSizeAxes = Axes.Both & ~scrollAxis,
AutoSizeAxes = scrollAxis,
},
Scrollbar = CreateScrollbar(scrollDirection)
});
Scrollbar.Hide();
Scrollbar.Dragged = onScrollbarMovement;
ScrollbarAnchor = scrollDirection == Direction.Vertical ? Anchor.TopRight : Anchor.BottomLeft;
AddLayout(parentScrollContainerCache);
}
private float lastUpdateDisplayableContent = -1;
private float lastAvailableContent = -1;
private void updateSize()
{
// ensure we only update scrollbar when something has changed, to avoid transform helpers resetting their transform every frame.
// also avoids creating many needless Transforms every update frame.
if (lastAvailableContent != AvailableContent || lastUpdateDisplayableContent != DisplayableContent)
{
lastAvailableContent = AvailableContent;
lastUpdateDisplayableContent = DisplayableContent;
scrollbarCache.Invalidate();
}
}
private readonly Cached scrollbarCache = new Cached();
private void updatePadding()
{
if (scrollbarOverlapsContent || !Precision.DefinitelyBigger(AvailableContent, DisplayableContent, 1f))
ScrollContent.Padding = new MarginPadding();
else
{
if (ScrollDirection == Direction.Vertical)
{
ScrollContent.Padding = ScrollbarAnchor == Anchor.TopLeft
? new MarginPadding { Left = Scrollbar.Width + Scrollbar.Margin.Left }
: new MarginPadding { Right = Scrollbar.Width + Scrollbar.Margin.Right };
}
else
{
ScrollContent.Padding = ScrollbarAnchor == Anchor.TopLeft
? new MarginPadding { Top = Scrollbar.Height + Scrollbar.Margin.Top }
: new MarginPadding { Bottom = Scrollbar.Height + Scrollbar.Margin.Bottom };
}
}
}
protected override bool OnDragStart(DragStartEvent e)
{
if (IsDragging || e.Button != MouseButton.Left || Content.AliveInternalChildren.Count == 0)
return false;
if (parentScrollContainer != null && parentScrollContainer.ScrollDirection != ScrollDirection)
{
bool dragWasMostlyHorizontal = Math.Abs(e.Delta.X) > Math.Abs(e.Delta.Y);
if (dragWasMostlyHorizontal != (ScrollDirection == Direction.Horizontal))
return false;
}
lastDragTime = Time.Current;
averageDragDelta = averageDragTime = 0;
IsDragging = true;
dragButtonManager = GetContainingInputManager().AsNonNull().GetButtonEventManagerFor(e.Button);
return true;
}
protected override bool OnKeyDown(KeyDownEvent e)
{
if (IsHandlingKeyboardScrolling && !IsDragging)
{
switch (e.Key)
{
case Key.PageUp:
OnUserScroll(Target - DisplayableContent);
return true;
case Key.PageDown:
OnUserScroll(Target + DisplayableContent);
return true;
}
}
return base.OnKeyDown(e);
}
protected override bool OnMouseDown(MouseDownEvent e)
{
if (IsDragging || e.Button != MouseButton.Left)
return false;
// Continue from where we currently are scrolled to.
Target = Current;
return false;
}
// We keep track of this because input events may happen at different intervals than update frames
// and we are interested in the time difference between drag _input_ events.
private double lastDragTime;
// These keep track of a sliding average (w.r.t. time) of the time between drag events
// and the delta of drag events. Both of these moving averages are decayed at the same
// rate and thus the velocity remains constant across time. The overall magnitude
// of averageDragTime and averageDragDelta simple decreases such that more recent movements
// have a larger weight.
private double averageDragTime;
private double averageDragDelta;
private MouseButtonEventManager dragButtonManager;
private bool dragBlocksClick;
public override bool DragBlocksClick => dragBlocksClick;
protected override void OnDrag(DragEvent e)
{
Trace.Assert(IsDragging, "We should never receive OnDrag if we are not dragging.");
double currentTime = Time.Current;
double timeDelta = currentTime - lastDragTime;
double decay = Math.Pow(0.95, timeDelta);
averageDragTime = averageDragTime * decay + timeDelta;
averageDragDelta = averageDragDelta * decay - e.Delta[ScrollDim];
lastDragTime = currentTime;
Vector2 childDelta = ToLocalSpace(e.ScreenSpaceMousePosition) - ToLocalSpace(e.ScreenSpaceLastMousePosition);
double scrollOffset = -childDelta[ScrollDim];
double clampedScrollOffset = Clamp(Target + scrollOffset) - Clamp(Target);
// If we are dragging past the extent of the scrollable area, half the offset
// such that the user can feel it.
scrollOffset = clampedScrollOffset + (scrollOffset - clampedScrollOffset) / 2;
// similar calculation to what is already done in MouseButtonEventManager.HandlePositionChange
// handles the case where a drag was triggered on an axis we are not interested in.
// can be removed if/when drag events are split out per axis or contain direction information.
dragBlocksClick |= Math.Abs(e.MouseDownPosition[ScrollDim] - e.MousePosition[ScrollDim]) > dragButtonManager.ClickDragDistance;
scrollByOffset(scrollOffset, false);
}
protected override void OnDragEnd(DragEndEvent e)
{
Trace.Assert(IsDragging, "We should never receive OnDragEnd if we are not dragging.");
dragBlocksClick = false;
dragButtonManager = null;
IsDragging = false;
if (averageDragTime <= 0.0)
return;
double velocity = averageDragDelta / averageDragTime;
// Detect whether we halted at the end of the drag and in fact should _not_
// perform a flick event.
const double velocity_cutoff = 0.1;
if (Math.Abs(Math.Pow(0.95, Time.Current - lastDragTime) * velocity) < velocity_cutoff)
velocity = 0;
// Differentiate f(t) = distance * (1 - exp(-t)) w.r.t. "t" to obtain
// velocity w.r.t. time. Then rearrange to solve for distance given velocity.
double distance = velocity / (1 - Math.Exp(-DistanceDecayDrag));
scrollByOffset((float)distance, true, DistanceDecayDrag);
}
protected override bool OnScroll(ScrollEvent e)
{
if (Content.AliveInternalChildren.Count == 0)
return false;
if (parentScrollContainer != null && parentScrollContainer.ScrollDirection != ScrollDirection)
{
bool scrollWasMostlyHorizontal = Math.Abs(e.ScrollDelta.X) > Math.Abs(e.ScrollDelta.Y);
// For horizontal scrolling containers, vertical scroll is also used to perform horizontal traversal.
// Due to this, we only block horizontal scroll in vertical containers, but not vice-versa.
if (scrollWasMostlyHorizontal && ScrollDirection == Direction.Vertical)
return false;
}
bool isPrecise = e.IsPrecise;
Vector2 scrollDelta = e.ScrollDelta;
float scrollDeltaFloat = scrollDelta.Y;
if (ScrollDirection == Direction.Horizontal && scrollDelta.X != 0)
scrollDeltaFloat = scrollDelta.X;
scrollByOffset(ScrollDistance * -scrollDeltaFloat, true, isPrecise ? 0.05 : DistanceDecayScroll);
return true;
}
private void onScrollbarMovement(float value) => OnUserScroll(Clamp(FromScrollbarPosition(value)), false);
/// <summary>
/// Immediately offsets the current and target scroll position.
/// </summary>
/// <param name="offset">The scroll offset.</param>
public virtual void OffsetScrollPosition(double offset)
{
Target += offset;
Current += offset;
}
private void scrollByOffset(double value, bool animated, double distanceDecay = float.PositiveInfinity) =>
OnUserScroll(Target + value, animated, distanceDecay);
/// <summary>
/// Scroll to the start of available content.
/// </summary>
/// <param name="animated">Whether to animate the movement.</param>
/// <param name="allowDuringDrag">Whether we should interrupt a user's active drag.</param>
public void ScrollToStart(bool animated = true, bool allowDuringDrag = false)
{
if (!IsDragging || allowDuringDrag)
scrollTo(0, animated, DistanceDecayJump);
}
/// <summary>
/// Scroll to the end of available content.
/// </summary>
/// <param name="animated">Whether to animate the movement.</param>
/// <param name="allowDuringDrag">Whether we should interrupt a user's active drag.</param>
public void ScrollToEnd(bool animated = true, bool allowDuringDrag = false)
{
if (!IsDragging || allowDuringDrag)
scrollTo(ScrollableExtent, animated, DistanceDecayJump);
}
/// <summary>
/// Scrolls to a new position relative to the current scroll offset.
/// </summary>
/// <param name="offset">The amount by which we should scroll.</param>
/// <param name="animated">Whether to animate the movement.</param>
public void ScrollBy(double offset, bool animated = true) => scrollTo(Target + offset, animated);
/// <summary>
/// Handle a scroll to an absolute position from a user input.
/// </summary>
/// <param name="value">The position to scroll to.</param>
/// <param name="animated">Whether to animate the movement.</param>
/// <param name="distanceDecay">Controls the rate with which the target position is approached after jumping to a specific location. Default is <see cref="DistanceDecayJump"/>.</param>
protected virtual void OnUserScroll(double value, bool animated = true, double? distanceDecay = null) =>
ScrollTo(value, animated, distanceDecay);
/// <summary>
/// Scrolls to an absolute position.
/// </summary>
/// <param name="value">The position to scroll to.</param>
/// <param name="animated">Whether to animate the movement.</param>
/// <param name="distanceDecay">Controls the rate with which the target position is approached after jumping to a specific location. Default is <see cref="DistanceDecayJump"/>.</param>
public void ScrollTo(double value, bool animated = true, double? distanceDecay = null) => scrollTo(value, animated, distanceDecay ?? DistanceDecayJump);
private void scrollTo(double value, bool animated, double distanceDecay = double.PositiveInfinity)
{
Target = Clamp(value, ClampExtension);
if (animated)
this.distanceDecay = distanceDecay;
else
Current = Target;
}
/// <summary>
/// Scrolls a <see cref="Drawable"/> to the top.
/// </summary>
/// <param name="d">The <see cref="Drawable"/> to scroll to.</param>
/// <param name="animated">Whether to animate the movement.</param>
public void ScrollTo(Drawable d, bool animated = true) => ScrollTo(GetChildPosInContent(d), animated);
/// <summary>
/// Scrolls a <see cref="Drawable"/> into view.
/// </summary>
/// <param name="d">The <see cref="Drawable"/> to scroll into view.</param>
/// <param name="animated">Whether to animate the movement.</param>
public void ScrollIntoView(Drawable d, bool animated = true)
{
double childPos0 = GetChildPosInContent(d);
double childPos1 = GetChildPosInContent(d, d.DrawSize);
double minPos = Math.Min(childPos0, childPos1);
double maxPos = Math.Max(childPos0, childPos1);
if (minPos < Current || (minPos > Current && d.DrawSize[ScrollDim] > DisplayableContent))
ScrollTo(minPos, animated);
else if (maxPos > Current + DisplayableContent)
ScrollTo(maxPos - DisplayableContent, animated);
}
/// <summary>
/// Determines the position of a child in the content.
/// </summary>
/// <param name="d">The child to get the position from.</param>
/// <param name="offset">Positional offset in the child's space.</param>
/// <returns>The position of the child.</returns>
public virtual double GetChildPosInContent(Drawable d, Vector2 offset) => d.ToSpaceOfOtherDrawable(offset, ScrollContent)[ScrollDim];
/// <summary>
/// Determines the position of a child in the content.
/// </summary>
/// <param name="d">The child to get the position from.</param>
/// <returns>The position of the child.</returns>
public double GetChildPosInContent(Drawable d) => GetChildPosInContent(d, Vector2.Zero);
private void updatePosition()
{
double localDistanceDecay = distanceDecay;
// If we are not currently dragging the content, and we have scrolled out of bounds,
// then we should handle the clamping force. Note, that if the target is _within_
// acceptable bounds, then we do not need special handling of the clamping force, as
// we will naturally scroll back into acceptable bounds.
if (!IsDragging && Current != Clamp(Current) && Target != Clamp(Target, -0.01f))
{
// Firstly, we want to limit how far out the target may go to limit overly bouncy
// behaviour with extreme scroll velocities.
Target = Clamp(Target, ClampExtension);
// Secondly, we would like to quickly approach the target while we are out of bounds.
// This is simulating a "strong" clamping force towards the target.
if ((Current < Target && Target < 0) || (Current > Target && Target > ScrollableExtent))
localDistanceDecay = distance_decay_clamping * 2;
// Lastly, we gradually nudge the target towards valid bounds.
Target = Interpolation.Lerp(Clamp(Target), Target, Math.Exp(-distance_decay_clamping * Time.Elapsed));
double clampedTarget = Clamp(Target);
if (Precision.AlmostEquals(clampedTarget, Target))
Target = clampedTarget;
}
// Exponential interpolation between the target and our current scroll position.
Current = Interpolation.Lerp(Target, Current, Math.Exp(-localDistanceDecay * Time.Elapsed));
// This prevents us from entering the de-normalized range of floating point numbers when approaching target closely.
if (Precision.AlmostEquals(Current, Target))
Current = Target;
}
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
updateSize();
updatePosition();
if (!scrollbarCache.IsValid)
{
float size = ScrollDirection == Direction.Horizontal ? DrawWidth : DrawHeight;
if (size > 0)
Scrollbar.ResizeTo(Math.Clamp(AvailableContent > 0 ? DisplayableContent / AvailableContent : 0, Math.Min(Scrollbar.MinimumDimSize / size, 1), 1), 200, Easing.OutQuint);
Scrollbar.FadeTo(ScrollbarVisible && Precision.DefinitelyBigger(AvailableContent, DisplayableContent, 1f) ? 1 : 0, 200);
updatePadding();
scrollbarCache.Validate();
}
if (ScrollDirection == Direction.Horizontal)
Scrollbar.X = ToScrollbarPosition(Current);
else
Scrollbar.Y = ToScrollbarPosition(Current);
ApplyCurrentToContent();
}
/// <summary>
/// This is the final internal step of updating the scroll container, which takes
/// <see cref="Current"/> and applies it to <see cref="ScrollContent"/> in order to
/// correctly offset children.
///
/// Overriding this method can be used to inhibit this default behaviour, to for instance
/// redirect the positioning to another container or change the way it is applied.
/// </summary>
protected virtual void ApplyCurrentToContent()
{
if (ScrollDirection == Direction.Horizontal)
ScrollContent.X = (float)(-Current + (ScrollableExtent * ScrollContent.RelativeAnchorPosition.X));
else
ScrollContent.Y = (float)(-Current + (ScrollableExtent * ScrollContent.RelativeAnchorPosition.Y));
}
/// <summary>
/// Converts a scroll position to a scrollbar position.
/// </summary>
/// <param name="scrollPosition">The absolute scroll position (e.g. <see cref="Current"/>).</param>
/// <returns>The scrollbar position.</returns>
protected virtual float ToScrollbarPosition(double scrollPosition)
{
if (Precision.AlmostEquals(0, ScrollableExtent))
return 0;
return (float)(ScrollbarMovementExtent * (scrollPosition / ScrollableExtent));
}
/// <summary>
/// Converts a scrollbar position to a scroll position.
/// </summary>
/// <param name="scrollbarPosition">The scrollbar position.</param>
/// <returns>The absolute scroll position.</returns>
protected virtual float FromScrollbarPosition(float scrollbarPosition)
{
if (Precision.AlmostEquals(0, ScrollbarMovementExtent))
return 0;
return (float)(ScrollableExtent * (scrollbarPosition / ScrollbarMovementExtent));
}
/// <summary>
/// Creates the scrollbar for this <see cref="ScrollContainer{T}"/>.
/// </summary>
/// <param name="direction">The scrolling direction.</param>
protected abstract ScrollbarContainer CreateScrollbar(Direction direction);
protected internal abstract partial class ScrollbarContainer : Container
{
private float dragOffset;
internal Action<float> Dragged;
protected readonly Direction ScrollDirection;
/// <summary>
/// The minimum size of this <see cref="ScrollbarContainer"/>. Defaults to the size in the non-scrolling direction.
/// </summary>
protected internal virtual float MinimumDimSize => Size[ScrollDirection == Direction.Vertical ? 0 : 1];
protected ScrollbarContainer(Direction direction)
{
ScrollDirection = direction;
RelativeSizeAxes = direction == Direction.Horizontal ? Axes.X : Axes.Y;
}
public abstract void ResizeTo(float val, int duration = 0, Easing easing = Easing.None);
protected override bool OnClick(ClickEvent e) => true;
protected override bool OnDragStart(DragStartEvent e)
{
if (e.Button != MouseButton.Left) return false;
dragOffset = e.MousePosition[(int)ScrollDirection] - Position[(int)ScrollDirection];
return true;
}
protected override bool OnMouseDown(MouseDownEvent e)
{
if (e.Button != MouseButton.Left) return false;
dragOffset = Position[(int)ScrollDirection];
Dragged?.Invoke(dragOffset);
return true;
}
protected override void OnDrag(DragEvent e)
{
Dragged?.Invoke(e.MousePosition[(int)ScrollDirection] - dragOffset);
}
}
public bool OnPressed(KeyBindingPressEvent<PlatformAction> e)
{
if (!IsHandlingKeyboardScrolling)
return false;
switch (e.Action)
{
case PlatformAction.MoveBackwardLine:
ScrollToStart();
return true;
case PlatformAction.MoveForwardLine:
ScrollToEnd();
return true;
default:
return false;
}
}
public void OnReleased(KeyBindingReleaseEvent<PlatformAction> e)
{
}
}
}