A game framework written with osu! in mind.
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2// See the LICENCE file in the repository root for full licence text.
3
4using System;
5using System.Collections;
6using System.Collections.Generic;
7using System.Collections.Specialized;
8using System.Linq;
9using osu.Framework.Bindables;
10using osu.Framework.Input.Events;
11using osuTK;
12
13namespace osu.Framework.Graphics.Containers
14{
15 /// <summary>
16 /// A list container that enables its children to be rearranged via dragging.
17 /// </summary>
18 /// <remarks>
19 /// Adding duplicate items is not currently supported.
20 /// </remarks>
21 /// <typeparam name="TModel">The type of rearrangeable item.</typeparam>
22 public abstract class RearrangeableListContainer<TModel> : CompositeDrawable
23 {
24 private const float exp_base = 1.05f;
25
26 /// <summary>
27 /// The items contained by this <see cref="RearrangeableListContainer{TModel}"/>, in the order they are arranged.
28 /// </summary>
29 public readonly BindableList<TModel> Items = new BindableList<TModel>();
30
31 /// <summary>
32 /// The maximum exponent of the automatic scroll speed at the boundaries of this <see cref="RearrangeableListContainer{TModel}"/>.
33 /// </summary>
34 protected float MaxExponent = 50;
35
36 /// <summary>
37 /// The <see cref="ScrollContainer"/> containing the flow of items.
38 /// </summary>
39 protected readonly ScrollContainer<Drawable> ScrollContainer;
40
41 /// <summary>
42 /// The <see cref="FillFlowContainer"/> containing of all the <see cref="RearrangeableListItem{TModel}"/>s.
43 /// </summary>
44 protected readonly FillFlowContainer<RearrangeableListItem<TModel>> ListContainer;
45
46 /// <summary>
47 /// The mapping of <typeparamref name="TModel"/> to <see cref="RearrangeableListItem{TModel}"/>.
48 /// </summary>
49 protected IReadOnlyDictionary<TModel, RearrangeableListItem<TModel>> ItemMap => itemMap;
50
51 private readonly Dictionary<TModel, RearrangeableListItem<TModel>> itemMap = new Dictionary<TModel, RearrangeableListItem<TModel>>();
52 private RearrangeableListItem<TModel> currentlyDraggedItem;
53 private Vector2 screenSpaceDragPosition;
54
55 /// <summary>
56 /// Creates a new <see cref="RearrangeableListContainer{TModel}"/>.
57 /// </summary>
58 protected RearrangeableListContainer()
59 {
60 ListContainer = CreateListFillFlowContainer().With(d =>
61 {
62 d.RelativeSizeAxes = Axes.X;
63 d.AutoSizeAxes = Axes.Y;
64 d.Direction = FillDirection.Vertical;
65 });
66
67 InternalChild = ScrollContainer = CreateScrollContainer().With(d =>
68 {
69 d.RelativeSizeAxes = Axes.Both;
70 d.Child = ListContainer;
71 });
72
73 Items.CollectionChanged += collectionChanged;
74 }
75
76 private void collectionChanged(object sender, NotifyCollectionChangedEventArgs e)
77 {
78 switch (e.Action)
79 {
80 case NotifyCollectionChangedAction.Add:
81 addItems(e.NewItems);
82 break;
83
84 case NotifyCollectionChangedAction.Remove:
85 removeItems(e.OldItems);
86
87 // Explicitly reset scroll position here so that ScrollContainer doesn't retain our
88 // scroll position if we quickly add new items after calling a Clear().
89 if (Items.Count == 0)
90 ScrollContainer.ScrollToStart();
91 break;
92
93 case NotifyCollectionChangedAction.Reset:
94 currentlyDraggedItem = null;
95 ListContainer.Clear();
96 itemMap.Clear();
97 break;
98
99 case NotifyCollectionChangedAction.Replace:
100 removeItems(e.OldItems);
101 addItems(e.NewItems);
102 break;
103 }
104 }
105
106 private void removeItems(IList items)
107 {
108 foreach (var item in items.Cast<TModel>())
109 {
110 if (currentlyDraggedItem != null && EqualityComparer<TModel>.Default.Equals(currentlyDraggedItem.Model, item))
111 currentlyDraggedItem = null;
112
113 ListContainer.Remove(itemMap[item]);
114 itemMap.Remove(item);
115 }
116
117 reSort();
118 }
119
120 private void addItems(IList items)
121 {
122 var drawablesToAdd = new List<Drawable>();
123
124 foreach (var item in items.Cast<TModel>())
125 {
126 if (itemMap.ContainsKey(item))
127 {
128 throw new InvalidOperationException(
129 $"Duplicate items cannot be added to a {nameof(BindableList<TModel>)} that is currently bound with a {nameof(RearrangeableListContainer<TModel>)}.");
130 }
131
132 var drawable = CreateDrawable(item).With(d =>
133 {
134 d.StartArrangement += startArrangement;
135 d.Arrange += arrange;
136 d.EndArrangement += endArrangement;
137 });
138
139 drawablesToAdd.Add(drawable);
140 itemMap[item] = drawable;
141 }
142
143 if (!IsLoaded)
144 addToHierarchy(drawablesToAdd);
145 else
146 LoadComponentsAsync(drawablesToAdd, addToHierarchy);
147
148 void addToHierarchy(IEnumerable<Drawable> drawables)
149 {
150 foreach (var d in drawables.Cast<RearrangeableListItem<TModel>>())
151 {
152 // Don't add drawables whose models were removed during the async load, or drawables that are no longer attached to the contained model.
153 if (itemMap.TryGetValue(d.Model, out var modelDrawable) && modelDrawable == d)
154 ListContainer.Add(d);
155 }
156
157 reSort();
158 }
159 }
160
161 private void reSort()
162 {
163 for (int i = 0; i < Items.Count; i++)
164 {
165 var drawable = itemMap[Items[i]];
166
167 // If the async load didn't complete, the item wouldn't exist in the container and an exception would be thrown
168 if (drawable.Parent == ListContainer)
169 ListContainer.SetLayoutPosition(drawable, i);
170 }
171 }
172
173 private void startArrangement(RearrangeableListItem<TModel> item, DragStartEvent e)
174 {
175 currentlyDraggedItem = item;
176 screenSpaceDragPosition = e.ScreenSpaceMousePosition;
177 }
178
179 private void arrange(RearrangeableListItem<TModel> item, DragEvent e) => screenSpaceDragPosition = e.ScreenSpaceMousePosition;
180
181 private void endArrangement(RearrangeableListItem<TModel> item, DragEndEvent e) => currentlyDraggedItem = null;
182
183 protected override void Update()
184 {
185 base.Update();
186
187 if (currentlyDraggedItem != null)
188 updateScrollPosition();
189 }
190
191 protected override void UpdateAfterChildren()
192 {
193 base.UpdateAfterChildren();
194
195 if (currentlyDraggedItem != null)
196 updateArrangement();
197 }
198
199 private void updateScrollPosition()
200 {
201 Vector2 localPos = ScrollContainer.ToLocalSpace(screenSpaceDragPosition);
202 float scrollSpeed = 0;
203
204 if (localPos.Y < 0)
205 {
206 var power = Math.Min(MaxExponent, Math.Abs(localPos.Y));
207 scrollSpeed = (float)(-MathF.Pow(exp_base, power) * Clock.ElapsedFrameTime * 0.1);
208 }
209 else if (localPos.Y > ScrollContainer.DrawHeight)
210 {
211 var power = Math.Min(MaxExponent, Math.Abs(ScrollContainer.DrawHeight - localPos.Y));
212 scrollSpeed = (float)(MathF.Pow(exp_base, power) * Clock.ElapsedFrameTime * 0.1);
213 }
214
215 if ((scrollSpeed < 0 && ScrollContainer.Current > 0) || (scrollSpeed > 0 && !ScrollContainer.IsScrolledToEnd()))
216 ScrollContainer.ScrollBy(scrollSpeed);
217 }
218
219 private void updateArrangement()
220 {
221 var localPos = ListContainer.ToLocalSpace(screenSpaceDragPosition);
222 int srcIndex = Items.IndexOf(currentlyDraggedItem.Model);
223
224 // Find the last item with position < mouse position. Note we can't directly use
225 // the item positions as they are being transformed
226 float heightAccumulator = 0;
227 int dstIndex = 0;
228
229 for (; dstIndex < Items.Count; dstIndex++)
230 {
231 var drawable = itemMap[Items[dstIndex]];
232
233 if (!drawable.IsLoaded || !drawable.IsPresent)
234 continue;
235
236 // Using BoundingBox here takes care of scale, paddings, etc...
237 float height = drawable.BoundingBox.Height;
238
239 // Rearrangement should occur only after the mid-point of items is crossed
240 heightAccumulator += height / 2;
241
242 // Check if the midpoint has been crossed (i.e. cursor is located above the midpoint)
243 if (heightAccumulator > localPos.Y)
244 {
245 if (dstIndex > srcIndex)
246 {
247 // Suppose an item is dragged just slightly below its own midpoint. The rearrangement condition (accumulator > pos) will be satisfied for the next immediate item
248 // but not the currently-dragged item, which will invoke a rearrangement. This is an off-by-one condition.
249 // 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.
250 dstIndex--;
251 }
252
253 break;
254 }
255
256 // Add the remainder of the height of the current item
257 heightAccumulator += height / 2 + ListContainer.Spacing.Y;
258 }
259
260 dstIndex = Math.Clamp(dstIndex, 0, Items.Count - 1);
261
262 if (srcIndex == dstIndex)
263 return;
264
265 Items.Move(srcIndex, dstIndex);
266
267 // Todo: this could be optimised, but it's a very simple iteration over all the items
268 reSort();
269 }
270
271 /// <summary>
272 /// Creates the <see cref="FillFlowContainer{DrawableRearrangeableListItem}"/> for the items.
273 /// </summary>
274 protected virtual FillFlowContainer<RearrangeableListItem<TModel>> CreateListFillFlowContainer() => new FillFlowContainer<RearrangeableListItem<TModel>>();
275
276 /// <summary>
277 /// Creates the <see cref="ScrollContainer"/> for the list of items.
278 /// </summary>
279 protected abstract ScrollContainer<Drawable> CreateScrollContainer();
280
281 /// <summary>
282 /// Creates the <see cref="Drawable"/> representation of an item.
283 /// </summary>
284 /// <param name="item">The item to create the <see cref="Drawable"/> representation of.</param>
285 /// <returns>The <see cref="RearrangeableListItem{TModel}"/>.</returns>
286 protected abstract RearrangeableListItem<TModel> CreateDrawable(TModel item);
287 }
288}