A game framework written with osu! in mind.
at master 288 lines 11 kB view raw
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}