1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-31 20:52:54 +08:00

Add selection and activation flow

This commit is contained in:
Dean Herbert 2025-01-23 16:11:02 +09:00
parent ecef5e5d71
commit 2f94456a06
No known key found for this signature in database
2 changed files with 329 additions and 67 deletions

View File

@ -26,6 +26,8 @@ namespace osu.Game.Screens.SelectV2
private readonly LoadingLayer loading;
private readonly BeatmapCarouselFilterGrouping grouping;
public BeatmapCarousel()
{
DebounceDelay = 100;
@ -34,7 +36,7 @@ namespace osu.Game.Screens.SelectV2
Filters = new ICarouselFilter[]
{
new BeatmapCarouselFilterSorting(() => Criteria),
new BeatmapCarouselFilterGrouping(() => Criteria),
grouping = new BeatmapCarouselFilterGrouping(() => Criteria),
};
AddInternal(carouselPanelPool);
@ -51,7 +53,50 @@ namespace osu.Game.Screens.SelectV2
protected override Drawable GetDrawableForDisplay(CarouselItem item) => carouselPanelPool.Get();
protected override CarouselItem CreateCarouselItemForModel(BeatmapInfo model) => new BeatmapCarouselItem(model);
protected override void HandleItemDeselected(object? model)
{
base.HandleItemDeselected(model);
var deselectedSet = model as BeatmapSetInfo ?? (model as BeatmapInfo)?.BeatmapSet;
if (grouping.SetItems.TryGetValue(deselectedSet!, out var group))
{
foreach (var i in group)
i.IsVisible = false;
}
}
protected override void HandleItemSelected(object? model)
{
base.HandleItemSelected(model);
// Selecting a set isn't valid let's re-select the first difficulty.
if (model is BeatmapSetInfo setInfo)
{
CurrentSelection = setInfo.Beatmaps.First();
return;
}
var currentSelectionSet = (model as BeatmapInfo)?.BeatmapSet;
if (currentSelectionSet == null)
return;
if (grouping.SetItems.TryGetValue(currentSelectionSet, out var group))
{
foreach (var i in group)
i.IsVisible = true;
}
}
protected override void HandleItemActivated(CarouselItem item)
{
base.HandleItemActivated(item);
// TODO: maybe this should be handled by the panel itself?
if (GetMaterialisedDrawableForItem(item) is BeatmapCarouselPanel drawable)
drawable.FlashFromActivation();
}
private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed)
{

View File

@ -28,7 +28,8 @@ namespace osu.Game.Screens.SelectV2
/// 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<T> : CompositeDrawable
public abstract partial class Carousel<T> : CompositeDrawable, IKeyBindingHandler<GlobalAction>
where T : notnull
{
#region Properties and methods for external usage
@ -80,26 +81,34 @@ namespace osu.Game.Screens.SelectV2
public int VisibleItems => scroll.Panels.Count;
/// <summary>
/// The currently selected model.
/// The currently selected model. Generally of type T.
/// </summary>
/// <remarks>
/// Setting this will ensure <see cref="CarouselItem.Selected"/> is set to <c>true</c> only on the matching <see cref="CarouselItem"/>.
/// Of note, if no matching item exists all items will be deselected while waiting for potential new item which matches.
/// A carousel may create panels for non-T types.
/// To keep things simple, we therefore avoid generic constraints on the current selection.
///
/// The selection is never reset due to not existing. It can be set to anything.
/// If no matching carousel item exists, there will be no visually selected item while waiting for potential new item which matches.
/// </remarks>
public virtual object? CurrentSelection
public object? CurrentSelection
{
get => currentSelection;
set
get => currentSelection.Model;
set => setSelection(value);
}
/// <summary>
/// Activate the current selection, if a selection exists.
/// </summary>
public void ActivateSelection()
{
if (currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
{
if (currentSelectionCarouselItem != null)
currentSelectionCarouselItem.Selected.Value = false;
currentSelection = value;
currentSelectionCarouselItem = null;
currentSelectionYPosition = null;
updateSelection();
CurrentSelection = currentKeyboardSelection.Model;
return;
}
if (currentSelection.CarouselItem != null)
HandleItemActivated(currentSelection.CarouselItem);
}
#endregion
@ -144,11 +153,42 @@ namespace osu.Game.Screens.SelectV2
protected abstract Drawable GetDrawableForDisplay(CarouselItem item);
/// <summary>
/// Create an internal carousel representation for the provided model object.
/// Given a <see cref="CarouselItem"/>, find a drawable representation if it is currently displayed in the carousel.
/// </summary>
/// <param name="model">The model.</param>
/// <returns>A <see cref="CarouselItem"/> representing the model.</returns>
protected abstract CarouselItem CreateCarouselItemForModel(T model);
/// <remarks>
/// This will only return a drawable if it is "on-screen".
/// </remarks>
/// <param name="item">The item to find a related drawable representation.</param>
/// <returns>The drawable representation if it exists.</returns>
protected Drawable? GetMaterialisedDrawableForItem(CarouselItem item) =>
scroll.Panels.SingleOrDefault(p => ((ICarouselPanel)p).Item == item);
/// <summary>
/// Called when an item is "selected".
/// </summary>
protected virtual void HandleItemSelected(object? model)
{
}
/// <summary>
/// Called when an item is "deselected".
/// </summary>
protected virtual void HandleItemDeselected(object? model)
{
}
/// <summary>
/// Called when an item is "activated".
/// </summary>
/// <remarks>
/// An activated item should for instance:
/// - Open or close a folder
/// - Start gameplay on a beatmap difficulty.
/// </remarks>
/// <param name="item">The carousel item which was activated.</param>
protected virtual void HandleItemActivated(CarouselItem item)
{
}
#endregion
@ -197,7 +237,7 @@ namespace osu.Game.Screens.SelectV2
// Copy must be performed on update thread for now (see ConfigureAwait above).
// Could potentially be optimised in the future if it becomes an issue.
IEnumerable<CarouselItem> items = new List<CarouselItem>(Items.Select(CreateCarouselItemForModel));
IEnumerable<CarouselItem> items = new List<CarouselItem>(Items.Select(m => new CarouselItem(m)));
await Task.Run(async () =>
{
@ -210,7 +250,7 @@ namespace osu.Game.Screens.SelectV2
}
log("Updating Y positions");
await updateYPositions(items, cts.Token).ConfigureAwait(false);
updateYPositions(items, visibleHalfHeight, SpacingBetweenPanels);
}
catch (OperationCanceledException)
{
@ -225,58 +265,231 @@ namespace osu.Game.Screens.SelectV2
carouselItems = items.ToList();
displayedRange = null;
updateSelection();
// Need to call this to ensure correct post-selection logic is handled on the new items list.
HandleItemSelected(currentSelection.Model);
refreshAfterSelection();
void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString()}] {stopwatch.ElapsedMilliseconds} ms: {text}");
}
private async Task updateYPositions(IEnumerable<CarouselItem> carouselItems, CancellationToken cancellationToken) => await Task.Run(() =>
private static void updateYPositions(IEnumerable<CarouselItem> carouselItems, float offset, float spacing)
{
float yPos = visibleHalfHeight;
foreach (var item in carouselItems)
updateItemYPosition(item, ref offset, spacing);
}
private static void updateItemYPosition(CarouselItem item, ref float offset, float spacing)
{
item.CarouselYPosition = offset;
if (item.IsVisible)
offset += item.DrawHeight + spacing;
}
#endregion
#region Input handling
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
switch (e.Action)
{
item.CarouselYPosition = yPos;
yPos += item.DrawHeight + SpacingBetweenPanels;
case GlobalAction.Select:
ActivateSelection();
return true;
case GlobalAction.SelectNext:
selectNext(1, isGroupSelection: false);
return true;
case GlobalAction.SelectNextGroup:
selectNext(1, isGroupSelection: true);
return true;
case GlobalAction.SelectPrevious:
selectNext(-1, isGroupSelection: false);
return true;
case GlobalAction.SelectPreviousGroup:
selectNext(-1, isGroupSelection: true);
return true;
}
}, cancellationToken).ConfigureAwait(false);
return false;
}
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{
}
/// <summary>
/// Select the next valid selection relative to a current selection.
/// This is generally for keyboard based traversal.
/// </summary>
/// <param name="direction">Positive for downwards, negative for upwards.</param>
/// <param name="isGroupSelection">Whether the selection should traverse groups. Group selection updates the actual selection immediately, while non-group selection will only prepare a future keyboard selection.</param>
/// <returns>Whether selection was possible.</returns>
private bool selectNext(int direction, bool isGroupSelection)
{
// Ensure sanity
Debug.Assert(direction != 0);
direction = direction > 0 ? 1 : -1;
if (carouselItems == null || carouselItems.Count == 0)
return false;
// If the user has a different keyboard selection and requests
// group selection, first transfer the keyboard selection to actual selection.
if (isGroupSelection && currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
{
ActivateSelection();
return true;
}
CarouselItem? selectionItem = currentKeyboardSelection.CarouselItem;
int selectionIndex = currentKeyboardSelection.Index ?? -1;
// To keep things simple, let's first handle the cases where there's no selection yet.
if (selectionItem == null || selectionIndex < 0)
{
// Start by selecting the first item.
selectionItem = carouselItems.First();
selectionIndex = 0;
// In the forwards case, immediately attempt selection of this panel.
// If selection fails, continue with standard logic to find the next valid selection.
if (direction > 0 && attemptSelection(selectionItem))
return true;
// In the backwards direction we can just allow the selection logic to go ahead and loop around to the last valid.
}
Debug.Assert(selectionItem != null);
// As a second special case, if we're group selecting backwards and the current selection isn't
// a group, base this selection operation from the closest previous group.
if (isGroupSelection && direction < 0)
{
while (!carouselItems[selectionIndex].IsGroupSelectionTarget)
selectionIndex--;
}
CarouselItem? newItem;
// Iterate over every item back to the current selection, finding the first valid item.
// The fail condition is when we reach the selection after a cyclic loop over every item.
do
{
selectionIndex += direction;
newItem = carouselItems[(selectionIndex + carouselItems.Count) % carouselItems.Count];
if (attemptSelection(newItem))
return true;
} while (newItem != selectionItem);
return false;
bool attemptSelection(CarouselItem item)
{
if (!item.IsVisible || (isGroupSelection && !item.IsGroupSelectionTarget))
return false;
if (isGroupSelection)
setSelection(item.Model);
else
setKeyboardSelection(item.Model);
return true;
}
}
#endregion
#region Selection handling
private object? currentSelection;
private CarouselItem? currentSelectionCarouselItem;
private double? currentSelectionYPosition;
private Selection currentKeyboardSelection = new Selection();
private Selection currentSelection = new Selection();
private void updateSelection()
private void setSelection(object? model)
{
currentSelectionCarouselItem = null;
if (currentSelection.Model == model)
return;
if (carouselItems == null) return;
var previousSelection = currentSelection;
foreach (var item in carouselItems)
if (previousSelection.Model != null)
HandleItemDeselected(previousSelection.Model);
currentSelection = currentKeyboardSelection = new Selection(model);
HandleItemSelected(currentSelection.Model);
// ensure the selection hasn't changed in the handling of selection.
// if it's changed, avoid a second update of selection/scroll.
if (currentSelection.Model != model)
return;
refreshAfterSelection();
scrollToSelection();
}
private void setKeyboardSelection(object? model)
{
currentKeyboardSelection = new Selection(model);
refreshAfterSelection();
scrollToSelection();
}
/// <summary>
/// Call after a selection of items change to re-attach <see cref="CarouselItem"/>s to current <see cref="Selection"/>s.
/// </summary>
private void refreshAfterSelection()
{
float yPos = visibleHalfHeight;
// Invalidate display range as panel positions and visible status may have changed.
// Position transfer won't happen unless we invalidate this.
displayedRange = null;
// The case where no items are available for display yet.
if (carouselItems == null)
{
bool isSelected = item.Model == currentSelection;
if (isSelected)
{
currentSelectionCarouselItem = item;
if (currentSelectionYPosition != item.CarouselYPosition)
{
if (currentSelectionYPosition != null)
{
float adjustment = (float)(item.CarouselYPosition - currentSelectionYPosition.Value);
scroll.OffsetScrollPosition(adjustment);
}
currentSelectionYPosition = item.CarouselYPosition;
}
}
item.Selected.Value = isSelected;
currentKeyboardSelection = new Selection();
currentSelection = new Selection();
return;
}
float spacing = SpacingBetweenPanels;
int count = carouselItems.Count;
Selection prevKeyboard = currentKeyboardSelection;
// We are performing two important operations here:
// - Update all Y positions. After a selection occurs, panels may have changed visibility state and therefore Y positions.
// - Link selected models to CarouselItems. If a selection changed, this is where we find the relevant CarouselItems for further use.
for (int i = 0; i < count; i++)
{
var item = carouselItems[i];
updateItemYPosition(item, ref yPos, spacing);
if (ReferenceEquals(item.Model, currentKeyboardSelection.Model))
currentKeyboardSelection = new Selection(item.Model, item, item.CarouselYPosition, i);
if (ReferenceEquals(item.Model, currentSelection.Model))
currentSelection = new Selection(item.Model, item, item.CarouselYPosition, i);
}
// If a keyboard selection is currently made, we want to keep the view stable around the selection.
// That means that we should offset the immediate scroll position by any change in Y position for the selection.
if (prevKeyboard.YPosition != null && currentKeyboardSelection.YPosition != prevKeyboard.YPosition)
scroll.OffsetScrollPosition((float)(currentKeyboardSelection.YPosition!.Value - prevKeyboard.YPosition.Value));
}
private void scrollToSelection()
{
if (currentKeyboardSelection.CarouselItem != null)
scroll.ScrollTo(currentKeyboardSelection.CarouselItem.CarouselYPosition - visibleHalfHeight);
}
#endregion
@ -285,7 +498,7 @@ namespace osu.Game.Screens.SelectV2
private DisplayRange? displayedRange;
private readonly CarouselItem carouselBoundsItem = new BoundsCarouselItem();
private readonly CarouselItem carouselBoundsItem = new CarouselItem(new object());
/// <summary>
/// The position of the lower visible bound with respect to the current scroll position.
@ -335,6 +548,9 @@ namespace osu.Game.Screens.SelectV2
float dist = Math.Abs(1f - posInScroll.Y / visibleHalfHeight);
panel.X = offsetX(dist, visibleHalfHeight);
c.Selected.Value = c.Item == currentSelection?.CarouselItem;
c.KeyboardSelected.Value = c.Item == currentKeyboardSelection?.CarouselItem;
}
}
@ -381,6 +597,8 @@ namespace osu.Game.Screens.SelectV2
? new List<CarouselItem>()
: carouselItems.GetRange(range.First, range.Last - range.First + 1);
toDisplay.RemoveAll(i => !i.IsVisible);
// Iterate over all panels which are already displayed and figure which need to be displayed / removed.
foreach (var panel in scroll.Panels)
{
@ -434,6 +652,15 @@ namespace osu.Game.Screens.SelectV2
#region Internal helper classes
/// <summary>
/// Bookkeeping for a current selection.
/// </summary>
/// <param name="Model">The selected model. If <c>null</c>, there's no selection.</param>
/// <param name="CarouselItem">A related carousel item representation for the model. May be null if selection is not present as an item, or if <see cref="Carousel{T}.refreshAfterSelection"/> has not been run yet.</param>
/// <param name="YPosition">The Y position of the selection as of the last run of <see cref="Carousel{T}.refreshAfterSelection"/>. May be null if selection is not present as an item, or if <see cref="Carousel{T}.refreshAfterSelection"/> has not been run yet.</param>
/// <param name="Index">The index of the selection as of the last run of <see cref="Carousel{T}.refreshAfterSelection"/>. May be null if selection is not present as an item, or if <see cref="Carousel{T}.refreshAfterSelection"/> has not been run yet.</param>
private record Selection(object? Model = null, CarouselItem? CarouselItem = null, double? YPosition = null, int? Index = null);
private record DisplayRange(int First, int Last);
/// <summary>
@ -573,16 +800,6 @@ namespace osu.Game.Screens.SelectV2
#endregion
}
private class BoundsCarouselItem : CarouselItem
{
public override float DrawHeight => 0;
public BoundsCarouselItem()
: base(new object())
{
}
}
#endregion
}
}