Merge pull request #16232 from bdach/beatmap-card/extra-on-listing

Add card size switcher to beatmap listing overlay
This commit is contained in:
Dean Herbert 2021-12-24 20:29:55 +09:00 committed by GitHub
commit f81e5e8b99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 350 additions and 37 deletions

View File

@ -107,6 +107,27 @@ namespace osu.Game.Tests.Visual.Online
AddUntilStep("is hidden", () => overlay.State.Value == Visibility.Hidden);
}
[Test]
public void TestCardSizeSwitching()
{
AddAssert("is visible", () => overlay.State.Value == Visibility.Visible);
AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray()));
assertAllCardsOfType<BeatmapCardNormal>();
setCardSize(BeatmapCardSize.Extra);
assertAllCardsOfType<BeatmapCardExtra>();
setCardSize(BeatmapCardSize.Normal);
assertAllCardsOfType<BeatmapCardNormal>();
AddStep("fetch for 0 beatmaps", () => fetchFor());
AddUntilStep("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true);
setCardSize(BeatmapCardSize.Extra);
AddAssert("placeholder shown", () => overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().SingleOrDefault()?.IsPresent == true);
}
[Test]
public void TestNoBeatmapsPlaceholder()
{
@ -299,5 +320,16 @@ namespace osu.Game.Tests.Visual.Online
AddUntilStep("\"supporter required\" placeholder not shown", () => !overlay.ChildrenOfType<BeatmapListingOverlay.SupporterRequiredDrawable>().Any(d => d.IsPresent));
AddUntilStep("\"no maps found\" placeholder not shown", () => !overlay.ChildrenOfType<BeatmapListingOverlay.NotFoundDrawable>().Any(d => d.IsPresent));
}
private void setCardSize(BeatmapCardSize cardSize) => AddStep($"set card size to {cardSize}", () => overlay.ChildrenOfType<BeatmapListingCardSizeTabControl>().Single().Current.Value = cardSize);
private void assertAllCardsOfType<T>()
where T : BeatmapCard =>
AddUntilStep($"all loaded beatmap cards are {typeof(T)}", () =>
{
int loadedCorrectCount = this.ChildrenOfType<BeatmapCard>().Count(card => card.IsLoaded && card.GetType() == typeof(T));
int totalCount = this.ChildrenOfType<BeatmapCard>().Count();
return loadedCorrectCount > 0 && loadedCorrectCount == totalCount;
});
}
}

View File

@ -0,0 +1,55 @@
// 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.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapListing;
namespace osu.Game.Tests.Visual.UserInterface
{
public class TestSceneBeatmapListingCardSizeTabControl : OsuTestScene
{
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue);
private readonly Bindable<BeatmapCardSize> cardSize = new Bindable<BeatmapCardSize>();
private SpriteText cardSizeText;
[BackgroundDependencyLoader]
private void load()
{
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
cardSizeText = new OsuSpriteText
{
Font = OsuFont.Default.With(size: 24)
},
new BeatmapListingCardSizeTabControl
{
Current = cardSize,
Anchor = Anchor.Centre,
Origin = Anchor.Centre
}
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
cardSize.BindValueChanged(size => cardSizeText.Text = $"Current size: {size.NewValue}", true);
}
}
}

View File

@ -3,6 +3,7 @@
#nullable enable
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@ -20,9 +21,12 @@ namespace osu.Game.Beatmaps.Drawables.Cards
public const float TRANSITION_DURATION = 400;
public const float CORNER_RADIUS = 10;
protected const float WIDTH = 430;
public IBindable<bool> Expanded { get; }
protected readonly APIBeatmapSet BeatmapSet;
public readonly APIBeatmapSet BeatmapSet;
protected readonly Bindable<BeatmapSetFavouriteState> FavouriteState;
protected abstract Drawable IdleContent { get; }
@ -76,5 +80,23 @@ namespace osu.Game.Beatmaps.Drawables.Cards
IdleContent.FadeTo(showProgress ? 0 : 1, TRANSITION_DURATION, Easing.OutQuint);
DownloadInProgressContent.FadeTo(showProgress ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint);
}
/// <summary>
/// Creates a beatmap card of the given <paramref name="size"/> for the supplied <paramref name="beatmapSet"/>.
/// </summary>
public static BeatmapCard Create(APIBeatmapSet beatmapSet, BeatmapCardSize size, bool allowExpansion = true)
{
switch (size)
{
case BeatmapCardSize.Normal:
return new BeatmapCardNormal(beatmapSet, allowExpansion);
case BeatmapCardSize.Extra:
return new BeatmapCardExtra(beatmapSet, allowExpansion);
default:
throw new ArgumentOutOfRangeException(nameof(size), size, @"Unsupported card size");
}
}
}
}

View File

@ -25,7 +25,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards
protected override Drawable IdleContent => idleBottomContent;
protected override Drawable DownloadInProgressContent => downloadProgressBar;
private const float width = 475;
private const float height = 140;
[Cached]
@ -51,7 +50,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
[BackgroundDependencyLoader(true)]
private void load(BeatmapSetOverlay? beatmapSetOverlay)
{
Width = width;
Width = WIDTH;
Height = height;
FillFlowContainer leftIconArea = null!;
@ -81,7 +80,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
buttonContainer = new CollapsibleButtonContainer(BeatmapSet)
{
X = height - CORNER_RADIUS,
Width = width - height + CORNER_RADIUS,
Width = WIDTH - height + CORNER_RADIUS,
FavouriteState = { BindTarget = FavouriteState },
ButtonsCollapsedWidth = CORNER_RADIUS,
ButtonsExpandedWidth = 30,

View File

@ -26,7 +26,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards
protected override Drawable IdleContent => idleBottomContent;
protected override Drawable DownloadInProgressContent => downloadProgressBar;
private const float width = 408;
private const float height = 100;
[Cached]
@ -52,7 +51,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
[BackgroundDependencyLoader]
private void load()
{
Width = width;
Width = WIDTH;
Height = height;
FillFlowContainer leftIconArea = null!;
@ -82,7 +81,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
buttonContainer = new CollapsibleButtonContainer(BeatmapSet)
{
X = height - CORNER_RADIUS,
Width = width - height + CORNER_RADIUS,
Width = WIDTH - height + CORNER_RADIUS,
FavouriteState = { BindTarget = FavouriteState },
ButtonsCollapsedWidth = CORNER_RADIUS,
ButtonsExpandedWidth = 30,

View File

@ -0,0 +1,14 @@
// 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.
namespace osu.Game.Beatmaps.Drawables.Cards
{
/// <summary>
/// Enumeration for all available sizes of <see cref="BeatmapCard"/>.
/// </summary>
public enum BeatmapCardSize
{
Normal,
Extra
}
}

View File

@ -0,0 +1,134 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Graphics.UserInterface;
using osuTK;
namespace osu.Game.Overlays.BeatmapListing
{
public class BeatmapListingCardSizeTabControl : OsuTabControl<BeatmapCardSize>
{
public BeatmapListingCardSizeTabControl()
{
AutoSizeAxes = Axes.Both;
}
protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer
{
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(10, 0),
};
protected override Dropdown<BeatmapCardSize> CreateDropdown() => null;
protected override TabItem<BeatmapCardSize> CreateTabItem(BeatmapCardSize value) => new TabItem(value);
private class TabItem : TabItem<BeatmapCardSize>
{
private Box background;
private SpriteIcon icon;
[Resolved]
private OverlayColourProvider colourProvider { get; set; }
public TabItem(BeatmapCardSize value)
: base(value)
{
}
[BackgroundDependencyLoader]
private void load()
{
AutoSizeAxes = Axes.Both;
Masking = true;
CornerRadius = 4;
Children = new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background3
},
new Container
{
AutoSizeAxes = Axes.Both,
Padding = new MarginPadding
{
Horizontal = 10,
Vertical = 5,
},
Child = icon = new SpriteIcon
{
Size = new Vector2(12),
Icon = getIconForCardSize(Value)
}
}
};
}
private static IconUsage getIconForCardSize(BeatmapCardSize cardSize)
{
switch (cardSize)
{
case BeatmapCardSize.Normal:
return FontAwesome.Solid.Th;
case BeatmapCardSize.Extra:
return FontAwesome.Solid.ThLarge;
default:
throw new ArgumentOutOfRangeException(nameof(cardSize), cardSize, "Unsupported card size");
}
}
protected override void LoadComplete()
{
base.LoadComplete();
updateState();
FinishTransforms(true);
}
protected override void OnActivated()
{
if (IsLoaded)
updateState();
}
protected override void OnDeactivated()
{
if (IsLoaded)
updateState();
}
protected override bool OnHover(HoverEvent e)
{
updateState();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateState();
base.OnHoverLost(e);
}
private const double fade_time = 200;
private void updateState()
{
background.FadeTo(IsHovered || Active.Value ? 1 : 0, fade_time, Easing.OutQuint);
icon.FadeColour(Active.Value && !IsHovered ? colourProvider.Light1 : colourProvider.Content1, fade_time, Easing.OutQuint);
}
}
}
}

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -12,6 +13,7 @@ using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Framework.Threading;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
@ -48,6 +50,11 @@ namespace osu.Game.Overlays.BeatmapListing
/// </summary>
public int CurrentPage { get; private set; }
/// <summary>
/// The currently selected <see cref="BeatmapCardSize"/>.
/// </summary>
public IBindable<BeatmapCardSize> CardSize { get; } = new Bindable<BeatmapCardSize>();
private readonly BeatmapListingSearchControl searchControl;
private readonly BeatmapListingSortTabControl sortControl;
private readonly Box sortControlBackground;
@ -105,6 +112,13 @@ namespace osu.Game.Overlays.BeatmapListing
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Margin = new MarginPadding { Left = 20 }
},
new BeatmapListingCardSizeTabControl
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Margin = new MarginPadding { Right = 20 },
Current = { BindTarget = CardSize }
}
}
}
@ -227,12 +241,14 @@ namespace osu.Game.Overlays.BeatmapListing
if (filters.Any())
{
SearchFinished?.Invoke(SearchResult.SupporterOnlyFilters(filters));
var supporterOnlyFilters = SearchResult.SupporterOnlyFilters(filters);
SearchFinished?.Invoke(supporterOnlyFilters);
return;
}
}
SearchFinished?.Invoke(SearchResult.ResultsReturned(sets));
var resultsReturned = SearchResult.ResultsReturned(sets);
SearchFinished?.Invoke(resultsReturned);
};
api.Queue(getSetsRequest);
@ -296,7 +312,7 @@ namespace osu.Game.Overlays.BeatmapListing
public static SearchResult ResultsReturned(List<APIBeatmapSet> results) => new SearchResult
{
Type = SearchResultType.ResultsReturned,
Results = results
Results = results,
};
public static SearchResult SupporterOnlyFilters(List<LocalisableString> filters) => new SearchResult

View File

@ -19,6 +19,7 @@ using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays.BeatmapListing;
using osu.Game.Resources.Localisation.Web;
using osuTK;
@ -33,7 +34,7 @@ namespace osu.Game.Overlays
private Drawable currentContent;
private Container panelTarget;
private FillFlowContainer<BeatmapCardNormal> foundContent;
private FillFlowContainer<BeatmapCard> foundContent;
private NotFoundDrawable notFoundContent;
private SupporterRequiredDrawable supporterRequiredContent;
private BeatmapListingFilterControl filterControl;
@ -78,7 +79,7 @@ namespace osu.Game.Overlays
Padding = new MarginPadding { Horizontal = 20 },
Children = new Drawable[]
{
foundContent = new FillFlowContainer<BeatmapCardNormal>(),
foundContent = new FillFlowContainer<BeatmapCard>(),
notFoundContent = new NotFoundDrawable(),
supporterRequiredContent = new SupporterRequiredDrawable(),
}
@ -89,6 +90,12 @@ namespace osu.Game.Overlays
};
}
protected override void LoadComplete()
{
base.LoadComplete();
filterControl.CardSize.BindValueChanged(_ => onCardSizeChanged());
}
public void ShowWithSearch(string query)
{
filterControl.Search(query);
@ -114,6 +121,8 @@ namespace osu.Game.Overlays
private CancellationTokenSource cancellationToken;
private Task panelLoadTask;
private void onSearchStarted()
{
cancellationToken?.Cancel();
@ -124,10 +133,10 @@ namespace osu.Game.Overlays
Loading.Show();
}
private Task panelLoadDelegate;
private void onSearchFinished(BeatmapListingFilterControl.SearchResult searchResult)
{
cancellationToken?.Cancel();
if (searchResult.Type == BeatmapListingFilterControl.SearchResultType.SupporterOnlyFilters)
{
supporterRequiredContent.UpdateText(searchResult.SupporterOnlyFiltersUsed);
@ -135,46 +144,54 @@ namespace osu.Game.Overlays
return;
}
var newPanels = searchResult.Results.Select(b => new BeatmapCardNormal(b)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
});
var newCards = createCardsFor(searchResult.Results);
if (filterControl.CurrentPage == 0)
{
//No matches case
if (!newPanels.Any())
if (!newCards.Any())
{
addContentToPlaceholder(notFoundContent);
return;
}
// spawn new children with the contained so we only clear old content at the last moment.
// reverse ID flow is required for correct Z-ordering of the cards' expandable content (last card should be front-most).
var content = new ReverseChildIDFillFlowContainer<BeatmapCardNormal>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(10),
Alpha = 0,
Margin = new MarginPadding { Vertical = 15 },
ChildrenEnumerable = newPanels
};
var content = createCardContainerFor(newCards);
panelLoadDelegate = LoadComponentAsync(foundContent = content, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token);
panelLoadTask = LoadComponentAsync(foundContent = content, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token);
}
else
{
panelLoadDelegate = LoadComponentsAsync(newPanels, loaded =>
panelLoadTask = LoadComponentsAsync(newCards, loaded =>
{
lastFetchDisplayedTime = Time.Current;
foundContent.AddRange(loaded);
loaded.ForEach(p => p.FadeIn(200, Easing.OutQuint));
});
}, (cancellationToken = new CancellationTokenSource()).Token);
}
}
private BeatmapCard[] createCardsFor(IEnumerable<APIBeatmapSet> beatmapSets) => beatmapSets.Select(set => BeatmapCard.Create(set, filterControl.CardSize.Value).With(c =>
{
c.Anchor = Anchor.TopCentre;
c.Origin = Anchor.TopCentre;
})).ToArray();
private static ReverseChildIDFillFlowContainer<BeatmapCard> createCardContainerFor(IEnumerable<BeatmapCard> newCards)
{
// spawn new children with the contained so we only clear old content at the last moment.
// reverse ID flow is required for correct Z-ordering of the cards' expandable content (last card should be front-most).
var content = new ReverseChildIDFillFlowContainer<BeatmapCard>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(10),
Alpha = 0,
Margin = new MarginPadding { Vertical = 15 },
ChildrenEnumerable = newCards
};
return content;
}
private void addContentToPlaceholder(Drawable content)
{
Loading.Hide();
@ -195,8 +212,14 @@ namespace osu.Game.Overlays
// To resolve both of these issues, the bypass is delayed until a point when the content transitions (fade-in and fade-out) overlap and it looks good to do so.
var sequence = lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y);
if (lastContent != notFoundContent && lastContent != supporterRequiredContent)
sequence.Then().Schedule(() => lastContent.Expire());
if (lastContent == foundContent)
{
sequence.Then().Schedule(() =>
{
foundContent.Expire();
foundContent = null;
});
}
}
if (!content.IsAlive)
@ -209,6 +232,25 @@ namespace osu.Game.Overlays
currentContent.BypassAutoSizeAxes = Axes.None;
}
private void onCardSizeChanged()
{
if (foundContent == null || !foundContent.Any())
return;
Loading.Show();
var newCards = createCardsFor(foundContent.Reverse().Select(card => card.BeatmapSet));
cancellationToken?.Cancel();
panelLoadTask = LoadComponentsAsync(newCards, cards =>
{
foundContent.Clear();
foundContent.AddRange(cards);
Loading.Hide();
}, (cancellationToken = new CancellationTokenSource()).Token);
}
protected override void Dispose(bool isDisposing)
{
cancellationToken?.Cancel();
@ -336,7 +378,7 @@ namespace osu.Game.Overlays
const int pagination_scroll_distance = 500;
bool shouldShowMore = panelLoadDelegate?.IsCompleted != false
bool shouldShowMore = panelLoadTask?.IsCompleted != false
&& Time.Current - lastFetchDisplayedTime > time_between_fetches
&& (ScrollFlow.ScrollableExtent > 0 && ScrollFlow.IsScrolledToEnd(pagination_scroll_distance));