mirror of
https://github.com/ppy/osu.git
synced 2025-01-30 04:42:55 +08:00
Add basic implementation of new beatmap carousel
This commit is contained in:
parent
94ea003d90
commit
5e9a7532d3
189
osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs
Normal file
189
osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using osu.Framework.Allocation;
|
||||||
|
using osu.Framework.Bindables;
|
||||||
|
using osu.Framework.Extensions.ObjectExtensions;
|
||||||
|
using osu.Framework.Graphics;
|
||||||
|
using osu.Framework.Graphics.Containers;
|
||||||
|
using osu.Framework.Graphics.Shapes;
|
||||||
|
using osu.Framework.Testing;
|
||||||
|
using osu.Framework.Utils;
|
||||||
|
using osu.Game.Beatmaps;
|
||||||
|
using osu.Game.Database;
|
||||||
|
using osu.Game.Graphics.Containers;
|
||||||
|
using osu.Game.Screens.SelectV2;
|
||||||
|
using osu.Game.Tests.Beatmaps;
|
||||||
|
using osu.Game.Tests.Resources;
|
||||||
|
using osuTK.Graphics;
|
||||||
|
|
||||||
|
namespace osu.Game.Tests.Visual.SongSelect
|
||||||
|
{
|
||||||
|
[TestFixture]
|
||||||
|
public partial class TestSceneBeatmapCarouselV2 : OsuManualInputManagerTestScene
|
||||||
|
{
|
||||||
|
private readonly BindableList<BeatmapSetInfo> beatmapSets = new BindableList<BeatmapSetInfo>();
|
||||||
|
|
||||||
|
[Cached(typeof(BeatmapStore))]
|
||||||
|
private BeatmapStore store;
|
||||||
|
|
||||||
|
private OsuTextFlowContainer stats = null!;
|
||||||
|
private BeatmapCarouselV2 carousel = null!;
|
||||||
|
|
||||||
|
private int beatmapCount;
|
||||||
|
|
||||||
|
public TestSceneBeatmapCarouselV2()
|
||||||
|
{
|
||||||
|
store = new TestBeatmapStore
|
||||||
|
{
|
||||||
|
BeatmapSets = { BindTarget = beatmapSets }
|
||||||
|
};
|
||||||
|
|
||||||
|
beatmapSets.BindCollectionChanged((_, _) =>
|
||||||
|
{
|
||||||
|
beatmapCount = beatmapSets.Sum(s => s.Beatmaps.Count);
|
||||||
|
});
|
||||||
|
|
||||||
|
Scheduler.AddDelayed(updateStats, 100, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SetUpSteps]
|
||||||
|
public void SetUpSteps()
|
||||||
|
{
|
||||||
|
AddStep("create components", () =>
|
||||||
|
{
|
||||||
|
beatmapSets.Clear();
|
||||||
|
|
||||||
|
Box topBox;
|
||||||
|
Children = new Drawable[]
|
||||||
|
{
|
||||||
|
new GridContainer
|
||||||
|
{
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
ColumnDimensions = new[]
|
||||||
|
{
|
||||||
|
new Dimension(GridSizeMode.Relative, 1),
|
||||||
|
},
|
||||||
|
RowDimensions = new[]
|
||||||
|
{
|
||||||
|
new Dimension(GridSizeMode.Absolute, 200),
|
||||||
|
new Dimension(),
|
||||||
|
new Dimension(GridSizeMode.Absolute, 200),
|
||||||
|
},
|
||||||
|
Content = new[]
|
||||||
|
{
|
||||||
|
new Drawable[]
|
||||||
|
{
|
||||||
|
topBox = new Box
|
||||||
|
{
|
||||||
|
Anchor = Anchor.Centre,
|
||||||
|
Origin = Anchor.Centre,
|
||||||
|
Colour = Color4.Cyan,
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
Alpha = 0.4f,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new Drawable[]
|
||||||
|
{
|
||||||
|
carousel = new BeatmapCarouselV2
|
||||||
|
{
|
||||||
|
Anchor = Anchor.Centre,
|
||||||
|
Origin = Anchor.Centre,
|
||||||
|
Width = 500,
|
||||||
|
RelativeSizeAxes = Axes.Y,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new Box
|
||||||
|
{
|
||||||
|
Anchor = Anchor.Centre,
|
||||||
|
Origin = Anchor.Centre,
|
||||||
|
Colour = Color4.Cyan,
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
Alpha = 0.4f,
|
||||||
|
},
|
||||||
|
topBox.CreateProxy(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stats = new OsuTextFlowContainer(cp => cp.Font = FrameworkFont.Regular.With())
|
||||||
|
{
|
||||||
|
Padding = new MarginPadding(10),
|
||||||
|
TextAnchor = Anchor.CentreLeft,
|
||||||
|
Anchor = Anchor.CentreLeft,
|
||||||
|
Origin = Anchor.CentreLeft,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestBasic()
|
||||||
|
{
|
||||||
|
AddStep("add 10 beatmaps", () =>
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
|
||||||
|
});
|
||||||
|
|
||||||
|
AddStep("add 1 beatmap", () => beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))));
|
||||||
|
|
||||||
|
AddStep("remove all beatmaps", () => beatmapSets.Clear());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestAddRemoveOneByOne()
|
||||||
|
{
|
||||||
|
AddRepeatStep("add beatmaps", () => beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))), 20);
|
||||||
|
|
||||||
|
AddRepeatStep("remove beatmaps", () => beatmapSets.RemoveAt(RNG.Next(0, beatmapSets.Count)), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Explicit]
|
||||||
|
public void TestInsane()
|
||||||
|
{
|
||||||
|
const int count = 200000;
|
||||||
|
|
||||||
|
List<BeatmapSetInfo> generated = new List<BeatmapSetInfo>();
|
||||||
|
|
||||||
|
AddStep($"populate {count} test beatmaps", () =>
|
||||||
|
{
|
||||||
|
generated.Clear();
|
||||||
|
Task.Run(() =>
|
||||||
|
{
|
||||||
|
for (int j = 0; j < count; j++)
|
||||||
|
generated.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
|
||||||
|
}).ConfigureAwait(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddUntilStep("wait for beatmaps populated", () => generated.Count, () => Is.GreaterThan(count / 3));
|
||||||
|
AddUntilStep("this takes a while", () => generated.Count, () => Is.GreaterThan(count / 3 * 2));
|
||||||
|
AddUntilStep("maybe they are done now", () => generated.Count, () => Is.EqualTo(count));
|
||||||
|
|
||||||
|
AddStep("add all beatmaps", () => beatmapSets.AddRange(generated));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateStats()
|
||||||
|
{
|
||||||
|
if (carousel.IsNull())
|
||||||
|
return;
|
||||||
|
|
||||||
|
stats.Text = $"""
|
||||||
|
store
|
||||||
|
sets: {beatmapSets.Count}
|
||||||
|
beatmaps: {beatmapCount}
|
||||||
|
carousel:
|
||||||
|
sorting: {carousel.IsFiltering}
|
||||||
|
tracked: {carousel.ItemsTracked}
|
||||||
|
displayable: {carousel.DisplayableItems}
|
||||||
|
displayed: {carousel.VisibleItems}
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
205
osu.Game/Screens/SelectV2/BeatmapCarouselV2.cs
Normal file
205
osu.Game/Screens/SelectV2/BeatmapCarouselV2.cs
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using osu.Framework.Allocation;
|
||||||
|
using osu.Framework.Bindables;
|
||||||
|
using osu.Framework.Extensions.Color4Extensions;
|
||||||
|
using osu.Framework.Graphics;
|
||||||
|
using osu.Framework.Graphics.Pooling;
|
||||||
|
using osu.Framework.Graphics.Shapes;
|
||||||
|
using osu.Game.Beatmaps;
|
||||||
|
using osu.Game.Database;
|
||||||
|
using osu.Game.Graphics.Sprites;
|
||||||
|
using osu.Game.Screens.Select;
|
||||||
|
using osuTK;
|
||||||
|
using osuTK.Graphics;
|
||||||
|
|
||||||
|
namespace osu.Game.Screens.SelectV2
|
||||||
|
{
|
||||||
|
public partial class BeatmapCarouselV2 : Carousel
|
||||||
|
{
|
||||||
|
private IBindableList<BeatmapSetInfo> detachedBeatmaps = null!;
|
||||||
|
|
||||||
|
private readonly DrawablePool<BeatmapCarouselPanel> carouselPanelPool = new DrawablePool<BeatmapCarouselPanel>(100);
|
||||||
|
|
||||||
|
public BeatmapCarouselV2()
|
||||||
|
{
|
||||||
|
DebounceDelay = 100;
|
||||||
|
DistanceOffscreenToPreload = 100;
|
||||||
|
|
||||||
|
Filters = new ICarouselFilter[]
|
||||||
|
{
|
||||||
|
new Sorter(),
|
||||||
|
new Grouper(),
|
||||||
|
};
|
||||||
|
|
||||||
|
AddInternal(carouselPanelPool);
|
||||||
|
}
|
||||||
|
|
||||||
|
[BackgroundDependencyLoader]
|
||||||
|
private void load(BeatmapStore beatmapStore, CancellationToken? cancellationToken)
|
||||||
|
{
|
||||||
|
detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken);
|
||||||
|
detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Drawable GetDrawableForDisplay(CarouselItem item)
|
||||||
|
{
|
||||||
|
var drawable = carouselPanelPool.Get();
|
||||||
|
drawable.FlashColour(Color4.Red, 2000);
|
||||||
|
|
||||||
|
return drawable;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed)
|
||||||
|
{
|
||||||
|
// TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider.
|
||||||
|
// right now we are managing this locally which is a bit of added overhead.
|
||||||
|
IEnumerable<BeatmapSetInfo>? newBeatmapSets = changed.NewItems?.Cast<BeatmapSetInfo>();
|
||||||
|
IEnumerable<BeatmapSetInfo>? beatmapSetInfos = changed.OldItems?.Cast<BeatmapSetInfo>();
|
||||||
|
|
||||||
|
switch (changed.Action)
|
||||||
|
{
|
||||||
|
case NotifyCollectionChangedAction.Add:
|
||||||
|
Items.AddRange(newBeatmapSets!.SelectMany(s => s.Beatmaps).Select(b => new BeatmapCarouselItem(b)));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotifyCollectionChangedAction.Remove:
|
||||||
|
|
||||||
|
foreach (var set in beatmapSetInfos!)
|
||||||
|
{
|
||||||
|
foreach (var beatmap in set.Beatmaps)
|
||||||
|
Items.RemoveAll(i => i.Model is BeatmapInfo bi && beatmap.Equals(bi));
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotifyCollectionChangedAction.Move:
|
||||||
|
case NotifyCollectionChangedAction.Replace:
|
||||||
|
throw new NotImplementedException();
|
||||||
|
|
||||||
|
case NotifyCollectionChangedAction.Reset:
|
||||||
|
Items.Clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilterCriteria Criteria { get; private set; } = new FilterCriteria();
|
||||||
|
|
||||||
|
public void Filter(FilterCriteria criteria)
|
||||||
|
{
|
||||||
|
Criteria = criteria;
|
||||||
|
QueueFilter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class BeatmapCarouselPanel : PoolableDrawable, ICarouselPanel
|
||||||
|
{
|
||||||
|
public CarouselItem? Item { get; set; }
|
||||||
|
|
||||||
|
protected override void PrepareForUse()
|
||||||
|
{
|
||||||
|
base.PrepareForUse();
|
||||||
|
|
||||||
|
Debug.Assert(Item != null);
|
||||||
|
|
||||||
|
Size = new Vector2(500, Item.DrawHeight);
|
||||||
|
|
||||||
|
InternalChildren = new Drawable[]
|
||||||
|
{
|
||||||
|
new Box
|
||||||
|
{
|
||||||
|
Colour = (Item.Model is BeatmapInfo ? Color4.Aqua : Color4.Yellow).Darken(5),
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
},
|
||||||
|
new OsuSpriteText
|
||||||
|
{
|
||||||
|
Text = Item.ToString() ?? string.Empty,
|
||||||
|
Padding = new MarginPadding(5),
|
||||||
|
Anchor = Anchor.CentreLeft,
|
||||||
|
Origin = Anchor.CentreLeft,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BeatmapCarouselItem : CarouselItem
|
||||||
|
{
|
||||||
|
public readonly Guid ID;
|
||||||
|
|
||||||
|
public override float DrawHeight => Model is BeatmapInfo ? 40 : 80;
|
||||||
|
|
||||||
|
public BeatmapCarouselItem(object model)
|
||||||
|
: base(model)
|
||||||
|
{
|
||||||
|
ID = (Model as IHasGuidPrimaryKey)?.ID ?? Guid.NewGuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string? ToString()
|
||||||
|
{
|
||||||
|
switch (Model)
|
||||||
|
{
|
||||||
|
case BeatmapInfo bi:
|
||||||
|
return $"Difficulty: {bi.DifficultyName} ({bi.StarRating:N1}*)";
|
||||||
|
|
||||||
|
case BeatmapSetInfo si:
|
||||||
|
return $"{si.Metadata}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Model.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Grouper : ICarouselFilter
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken) => await Task.Run(() =>
|
||||||
|
{
|
||||||
|
// TODO: perform grouping based on FilterCriteria
|
||||||
|
|
||||||
|
CarouselItem? lastItem = null;
|
||||||
|
|
||||||
|
var newItems = new List<CarouselItem>();
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (item.Model is BeatmapInfo b1)
|
||||||
|
{
|
||||||
|
// Add set header
|
||||||
|
if (lastItem == null || (lastItem.Model is BeatmapInfo b2 && b2.BeatmapSet!.OnlineID != b1.BeatmapSet!.OnlineID))
|
||||||
|
newItems.Add(new BeatmapCarouselItem(b1.BeatmapSet!));
|
||||||
|
}
|
||||||
|
|
||||||
|
newItems.Add(item);
|
||||||
|
lastItem = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newItems;
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Sorter : ICarouselFilter
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken) => await Task.Run(() =>
|
||||||
|
{
|
||||||
|
return items.OrderDescending(Comparer<CarouselItem>.Create((a, b) =>
|
||||||
|
{
|
||||||
|
if (a.Model is BeatmapInfo ab && b.Model is BeatmapInfo bb)
|
||||||
|
return ab.OnlineID.CompareTo(bb.OnlineID);
|
||||||
|
|
||||||
|
if (a is BeatmapCarouselItem aItem && b is BeatmapCarouselItem bItem)
|
||||||
|
return aItem.ID.CompareTo(bItem.ID);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}));
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
371
osu.Game/Screens/SelectV2/Carousel.cs
Normal file
371
osu.Game/Screens/SelectV2/Carousel.cs
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using osu.Framework.Bindables;
|
||||||
|
using osu.Framework.Extensions.TypeExtensions;
|
||||||
|
using osu.Framework.Graphics;
|
||||||
|
using osu.Framework.Graphics.Containers;
|
||||||
|
using osu.Framework.Graphics.Pooling;
|
||||||
|
using osu.Framework.Graphics.Shapes;
|
||||||
|
using osu.Framework.Logging;
|
||||||
|
using osu.Game.Graphics.Containers;
|
||||||
|
using osuTK;
|
||||||
|
using osuTK.Graphics;
|
||||||
|
|
||||||
|
namespace osu.Game.Screens.SelectV2
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A highly efficient vertical list display that is used primarily for the song select screen,
|
||||||
|
/// but flexible enough to be used for other use cases.
|
||||||
|
/// </summary>
|
||||||
|
public abstract partial class Carousel : CompositeDrawable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A collection of filters which should be run each time a <see cref="QueueFilter"/> is executed.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<ICarouselFilter> Filters { get; init; } = Enumerable.Empty<ICarouselFilter>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it.
|
||||||
|
/// </summary>
|
||||||
|
public float BleedTop { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Height of the area below the carousel that should be treated as visible due to transparency of elements in front of it.
|
||||||
|
/// </summary>
|
||||||
|
public float BleedBottom { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The number of pixels outside the carousel's vertical bounds to manifest drawables.
|
||||||
|
/// This allows preloading content before it scrolls into view.
|
||||||
|
/// </summary>
|
||||||
|
public float DistanceOffscreenToPreload { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When a new request arrives to change filtering, the number of milliseconds to wait before performing the filter.
|
||||||
|
/// Regardless of any external debouncing, this is a safety measure to avoid triggering too many threaded operations.
|
||||||
|
/// </summary>
|
||||||
|
public int DebounceDelay { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether an asynchronous filter / group operation is currently underway.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsFiltering => !filterTask.IsCompleted;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The number of displayable items currently being tracked (before filtering).
|
||||||
|
/// </summary>
|
||||||
|
public int ItemsTracked => Items.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The number of carousel items currently in rotation for display.
|
||||||
|
/// </summary>
|
||||||
|
public int DisplayableItems => displayedCarouselItems?.Count ?? 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The number of items currently actualised into drawables.
|
||||||
|
/// </summary>
|
||||||
|
public int VisibleItems => scroll.Panels.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All items which are to be considered for display in this carousel.
|
||||||
|
/// Mutating this list will automatically queue a <see cref="QueueFilter"/>.
|
||||||
|
/// </summary>
|
||||||
|
protected readonly BindableList<CarouselItem> Items = new BindableList<CarouselItem>();
|
||||||
|
|
||||||
|
private List<CarouselItem>? displayedCarouselItems;
|
||||||
|
|
||||||
|
private readonly DoublePrecisionScroll scroll;
|
||||||
|
|
||||||
|
protected Carousel()
|
||||||
|
{
|
||||||
|
InternalChildren = new Drawable[]
|
||||||
|
{
|
||||||
|
new Box
|
||||||
|
{
|
||||||
|
Colour = Color4.Black,
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
},
|
||||||
|
scroll = new DoublePrecisionScroll
|
||||||
|
{
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
Masking = false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Items.BindCollectionChanged((_, _) => QueueFilter());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queue an asynchronous filter operation.
|
||||||
|
/// </summary>
|
||||||
|
public void QueueFilter() => Scheduler.AddOnce(() => filterTask = performFilter());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a drawable for the given carousel item so it can be displayed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For efficiency, it is recommended the drawables are retrieved from a <see cref="DrawablePool{T}"/>.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="item">The item which should be represented by the returned drawable.</param>
|
||||||
|
/// <returns>The manifested drawable.</returns>
|
||||||
|
protected abstract Drawable GetDrawableForDisplay(CarouselItem item);
|
||||||
|
|
||||||
|
#region Filtering and display preparation
|
||||||
|
|
||||||
|
private Task filterTask = Task.CompletedTask;
|
||||||
|
private CancellationTokenSource cancellationSource = new CancellationTokenSource();
|
||||||
|
|
||||||
|
private async Task performFilter()
|
||||||
|
{
|
||||||
|
Debug.Assert(SynchronizationContext.Current != null);
|
||||||
|
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
lock (this)
|
||||||
|
{
|
||||||
|
cancellationSource.Cancel();
|
||||||
|
cancellationSource = cts;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||||
|
IEnumerable<CarouselItem> items = new List<CarouselItem>(Items);
|
||||||
|
|
||||||
|
await Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (DebounceDelay > 0)
|
||||||
|
{
|
||||||
|
log($"Filter operation queued, waiting for {DebounceDelay} ms debounce");
|
||||||
|
await Task.Delay(DebounceDelay, cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var filter in Filters)
|
||||||
|
{
|
||||||
|
log($"Performing {filter.GetType().ReadableName()}");
|
||||||
|
items = await filter.Run(items, cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Updating Y positions");
|
||||||
|
await updateYPositions(items, cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
log("Cancelled due to newer request arriving");
|
||||||
|
}
|
||||||
|
}, cts.Token).ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (cts.Token.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
|
||||||
|
log("Items ready for display");
|
||||||
|
displayedCarouselItems = items.ToList();
|
||||||
|
displayedRange = null;
|
||||||
|
|
||||||
|
void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString().Substring(0, 5)}] {stopwatch.ElapsedMilliseconds} ms: {text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task updateYPositions(IEnumerable<CarouselItem> carouselItems, CancellationToken cancellationToken) => await Task.Run(() =>
|
||||||
|
{
|
||||||
|
const float spacing = 10;
|
||||||
|
float yPos = 0;
|
||||||
|
|
||||||
|
foreach (var item in carouselItems)
|
||||||
|
{
|
||||||
|
item.CarouselYPosition = yPos;
|
||||||
|
yPos += item.DrawHeight + spacing;
|
||||||
|
}
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Display handling
|
||||||
|
|
||||||
|
private DisplayRange? displayedRange;
|
||||||
|
|
||||||
|
private readonly CarouselItem carouselBoundsItem = new BoundsCarouselItem();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The position of the lower visible bound with respect to the current scroll position.
|
||||||
|
/// </summary>
|
||||||
|
private float visibleBottomBound => (float)(scroll.Current + DrawHeight + BleedBottom);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The position of the upper visible bound with respect to the current scroll position.
|
||||||
|
/// </summary>
|
||||||
|
private float visibleUpperBound => (float)(scroll.Current - BleedTop);
|
||||||
|
|
||||||
|
protected override void Update()
|
||||||
|
{
|
||||||
|
base.Update();
|
||||||
|
|
||||||
|
if (displayedCarouselItems == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var range = getDisplayRange();
|
||||||
|
|
||||||
|
if (range != displayedRange)
|
||||||
|
{
|
||||||
|
Logger.Log($"Updating displayed range of carousel from {displayedRange} to {range}");
|
||||||
|
displayedRange = range;
|
||||||
|
|
||||||
|
updateDisplayedRange(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DisplayRange getDisplayRange()
|
||||||
|
{
|
||||||
|
Debug.Assert(displayedCarouselItems != null);
|
||||||
|
|
||||||
|
// Find index range of all items that should be on-screen
|
||||||
|
carouselBoundsItem.CarouselYPosition = visibleUpperBound - DistanceOffscreenToPreload;
|
||||||
|
int firstIndex = displayedCarouselItems.BinarySearch(carouselBoundsItem);
|
||||||
|
if (firstIndex < 0) firstIndex = ~firstIndex;
|
||||||
|
|
||||||
|
carouselBoundsItem.CarouselYPosition = visibleBottomBound + DistanceOffscreenToPreload;
|
||||||
|
int lastIndex = displayedCarouselItems.BinarySearch(carouselBoundsItem);
|
||||||
|
if (lastIndex < 0) lastIndex = ~lastIndex;
|
||||||
|
|
||||||
|
firstIndex = Math.Max(0, firstIndex - 1);
|
||||||
|
lastIndex = Math.Max(0, lastIndex - 1);
|
||||||
|
|
||||||
|
return new DisplayRange(firstIndex, lastIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDisplayedRange(DisplayRange range)
|
||||||
|
{
|
||||||
|
Debug.Assert(displayedCarouselItems != null);
|
||||||
|
|
||||||
|
List<CarouselItem> toDisplay = range.Last - range.First == 0
|
||||||
|
? new List<CarouselItem>()
|
||||||
|
: displayedCarouselItems.GetRange(range.First, range.Last - range.First + 1);
|
||||||
|
|
||||||
|
// Iterate over all panels which are already displayed and figure which need to be displayed / removed.
|
||||||
|
foreach (var panel in scroll.Panels)
|
||||||
|
{
|
||||||
|
var carouselPanel = (ICarouselPanel)panel;
|
||||||
|
|
||||||
|
// The case where we're intending to display this panel, but it's already displayed.
|
||||||
|
// Note that we **must compare the model here** as the CarouselItems may be fresh instances due to a filter operation.
|
||||||
|
var existing = toDisplay.FirstOrDefault(i => i.Model == carouselPanel.Item!.Model);
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
carouselPanel.Item = existing;
|
||||||
|
toDisplay.Remove(existing);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the new display range doesn't contain the panel, it's no longer required for display.
|
||||||
|
expirePanelImmediately(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any new items which need to be displayed and haven't yet.
|
||||||
|
foreach (var item in toDisplay)
|
||||||
|
{
|
||||||
|
var drawable = GetDrawableForDisplay(item);
|
||||||
|
|
||||||
|
if (drawable is not ICarouselPanel carouselPanel)
|
||||||
|
throw new InvalidOperationException($"Carousel panel drawables must implement {typeof(ICarouselPanel)}");
|
||||||
|
|
||||||
|
carouselPanel.Item = item;
|
||||||
|
scroll.Add(drawable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the total height of all items (to make the scroll container scrollable through the full height even though
|
||||||
|
// most items are not displayed / loaded).
|
||||||
|
if (displayedCarouselItems.Count > 0)
|
||||||
|
{
|
||||||
|
var lastItem = displayedCarouselItems[^1];
|
||||||
|
scroll.SetLayoutHeight((float)(lastItem.CarouselYPosition + lastItem.DrawHeight));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
scroll.SetLayoutHeight(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void expirePanelImmediately(Drawable panel)
|
||||||
|
{
|
||||||
|
panel.FinishTransforms();
|
||||||
|
panel.Expire();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Internal helper classes
|
||||||
|
|
||||||
|
private record DisplayRange(int First, int Last);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of scroll container which handles very large vertical lists by internally using <c>double</c> precision
|
||||||
|
/// for pre-display Y values.
|
||||||
|
/// </summary>
|
||||||
|
private partial class DoublePrecisionScroll : OsuScrollContainer
|
||||||
|
{
|
||||||
|
public readonly Container Panels;
|
||||||
|
|
||||||
|
public void SetLayoutHeight(float height) => Panels.Height = height;
|
||||||
|
|
||||||
|
public DoublePrecisionScroll()
|
||||||
|
{
|
||||||
|
// Managing our own custom layout within ScrollContent causes feedback with public ScrollContainer calculations,
|
||||||
|
// so we must maintain one level of separation from ScrollContent.
|
||||||
|
base.Add(Panels = new Container
|
||||||
|
{
|
||||||
|
Name = "Layout content",
|
||||||
|
RelativeSizeAxes = Axes.X,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Clear(bool disposeChildren)
|
||||||
|
{
|
||||||
|
Panels.Height = 0;
|
||||||
|
Panels.Clear(disposeChildren);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Add(Drawable drawable)
|
||||||
|
{
|
||||||
|
if (drawable is not ICarouselPanel)
|
||||||
|
throw new InvalidOperationException($"Carousel panel drawables must implement {typeof(ICarouselPanel)}");
|
||||||
|
|
||||||
|
Panels.Add(drawable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override double GetChildPosInContent(Drawable d, Vector2 offset)
|
||||||
|
{
|
||||||
|
if (d is not ICarouselPanel panel)
|
||||||
|
return base.GetChildPosInContent(d, offset);
|
||||||
|
|
||||||
|
return panel.YPosition + offset.X;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ApplyCurrentToContent()
|
||||||
|
{
|
||||||
|
Debug.Assert(ScrollDirection == Direction.Vertical);
|
||||||
|
|
||||||
|
double scrollableExtent = -Current + ScrollableExtent * ScrollContent.RelativeAnchorPosition.Y;
|
||||||
|
|
||||||
|
foreach (var d in Panels)
|
||||||
|
d.Y = (float)(((ICarouselPanel)d).YPosition + scrollableExtent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BoundsCarouselItem : CarouselItem
|
||||||
|
{
|
||||||
|
public override float DrawHeight => 0;
|
||||||
|
|
||||||
|
public BoundsCarouselItem()
|
||||||
|
: base(new object())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
41
osu.Game/Screens/SelectV2/CarouselItem.cs
Normal file
41
osu.Game/Screens/SelectV2/CarouselItem.cs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace osu.Game.Screens.SelectV2
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single display item for display in a <see cref="Carousel"/>.
|
||||||
|
/// This is used to house information related to the attached model that helps with display and tracking.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class CarouselItem : IComparable<CarouselItem>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The model this item is representing.
|
||||||
|
/// </summary>
|
||||||
|
public readonly object Model;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The current Y position in the carousel. This is managed by <see cref="Carousel"/> and should not be set manually.
|
||||||
|
/// </summary>
|
||||||
|
public double CarouselYPosition { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The height this item will take when displayed.
|
||||||
|
/// </summary>
|
||||||
|
public abstract float DrawHeight { get; }
|
||||||
|
|
||||||
|
protected CarouselItem(object model)
|
||||||
|
{
|
||||||
|
Model = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CompareTo(CarouselItem? other)
|
||||||
|
{
|
||||||
|
if (other == null) return 1;
|
||||||
|
|
||||||
|
return CarouselYPosition.CompareTo(other.CarouselYPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
23
osu.Game/Screens/SelectV2/ICarouselFilter.cs
Normal file
23
osu.Game/Screens/SelectV2/ICarouselFilter.cs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace osu.Game.Screens.SelectV2
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// An interface representing a filter operation which can be run on a <see cref="Carousel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface ICarouselFilter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Execute the filter operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="items">The items to be filtered.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token.</param>
|
||||||
|
/// <returns>The post-filtered items.</returns>
|
||||||
|
Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
23
osu.Game/Screens/SelectV2/ICarouselPanel.cs
Normal file
23
osu.Game/Screens/SelectV2/ICarouselPanel.cs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
using osu.Framework.Graphics;
|
||||||
|
|
||||||
|
namespace osu.Game.Screens.SelectV2
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// An interface to be attached to any <see cref="Drawable"/>s which are used for display inside a <see cref="Carousel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface ICarouselPanel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The Y position which should be used for displaying this item within the carousel.
|
||||||
|
/// </summary>
|
||||||
|
double YPosition => Item!.CarouselYPosition;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The carousel item this drawable is representing. This is managed by <see cref="Carousel"/> and should not be set manually.
|
||||||
|
/// </summary>
|
||||||
|
CarouselItem? Item { get; set; }
|
||||||
|
}
|
||||||
|
}
|
@ -11,6 +11,6 @@ namespace osu.Game.Tests.Beatmaps
|
|||||||
internal partial class TestBeatmapStore : BeatmapStore
|
internal partial class TestBeatmapStore : BeatmapStore
|
||||||
{
|
{
|
||||||
public readonly BindableList<BeatmapSetInfo> BeatmapSets = new BindableList<BeatmapSetInfo>();
|
public readonly BindableList<BeatmapSetInfo> BeatmapSets = new BindableList<BeatmapSetInfo>();
|
||||||
public override IBindableList<BeatmapSetInfo> GetBeatmapSets(CancellationToken? cancellationToken) => BeatmapSets;
|
public override IBindableList<BeatmapSetInfo> GetBeatmapSets(CancellationToken? cancellationToken) => BeatmapSets.GetBoundCopy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user