Merge pull request #8999 from namaenonaimumei/pr-beatmap_listing_pagination

BeatmapListingOverlay auto-pagination reimplementation
This commit is contained in:
Dan Balasescu
2020-05-14 17:36:04 +09:00
committed by GitHub
6 changed files with 214 additions and 64 deletions

View 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.IO.Network;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Online.API.Requests;
namespace osu.Game.Extensions
{
public static class WebRequestExtensions
{
/// <summary>
/// Add a pagination cursor to the web request in the format required by osu-web.
/// </summary>
public static void AddCursor(this WebRequest webRequest, Cursor cursor)
{
cursor?.Properties.ForEach(x =>
{
webRequest.AddParameter("cursor[" + x.Key + "]", x.Value.ToString());
});
}
}
}

View File

@ -0,0 +1,20 @@
// 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 JetBrains.Annotations;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace osu.Game.Online.API.Requests
{
/// <summary>
/// A collection of parameters which should be passed to the search endpoint to fetch the next page.
/// </summary>
public class Cursor
{
[UsedImplicitly]
[JsonExtensionData]
public IDictionary<string, JToken> Properties;
}
}

View File

@ -7,10 +7,7 @@ namespace osu.Game.Online.API.Requests
{
public abstract class ResponseWithCursor
{
/// <summary>
/// A collection of parameters which should be passed to the search endpoint to fetch the next page.
/// </summary>
[JsonProperty("cursor")]
public dynamic CursorJson;
public Cursor Cursor;
}
}

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.IO.Network;
using osu.Game.Extensions;
using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapListing;
using osu.Game.Rulesets;
@ -10,29 +11,31 @@ namespace osu.Game.Online.API.Requests
{
public class SearchBeatmapSetsRequest : APIRequest<SearchBeatmapSetsResponse>
{
public SearchCategory SearchCategory { get; set; }
public SearchCategory SearchCategory { get; }
public SortCriteria SortCriteria { get; set; }
public SortCriteria SortCriteria { get; }
public SortDirection SortDirection { get; set; }
public SortDirection SortDirection { get; }
public SearchGenre Genre { get; set; }
public SearchGenre Genre { get; }
public SearchLanguage Language { get; set; }
public SearchLanguage Language { get; }
private readonly string query;
private readonly RulesetInfo ruleset;
private readonly Cursor cursor;
private string directionString => SortDirection == SortDirection.Descending ? @"desc" : @"asc";
public SearchBeatmapSetsRequest(string query, RulesetInfo ruleset)
public SearchBeatmapSetsRequest(string query, RulesetInfo ruleset, Cursor cursor = null, SearchCategory searchCategory = SearchCategory.Any, SortCriteria sortCriteria = SortCriteria.Ranked, SortDirection sortDirection = SortDirection.Descending)
{
this.query = string.IsNullOrEmpty(query) ? string.Empty : System.Uri.EscapeDataString(query);
this.ruleset = ruleset;
this.cursor = cursor;
SearchCategory = SearchCategory.Any;
SortCriteria = SortCriteria.Ranked;
SortDirection = SortDirection.Descending;
SearchCategory = searchCategory;
SortCriteria = sortCriteria;
SortDirection = sortDirection;
Genre = SearchGenre.Any;
Language = SearchLanguage.Any;
}
@ -55,6 +58,8 @@ namespace osu.Game.Online.API.Requests
req.AddParameter("sort", $"{SortCriteria.ToString().ToLowerInvariant()}_{directionString}");
req.AddCursor(cursor);
return req;
}

View File

@ -22,25 +22,46 @@ namespace osu.Game.Overlays.BeatmapListing
{
public class BeatmapListingFilterControl : CompositeDrawable
{
/// <summary>
/// Fired when a search finishes. Contains only new items in the case of pagination.
/// </summary>
public Action<List<BeatmapSetInfo>> SearchFinished;
/// <summary>
/// Fired when search criteria change.
/// </summary>
public Action SearchStarted;
/// <summary>
/// True when pagination has reached the end of available results.
/// </summary>
private bool noMoreResults;
/// <summary>
/// The current page fetched of results (zero index).
/// </summary>
public int CurrentPage { get; private set; }
private readonly BeatmapListingSearchControl searchControl;
private readonly BeatmapListingSortTabControl sortControl;
private readonly Box sortControlBackground;
private ScheduledDelegate queryChangedDebounce;
private SearchBeatmapSetsRequest getSetsRequest;
private SearchBeatmapSetsResponse lastResponse;
[Resolved]
private IAPIProvider api { get; set; }
[Resolved]
private RulesetStore rulesets { get; set; }
private readonly BeatmapListingSearchControl searchControl;
private readonly BeatmapListingSortTabControl sortControl;
private readonly Box sortControlBackground;
private SearchBeatmapSetsRequest getSetsRequest;
public BeatmapListingFilterControl()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
InternalChild = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
@ -114,51 +135,84 @@ namespace osu.Game.Overlays.BeatmapListing
sortDirection.BindValueChanged(_ => queueUpdateSearch());
}
private ScheduledDelegate queryChangedDebounce;
public void TakeFocus() => searchControl.TakeFocus();
/// <summary>
/// Fetch the next page of results. May result in a no-op if a fetch is already in progress, or if there are no results left.
/// </summary>
public void FetchNextPage()
{
// there may be no results left.
if (noMoreResults)
return;
// there may already be an active request.
if (getSetsRequest != null)
return;
if (lastResponse != null)
CurrentPage++;
performRequest();
}
private void queueUpdateSearch(bool queryTextChanged = false)
{
SearchStarted?.Invoke();
getSetsRequest?.Cancel();
resetSearch();
queryChangedDebounce?.Cancel();
queryChangedDebounce = Scheduler.AddDelayed(updateSearch, queryTextChanged ? 500 : 100);
queryChangedDebounce = Scheduler.AddDelayed(() =>
{
resetSearch();
FetchNextPage();
}, queryTextChanged ? 500 : 100);
}
private void updateSearch()
private void performRequest()
{
getSetsRequest = new SearchBeatmapSetsRequest(searchControl.Query.Value, searchControl.Ruleset.Value)
{
SearchCategory = searchControl.Category.Value,
SortCriteria = sortControl.Current.Value,
SortDirection = sortControl.SortDirection.Value,
Genre = searchControl.Genre.Value,
Language = searchControl.Language.Value
};
getSetsRequest = new SearchBeatmapSetsRequest(
searchControl.Query.Value,
searchControl.Ruleset.Value,
lastResponse?.Cursor,
searchControl.Category.Value,
sortControl.Current.Value,
sortControl.SortDirection.Value);
getSetsRequest.Success += response => Schedule(() => onSearchFinished(response));
getSetsRequest.Success += response =>
{
var sets = response.BeatmapSets.Select(responseJson => responseJson.ToBeatmapSet(rulesets)).ToList();
if (sets.Count == 0)
noMoreResults = true;
lastResponse = response;
getSetsRequest = null;
SearchFinished?.Invoke(sets);
};
api.Queue(getSetsRequest);
}
private void onSearchFinished(SearchBeatmapSetsResponse response)
private void resetSearch()
{
var beatmaps = response.BeatmapSets.Select(r => r.ToBeatmapSet(rulesets)).ToList();
noMoreResults = false;
CurrentPage = 0;
searchControl.BeatmapSet = response.Total == 0 ? null : beatmaps.First();
lastResponse = null;
SearchFinished?.Invoke(beatmaps);
getSetsRequest?.Cancel();
getSetsRequest = null;
queryChangedDebounce?.Cancel();
}
protected override void Dispose(bool isDisposing)
{
getSetsRequest?.Cancel();
queryChangedDebounce?.Cancel();
resetSearch();
base.Dispose(isDisposing);
}
public void TakeFocus() => searchControl.TakeFocus();
}
}

View File

@ -4,7 +4,9 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
@ -30,6 +32,10 @@ namespace osu.Game.Overlays
private Drawable currentContent;
private LoadingLayer loadingLayer;
private Container panelTarget;
private FillFlowContainer<BeatmapPanel> foundContent;
private NotFoundDrawable notFoundContent;
private OverlayScrollContainer resultScrollContainer;
public BeatmapListingOverlay()
: base(OverlayColourScheme.Blue)
@ -48,7 +54,7 @@ namespace osu.Game.Overlays
RelativeSizeAxes = Axes.Both,
Colour = ColourProvider.Background6
},
new OverlayScrollContainer
resultScrollContainer = new OverlayScrollContainer
{
RelativeSizeAxes = Axes.Both,
ScrollbarVisible = false,
@ -80,9 +86,14 @@ namespace osu.Game.Overlays
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Padding = new MarginPadding { Horizontal = 20 }
},
loadingLayer = new LoadingLayer(panelTarget)
Padding = new MarginPadding { Horizontal = 20 },
Children = new Drawable[]
{
foundContent = new FillFlowContainer<BeatmapPanel>(),
notFoundContent = new NotFoundDrawable(),
loadingLayer = new LoadingLayer(panelTarget)
}
}
}
},
}
@ -110,34 +121,53 @@ namespace osu.Game.Overlays
loadingLayer.Show();
}
private Task panelLoadDelegate;
private void onSearchFinished(List<BeatmapSetInfo> beatmaps)
{
if (!beatmaps.Any())
var newPanels = beatmaps.Select<BeatmapSetInfo, BeatmapPanel>(b => new GridBeatmapPanel(b)
{
LoadComponentAsync(new NotFoundDrawable(), addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token);
return;
}
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
});
var newPanels = new FillFlowContainer<BeatmapPanel>
if (filterControl.CurrentPage == 0)
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(10),
Alpha = 0,
Margin = new MarginPadding { Vertical = 15 },
ChildrenEnumerable = beatmaps.Select<BeatmapSetInfo, BeatmapPanel>(b => new GridBeatmapPanel(b)
//No matches case
if (!newPanels.Any())
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
})
};
LoadComponentAsync(notFoundContent, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token);
return;
}
LoadComponentAsync(newPanels, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token);
// spawn new children with the contained so we only clear old content at the last moment.
var content = new FillFlowContainer<BeatmapPanel>
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(10),
Alpha = 0,
Margin = new MarginPadding { Vertical = 15 },
ChildrenEnumerable = newPanels
};
panelLoadDelegate = LoadComponentAsync(foundContent = content, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token);
}
else
{
panelLoadDelegate = LoadComponentsAsync(newPanels, loaded =>
{
lastFetchDisplayedTime = Time.Current;
foundContent.AddRange(loaded);
loaded.ForEach(p => p.FadeIn(200, Easing.OutQuint));
});
}
}
private void addContentToPlaceholder(Drawable content)
{
loadingLayer.Hide();
lastFetchDisplayedTime = Time.Current;
var lastContent = currentContent;
@ -149,11 +179,14 @@ namespace osu.Game.Overlays
// If the auto-size computation is delayed until fade out completes, the background remain high for too long making the resulting transition to the smaller height look weird.
// At the same time, if the last content's height is bypassed immediately, there is a period where the new content is at Alpha = 0 when the auto-sized height will be 0.
// 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.
lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y);
lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y).Then().Schedule(() => panelTarget.Remove(lastContent));
}
panelTarget.Add(currentContent = content);
currentContent.FadeIn(200, Easing.OutQuint);
if (!content.IsAlive)
panelTarget.Add(content);
content.FadeIn(200, Easing.OutQuint);
currentContent = content;
}
protected override void Dispose(bool isDisposing)
@ -203,5 +236,23 @@ namespace osu.Game.Overlays
});
}
}
private const double time_between_fetches = 500;
private double lastFetchDisplayedTime;
protected override void Update()
{
base.Update();
const int pagination_scroll_distance = 500;
bool shouldShowMore = panelLoadDelegate?.IsCompleted != false
&& Time.Current - lastFetchDisplayedTime > time_between_fetches
&& (resultScrollContainer.ScrollableExtent > 0 && resultScrollContainer.IsScrolledToEnd(pagination_scroll_distance));
if (shouldShowMore)
filterControl.FetchNextPage();
}
}
}