// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Layout; using osu.Framework.Utils; namespace osu.Game.Graphics.Containers { /// /// A container that can scroll to each section inside it. /// [Cached] public class SectionsContainer : Container where T : Drawable { public Bindable SelectedSection { get; } = new Bindable(); private Drawable lastClickedSection; private T smallestSection; public Drawable ExpandableHeader { get => expandableHeader; set { if (value == expandableHeader) return; if (expandableHeader != null) RemoveInternal(expandableHeader); expandableHeader = value; if (value == null) return; AddInternal(expandableHeader); lastKnownScroll = float.NaN; } } public Drawable FixedHeader { get => fixedHeader; set { if (value == fixedHeader) return; fixedHeader?.Expire(); fixedHeader = value; if (value == null) return; AddInternal(fixedHeader); lastKnownScroll = float.NaN; } } public Drawable Footer { get => footer; set { if (value == footer) return; if (footer != null) scrollContainer.Remove(footer); footer = value; if (value == null) return; footer.Anchor |= Anchor.y2; footer.Origin |= Anchor.y2; scrollContainer.Add(footer); lastKnownScroll = float.NaN; } } public Drawable HeaderBackground { get => headerBackground; set { if (value == headerBackground) return; headerBackgroundContainer.Clear(); headerBackground = value; if (value == null) return; headerBackgroundContainer.Add(headerBackground); lastKnownScroll = float.NaN; } } protected override Container Content => scrollContentContainer; private readonly UserTrackingScrollContainer scrollContainer; private readonly Container headerBackgroundContainer; private readonly MarginPadding originalSectionsMargin; private Drawable expandableHeader, fixedHeader, footer, headerBackground; private FlowContainer scrollContentContainer; private float headerHeight, footerHeight; private float lastKnownScroll; public SectionsContainer() { AddRangeInternal(new Drawable[] { scrollContainer = CreateScrollContainer().With(s => { s.RelativeSizeAxes = Axes.Both; s.Masking = true; s.ScrollbarVisible = false; s.Child = scrollContentContainer = CreateScrollContentContainer(); }), headerBackgroundContainer = new Container { RelativeSizeAxes = Axes.X } }); originalSectionsMargin = scrollContentContainer.Margin; } public override void Add(T drawable) { base.Add(drawable); lastKnownScroll = float.NaN; headerHeight = float.NaN; footerHeight = float.NaN; if (drawable == null) return; if (smallestSection == null || smallestSection.Height > drawable.Height) smallestSection = drawable; } private const float scroll_target_multiplier = 0.2f; public void ScrollTo(Drawable section) { lastClickedSection = section; scrollContainer.ScrollTo(scrollContainer.GetChildPosInContent(section) - scrollContainer.DisplayableContent * scroll_target_multiplier - (FixedHeader?.BoundingBox.Height ?? 0)); } public void ScrollToTop() => scrollContainer.ScrollTo(0); [NotNull] protected virtual UserTrackingScrollContainer CreateScrollContainer() => new UserTrackingScrollContainer(); [NotNull] protected virtual FlowContainer CreateScrollContentContainer() => new FillFlowContainer { Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, }; protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) { var result = base.OnInvalidate(invalidation, source); if (source == InvalidationSource.Child && (invalidation & Invalidation.DrawSize) != 0) { lastKnownScroll = -1; result = true; } return result; } protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); float headerH = (ExpandableHeader?.LayoutSize.Y ?? 0) + (FixedHeader?.LayoutSize.Y ?? 0); float footerH = Footer?.LayoutSize.Y ?? 0; if (headerH != headerHeight || footerH != footerHeight) { headerHeight = headerH; footerHeight = footerH; updateSectionsMargin(); } float currentScroll = scrollContainer.Current; if (currentScroll != lastKnownScroll) { lastKnownScroll = currentScroll; // reset last clicked section because user started scrolling themselves if (scrollContainer.UserScrolling) lastClickedSection = null; if (ExpandableHeader != null && FixedHeader != null) { float offset = Math.Min(ExpandableHeader.LayoutSize.Y, currentScroll); ExpandableHeader.Y = -offset; FixedHeader.Y = -offset + ExpandableHeader.LayoutSize.Y; } headerBackgroundContainer.Height = (ExpandableHeader?.LayoutSize.Y ?? 0) + (FixedHeader?.LayoutSize.Y ?? 0); headerBackgroundContainer.Y = ExpandableHeader?.Y ?? 0; // scroll offset is our fixed header height if we have it plus 20% of content height // plus 5% to fix floating point errors and to not have a section instantly unselect when scrolling upwards // but the 5% can't be bigger than our smallest section height, otherwise it won't get selected correctly float sectionOrContent = Math.Min(smallestSection?.Height / 2.0f ?? 0, scrollContainer.DisplayableContent * 0.05f); float scrollOffset = (FixedHeader?.LayoutSize.Y ?? 0) + scrollContainer.DisplayableContent * scroll_target_multiplier + sectionOrContent; Func diff = section => scrollContainer.GetChildPosInContent(section) - currentScroll - scrollOffset; if (Precision.AlmostBigger(0, scrollContainer.Current)) { SelectedSection.Value = lastClickedSection as T ?? Children.FirstOrDefault(); return; } if (Precision.AlmostBigger(scrollContainer.Current, scrollContainer.ScrollableExtent)) { SelectedSection.Value = lastClickedSection as T ?? Children.LastOrDefault(); return; } SelectedSection.Value = Children.TakeWhile(section => diff(section) <= 0).LastOrDefault() ?? Children.FirstOrDefault(); } } private void updateSectionsMargin() { if (!Children.Any()) return; var newMargin = originalSectionsMargin; newMargin.Top += headerHeight; newMargin.Bottom += footerHeight; scrollContentContainer.Margin = newMargin; } } }