// 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.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Input.Events; using osuTK; namespace osu.Framework.Graphics.Containers { /// /// A list container that enables its children to be rearranged via dragging. /// /// /// Adding duplicate items is not currently supported. /// /// The type of rearrangeable item. public abstract class RearrangeableListContainer : CompositeDrawable { private const float exp_base = 1.05f; /// /// The items contained by this , in the order they are arranged. /// public readonly BindableList Items = new BindableList(); /// /// The maximum exponent of the automatic scroll speed at the boundaries of this . /// protected float MaxExponent = 50; /// /// The containing the flow of items. /// protected readonly ScrollContainer ScrollContainer; /// /// The containing of all the s. /// protected readonly FillFlowContainer> ListContainer; /// /// The mapping of to . /// protected IReadOnlyDictionary> ItemMap => itemMap; private readonly Dictionary> itemMap = new Dictionary>(); private RearrangeableListItem currentlyDraggedItem; private Vector2 screenSpaceDragPosition; /// /// Creates a new . /// protected RearrangeableListContainer() { ListContainer = CreateListFillFlowContainer().With(d => { d.RelativeSizeAxes = Axes.X; d.AutoSizeAxes = Axes.Y; d.Direction = FillDirection.Vertical; }); InternalChild = ScrollContainer = CreateScrollContainer().With(d => { d.RelativeSizeAxes = Axes.Both; d.Child = ListContainer; }); Items.CollectionChanged += collectionChanged; } private void collectionChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) { case NotifyCollectionChangedAction.Add: addItems(e.NewItems); break; case NotifyCollectionChangedAction.Remove: removeItems(e.OldItems); // Explicitly reset scroll position here so that ScrollContainer doesn't retain our // scroll position if we quickly add new items after calling a Clear(). if (Items.Count == 0) ScrollContainer.ScrollToStart(); break; case NotifyCollectionChangedAction.Reset: currentlyDraggedItem = null; ListContainer.Clear(); itemMap.Clear(); break; case NotifyCollectionChangedAction.Replace: removeItems(e.OldItems); addItems(e.NewItems); break; } } private void removeItems(IList items) { foreach (var item in items.Cast()) { if (currentlyDraggedItem != null && EqualityComparer.Default.Equals(currentlyDraggedItem.Model, item)) currentlyDraggedItem = null; ListContainer.Remove(itemMap[item]); itemMap.Remove(item); } reSort(); } private void addItems(IList items) { var drawablesToAdd = new List(); foreach (var item in items.Cast()) { if (itemMap.ContainsKey(item)) { throw new InvalidOperationException( $"Duplicate items cannot be added to a {nameof(BindableList)} that is currently bound with a {nameof(RearrangeableListContainer)}."); } var drawable = CreateDrawable(item).With(d => { d.StartArrangement += startArrangement; d.Arrange += arrange; d.EndArrangement += endArrangement; }); drawablesToAdd.Add(drawable); itemMap[item] = drawable; } if (!IsLoaded) addToHierarchy(drawablesToAdd); else LoadComponentsAsync(drawablesToAdd, addToHierarchy); void addToHierarchy(IEnumerable drawables) { foreach (var d in drawables.Cast>()) { // Don't add drawables whose models were removed during the async load, or drawables that are no longer attached to the contained model. if (itemMap.TryGetValue(d.Model, out var modelDrawable) && modelDrawable == d) ListContainer.Add(d); } reSort(); } } private void reSort() { for (int i = 0; i < Items.Count; i++) { var drawable = itemMap[Items[i]]; // If the async load didn't complete, the item wouldn't exist in the container and an exception would be thrown if (drawable.Parent == ListContainer) ListContainer.SetLayoutPosition(drawable, i); } } private void startArrangement(RearrangeableListItem item, DragStartEvent e) { currentlyDraggedItem = item; screenSpaceDragPosition = e.ScreenSpaceMousePosition; } private void arrange(RearrangeableListItem item, DragEvent e) => screenSpaceDragPosition = e.ScreenSpaceMousePosition; private void endArrangement(RearrangeableListItem item, DragEndEvent e) => currentlyDraggedItem = null; protected override void Update() { base.Update(); if (currentlyDraggedItem != null) updateScrollPosition(); } protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); if (currentlyDraggedItem != null) updateArrangement(); } private void updateScrollPosition() { Vector2 localPos = ScrollContainer.ToLocalSpace(screenSpaceDragPosition); float scrollSpeed = 0; if (localPos.Y < 0) { var power = Math.Min(MaxExponent, Math.Abs(localPos.Y)); scrollSpeed = (float)(-MathF.Pow(exp_base, power) * Clock.ElapsedFrameTime * 0.1); } else if (localPos.Y > ScrollContainer.DrawHeight) { var power = Math.Min(MaxExponent, Math.Abs(ScrollContainer.DrawHeight - localPos.Y)); scrollSpeed = (float)(MathF.Pow(exp_base, power) * Clock.ElapsedFrameTime * 0.1); } if ((scrollSpeed < 0 && ScrollContainer.Current > 0) || (scrollSpeed > 0 && !ScrollContainer.IsScrolledToEnd())) ScrollContainer.ScrollBy(scrollSpeed); } private void updateArrangement() { var localPos = ListContainer.ToLocalSpace(screenSpaceDragPosition); int srcIndex = Items.IndexOf(currentlyDraggedItem.Model); // Find the last item with position < mouse position. Note we can't directly use // the item positions as they are being transformed float heightAccumulator = 0; int dstIndex = 0; for (; dstIndex < Items.Count; dstIndex++) { var drawable = itemMap[Items[dstIndex]]; if (!drawable.IsLoaded || !drawable.IsPresent) continue; // Using BoundingBox here takes care of scale, paddings, etc... float height = drawable.BoundingBox.Height; // Rearrangement should occur only after the mid-point of items is crossed heightAccumulator += height / 2; // Check if the midpoint has been crossed (i.e. cursor is located above the midpoint) if (heightAccumulator > localPos.Y) { if (dstIndex > srcIndex) { // Suppose an item is dragged just slightly below its own midpoint. The rearrangement condition (accumulator > pos) will be satisfied for the next immediate item // but not the currently-dragged item, which will invoke a rearrangement. This is an off-by-one condition. // Rearrangement should not occur until the midpoint of the next item is crossed, and so to fix this the next item's index is discarded. dstIndex--; } break; } // Add the remainder of the height of the current item heightAccumulator += height / 2 + ListContainer.Spacing.Y; } dstIndex = Math.Clamp(dstIndex, 0, Items.Count - 1); if (srcIndex == dstIndex) return; Items.Move(srcIndex, dstIndex); // Todo: this could be optimised, but it's a very simple iteration over all the items reSort(); } /// /// Creates the for the items. /// protected virtual FillFlowContainer> CreateListFillFlowContainer() => new FillFlowContainer>(); /// /// Creates the for the list of items. /// protected abstract ScrollContainer CreateScrollContainer(); /// /// Creates the representation of an item. /// /// The item to create the representation of. /// The . protected abstract RearrangeableListItem CreateDrawable(TModel item); } }