using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using UnityEngine; using UnityEngine.UIElements; #if ENABLE_RUNTIME_DATA_BINDINGS using Unity.Properties; #endif namespace UnityEditor.U2D.Animation.SpriteLibraryEditor { /// /// A view containing recycled rows with items inside. /// #if ENABLE_UXML_SERIALIZED_DATA [UxmlElement] #endif internal partial class GridView : BindableElement, ISerializationCallbackReceiver { #if ENABLE_RUNTIME_DATA_BINDINGS internal static readonly BindingId itemHeightProperty = new BindingId(nameof(itemHeight)); internal static readonly BindingId itemSquareProperty = new BindingId(nameof(itemSquare)); internal static readonly BindingId selectionTypeProperty = new BindingId(nameof(selectionType)); internal static readonly BindingId allowNoSelectionProperty = new BindingId(nameof(allowNoSelection)); #endif /// /// Available Operations. /// [Flags] public enum GridOperations { /// /// No operation. /// None = 0, /// /// Select all items. /// SelectAll = 1 << 0, /// /// Cancel selection. /// Cancel = 1 << 1, /// /// Move selection cursor left. /// Left = 1 << 2, /// /// Move selection cursor right. /// Right = 1 << 3, /// /// Move selection cursor up. /// Up = 1 << 4, /// /// Move selection cursor down. /// Down = 1 << 5, /// /// Move selection cursor to the beginning of the list. /// Begin = 1 << 6, /// /// Move selection cursor to the end of the list. /// End = 1 << 7, /// /// Choose selected items. /// Choose = 1 << 8, } const float k_PageSizeFactor = 0.25f; const int k_ExtraVisibleRows = 2; /// /// The USS class name for GridView elements. /// /// /// Unity adds this USS class to every instance of the GridView element. Any styling applied to /// this class affects every GridView located beside, or below the stylesheet in the visual tree. /// const string k_UssClassName = "unity2d-grid-view"; /// /// The USS class name for GridView elements with a border. /// /// /// Unity adds this USS class to an instance of the GridView element if the instance's /// property is set to true. Any styling applied to this class /// affects every such GridView located beside, or below the stylesheet in the visual tree. /// const string k_BorderUssClassName = k_UssClassName + "--with-border"; /// /// The USS class name of item elements in GridView elements. /// /// /// Unity adds this USS class to every item element the GridView contains. Any styling applied to /// this class affects every item element located beside, or below the stylesheet in the visual tree. /// const string k_ItemUssClassName = k_UssClassName + "__item"; /// /// The first column USS class name for GridView elements. /// static readonly string k_FirstColumnUssClassName = k_UssClassName + "__first-column"; /// /// The last column USS class name for GridView elements. /// static readonly string k_LastColumnUssClassName = k_UssClassName + "__last-column"; /// /// The USS class name of selected item elements in the GridView. /// /// /// Unity adds this USS class to every selected element in the GridView. The /// property decides if zero, one, or more elements can be selected. Any styling applied to /// this class affects every GridView item located beside, or below the stylesheet in the visual tree. /// internal const string itemSelectedVariantUssClassName = k_ItemUssClassName + "--selected"; /// /// The USS class name of rows in the GridView. /// const string k_RowUssClassName = k_UssClassName + "__row"; internal const int defaultItemHeight = 30; const float k_DpiScaling = 1f; const bool k_DefaultPreventScrollWithModifiers = true; static CustomStyleProperty s_ItemHeightProperty = new CustomStyleProperty("--unity-item-height"); /// /// The used by the GridView. /// public ScrollView scrollView { get; } Func m_MakeItem; Action m_BindItem; Func m_GetItemId; readonly List m_SelectedIds = new List(); readonly List m_SelectedIndices = new List(); readonly List m_SelectedItems = new List(); readonly List m_PreviouslySelectedIndices = new List(); readonly List m_OriginalSelection = new List(); float m_OriginalScrollOffset; int m_SoftSelectIndex = -1; int m_ColumnCount = 1; int m_FirstVisibleIndex; int m_ItemHeight = defaultItemHeight; float m_LastHeight; bool m_ItemHeightIsInline; bool m_ItemSquare; IList m_ItemsSource; int m_RangeSelectionOrigin = -1; bool m_IsRangeSelectionDirectionUp; List m_RowPool = new List(); // We keep this list in order to minimize temporary gc allocations. List m_ScrollInsertionList = new List(); // Persisted. float m_ScrollOffset; SelectionType m_SelectionType; bool m_AllowNoSelection = true; int m_VisibleRowCount; bool m_IsList; NavigationMoveEvent m_NavigationMoveAdapter; NavigationCancelEvent m_NavigationCancelAdapter; bool m_HasPointerMoved; bool m_SoftSelectIndexWasPreviouslySelected; /// /// Creates a with all default properties. The , /// , and properties /// must all be set for the GridView to function properly. /// public GridView() { AddToClassList(k_UssClassName); styleSheets.Add(ResourceLoader.Load("SpriteLibraryEditor/GridView.uss")); selectionType = SelectionType.Single; m_ScrollOffset = 0.0f; scrollView = new ScrollView { viewDataKey = "grid-view__scroll-view", horizontalScrollerVisibility = ScrollerVisibility.Hidden }; scrollView.StretchToParentSize(); scrollView.verticalScroller.valueChanged += OnScroll; dragger = new Dragger(OnDraggerStarted, OnDraggerMoved, OnDraggerEnded, OnDraggerCanceled); RegisterCallback(OnSizeChanged); RegisterCallback(OnCustomStyleResolved); RegisterCallback(OnAttachToPanel); RegisterCallback(OnDetachFromPanel); hierarchy.Add(scrollView); scrollView.contentContainer.usageHints &= ~UsageHints.GroupTransform; // Scroll views with virtualized content shouldn't have the "view transform" optimization focusable = true; dragger.acceptStartDrag = DefaultAcceptStartDrag; } /// /// Constructs a , with all required properties provided. /// /// The list of items to use as a data source. /// The factory method to call to create a display item. The method should return a /// VisualElement that can be bound to a data item. /// The method to call to bind a data item to a display item. The method /// receives as parameters the display item to bind, and the index of the data item to bind it to. public GridView(IList itemsSource, Func makeItem, Action bindItem) : this() { m_ItemsSource = itemsSource; m_ItemHeightIsInline = true; m_MakeItem = makeItem; m_BindItem = bindItem; operationMask = ~GridOperations.None; } bool Apply(GridOperations operation, bool shiftKey) { if ((operation & operationMask) == 0) return false; void HandleSelectionAndScroll(int index) { if (selectionType == SelectionType.Multiple && shiftKey && m_SelectedIndices.Count != 0) DoRangeSelection(index, true, true); else selectedIndex = index; ScrollToItem(index); } switch (operation) { case GridOperations.None: break; case GridOperations.SelectAll: SelectAll(); return true; case GridOperations.Cancel: ClearSelection(); return true; case GridOperations.Left: { var newIndex = Mathf.Max(selectedIndex - 1, 0); if (newIndex != selectedIndex) { HandleSelectionAndScroll(newIndex); return true; } } break; case GridOperations.Right: { var newIndex = Mathf.Min(selectedIndex + 1, itemsSource.Count - 1); if (newIndex != selectedIndex) { HandleSelectionAndScroll(newIndex); return true; } } break; case GridOperations.Up: { var newIndex = Mathf.Max(selectedIndex - columnCount, 0); if (newIndex != selectedIndex) { HandleSelectionAndScroll(newIndex); return true; } } break; case GridOperations.Down: { var newIndex = Mathf.Min(selectedIndex + columnCount, itemsSource.Count - 1); if (newIndex != selectedIndex) { HandleSelectionAndScroll(newIndex); return true; } } break; case GridOperations.Begin: HandleSelectionAndScroll(0); return true; case GridOperations.End: HandleSelectionAndScroll(itemsSource.Count - 1); return true; case GridOperations.Choose: if (m_SelectedIndices.Count > 0) itemsChosen?.Invoke(selectedItems); return true; default: throw new ArgumentOutOfRangeException(nameof(operation), operation, null); } return false; } void Apply(GridOperations operation, EventBase sourceEvent) { if ((operation & operationMask) != 0 && Apply(operation, (sourceEvent as IKeyboardEvent)?.shiftKey ?? false)) { sourceEvent?.StopPropagation(); } } /// /// Internal use only. /// /// internal void Apply(GridOperations operation) => Apply(operation, null); void OnDraggerStarted(PointerMoveEvent evt) { dragStarted?.Invoke(evt); } void OnDraggerMoved(PointerMoveEvent evt) { dragUpdated?.Invoke(evt); } void OnDraggerEnded(PointerUpEvent evt) { dragFinished?.Invoke(evt); CancelSoftSelect(); } void OnDraggerCanceled() { dragCanceled?.Invoke(); CancelSoftSelect(); } /// /// Cancel drag operation. /// public void CancelDrag() { dragger?.Cancel(); } void OnKeyDown(KeyDownEvent evt) { var operation = evt.keyCode switch { KeyCode.A when evt.actionKey => GridOperations.SelectAll, KeyCode.Escape => GridOperations.Cancel, KeyCode.Home => GridOperations.Begin, KeyCode.End => GridOperations.End, KeyCode.UpArrow => GridOperations.Up, KeyCode.DownArrow => GridOperations.Down, KeyCode.LeftArrow => GridOperations.Left, KeyCode.RightArrow => GridOperations.Right, KeyCode.KeypadEnter or KeyCode.Return => GridOperations.Choose, _ => GridOperations.None }; Apply(operation, evt); } static void OnNavigationMove(NavigationMoveEvent evt) => evt.StopPropagation(); static void OnNavigationCancel(NavigationCancelEvent evt) => evt.StopPropagation(); /// /// Callback for binding a data item to the visual element. /// /// /// The method called by this callback receives the VisualElement to bind, and the index of the /// element to bind it to. /// public Action bindItem { get => m_BindItem; set { m_BindItem = value; Refresh(); } } /// /// The number of columns for this grid. /// public int columnCount { get => m_ColumnCount; set { if (m_ColumnCount != value && value > 0) { m_ScrollOffset = 0; m_ColumnCount = value; Refresh(); } } } /// /// The manipulator used by this . /// public Dragger dragger { get; } /// /// A mask describing available operations in this when the user interacts with it. /// public GridOperations operationMask { get; set; } = GridOperations.Choose | GridOperations.SelectAll | GridOperations.Cancel | GridOperations.Begin | GridOperations.End | GridOperations.Left | GridOperations.Right | GridOperations.Up | GridOperations.Down; /// /// Returns the content container for the . Because the GridView control automatically manages /// its content, this always returns null. /// public override VisualElement contentContainer => null; /// /// The height of a single item in the list, in pixels. /// /// /// GridView requires that all visual elements have the same height so that it can calculate the /// scroller size. /// /// This property must be set for the list view to function. /// #if ENABLE_RUNTIME_DATA_BINDINGS [CreateProperty] #endif #if ENABLE_UXML_SERIALIZED_DATA [UxmlAttribute] #endif public int itemHeight { get => m_ItemHeight; set { if (m_ItemHeight != value && value > 0) { m_ItemHeightIsInline = true; m_ItemHeight = value; scrollView.verticalPageSize = m_ItemHeight * k_PageSizeFactor; Refresh(); #if ENABLE_RUNTIME_DATA_BINDINGS NotifyPropertyChanged(in itemHeightProperty); #endif } } } /// /// Decides whether the item should have 1:1 aspect ratio. /// #if ENABLE_RUNTIME_DATA_BINDINGS [CreateProperty] #endif #if ENABLE_UXML_SERIALIZED_DATA [UxmlAttribute] #endif public bool itemSquare { get => m_ItemSquare; set { if (m_ItemSquare != value) { m_ItemSquare = value; Refresh(); #if ENABLE_RUNTIME_DATA_BINDINGS NotifyPropertyChanged(in itemSquareProperty); #endif } } } /// /// The width of the item. /// public float itemWidth => scrollView.contentViewport.layout.width / columnCount; /// /// The data source for list items. /// /// /// This list contains the items that the displays. /// This property must be set for the list view to function. /// public IList itemsSource { get => m_ItemsSource; set { if (m_ItemsSource is INotifyCollectionChanged oldCollection) { oldCollection.CollectionChanged -= OnItemsSourceCollectionChanged; } m_ItemsSource = value; if (m_ItemsSource is INotifyCollectionChanged newCollection) { newCollection.CollectionChanged += OnItemsSourceCollectionChanged; } Refresh(); } } /// /// Callback for constructing the VisualElement that is the template for each recycled and re-bound element in the list. /// /// /// This callback needs to call a function that constructs a blank that is /// bound to an element from the list. /// /// The GridView automatically creates enough elements to fill the visible area, and adds more if the area /// is expanded. As the user scrolls, the GridView cycles elements in and out as they appear or disappear. /// /// This property must be set for the list view to function. /// public Func makeItem { get => m_MakeItem; set { if (m_MakeItem == value) return; m_MakeItem = value; Refresh(); } } /// /// The computed pixel-aligned height for the list elements. /// /// /// This value changes depending on the current panel's DPI scaling. /// /// public float resolvedItemHeight => Mathf.Round(itemHeight * k_DpiScaling) / k_DpiScaling; /// /// The computed pixel-aligned width for the list elements. /// /// /// This value changes depending on the current panel's DPI scaling. /// /// public float resolvedItemWidth => Mathf.Round(itemWidth * k_DpiScaling) / k_DpiScaling; /// /// Returns or sets the selected item's index in the data source. If multiple items are selected, returns the /// first selected item's index. If multiple items are provided, sets them all as selected. /// public int selectedIndex { get => m_SelectedIndices.Count == 0 ? -1 : m_SelectedIndices[0]; set => SetSelection(value); } /// /// Returns the indices of selected items in the data source. Always returns an enumerable, even if no item is selected, or a /// single item is selected. /// public IEnumerable selectedIndices => m_SelectedIndices; /// /// Returns the selected item from the data source. If multiple items are selected, returns the first selected item. /// public object selectedItem => m_SelectedItems.Count == 0 ? null : m_SelectedItems[0]; /// /// Returns the selected items from the data source. Always returns an enumerable, even if no item is selected, or a single /// item is selected. /// public IEnumerable selectedItems => m_SelectedItems; /// /// Returns the IDs of selected items in the data source. Always returns an enumerable, even if no item is selected, or a /// single item is selected. /// public IEnumerable selectedIds => m_SelectedIds; /// /// Controls the selection type. /// /// /// You can set the GridView to make one item selectable at a time, make multiple items selectable, or disable selections completely. /// /// When you set the GridView to disable selections, any current selection is cleared. /// #if ENABLE_RUNTIME_DATA_BINDINGS [CreateProperty] #endif #if ENABLE_UXML_SERIALIZED_DATA [UxmlAttribute] #endif public SelectionType selectionType { get { return m_SelectionType; } set { var changed = m_SelectionType != value; m_SelectionType = value; if (m_SelectionType == SelectionType.None) { ClearSelectionWithoutValidation(); } else { if (allowNoSelection) ClearSelectionWithoutValidation(); else if (m_ItemsSource.Count > 0) SetSelectionInternal(new[] { 0 }, false, false); else ClearSelectionWithoutValidation(); } m_RangeSelectionOrigin = -1; PostSelection(updatePreviousSelection: true, sendNotification: true); #if ENABLE_RUNTIME_DATA_BINDINGS if (changed) NotifyPropertyChanged(in selectionTypeProperty); #endif } } /// /// Whether the GridView allows to have no selection when the selection type is or . /// #if ENABLE_RUNTIME_DATA_BINDINGS [CreateProperty] #endif #if ENABLE_UXML_SERIALIZED_DATA [UxmlAttribute] #endif public bool allowNoSelection { get => m_AllowNoSelection; set { var changed = m_AllowNoSelection != value; m_AllowNoSelection = value; if (HasValidDataAndBindings() && !m_AllowNoSelection && m_SelectedIndices.Count == 0 && m_ItemsSource.Count > 0) SetSelectionInternal(new[] { 0 }, true, true); #if ENABLE_RUNTIME_DATA_BINDINGS if (changed) NotifyPropertyChanged(in allowNoSelectionProperty); #endif } } /// /// Returns true if the soft-selection is in progress. /// public bool isSelecting => m_SoftSelectIndex != -1; /// /// Enable this property to display a border around the GridView. /// /// /// If set to true, a border appears around the ScrollView. /// public bool showBorder { get => ClassListContains(k_BorderUssClassName); set => EnableInClassList(k_BorderUssClassName, value); } /// /// Prevents the grid view from scrolling when the user presses a modifier key at the same time as scrolling. /// public bool preventScrollWithModifiers { get; set; } = k_DefaultPreventScrollWithModifiers; /// /// Callback for unbinding a data item from the VisualElement. /// /// /// The method called by this callback receives the VisualElement to unbind, and the index of the /// element to unbind it from. /// public Action unbindItem { get; set; } /// /// Callback for getting the ID of an item. /// /// /// The method called by this callback receives the index of the item to get the ID from. /// public Func getItemId { get => m_GetItemId; set { m_GetItemId = value; Refresh(); } } bool DefaultAcceptStartDrag(Vector2 worldPosition) { if (!HasValidDataAndBindings()) return false; var idx = GetIndexByWorldPosition(worldPosition); return idx >= 0 && idx < itemsSource.Count; } internal List rowPool => m_RowPool; void ISerializationCallbackReceiver.OnAfterDeserialize() { Refresh(); } void ISerializationCallbackReceiver.OnBeforeSerialize() { } /// /// Callback triggered when the user acts on a selection of one or more items, for example by double-clicking or pressing Enter. /// /// /// This callback receives an enumerable that contains the item or items chosen. /// public event Action> itemsChosen; /// /// Callback triggered when the selection changes. /// /// /// This callback receives an enumerable that contains the item or items selected. /// public event Action> selectionChanged; /// /// Callback triggered when the selection changes. /// /// /// This callback receives an enumerable that contains the indices of selected items. /// public event Action> selectedIndicesChanged; /// /// Callback triggered when the user right-clicks on an item. /// /// /// This callback receives an enumerable that contains the item or items selected. /// public event Action contextClicked; /// /// Callback triggered when the user double-clicks on an item. /// public event Action doubleClicked; /// /// Callback triggered when drag has started. /// public event Action dragStarted; /// /// Callback triggered when items are dragged. /// public event Action dragUpdated; /// /// Callback triggered when drag has finished. /// public event Action dragFinished; /// /// Callback triggered when drag has been canceled. /// public event Action dragCanceled; /// /// Adds an item to the collection of selected items. /// /// Item index. public void AddToSelection(int index) { AddToSelection(new[] { index }, true, true); } internal void AddToSelection(int index, bool updatePrevious, bool notify) { AddToSelection(new[] { index }, updatePrevious, notify); } /// /// Deselects any selected items. /// public void ClearSelection() { if (m_SelectedIndices.Count == 0 || !allowNoSelection) return; ClearSelectionWithoutValidation(); PostSelection(true, true); } /// /// Clear the selection without triggering selection changed event. /// public void ClearSelectionWithoutNotify() { if (m_SelectedIndices.Count == 0 || !allowNoSelection) return; ClearSelectionWithoutValidation(); m_RangeSelectionOrigin = -1; m_PreviouslySelectedIndices.Clear(); } /// /// Clears the GridView, recreates all visible visual elements, and rebinds all items. /// /// /// Call this method whenever the data source changes. /// public void Refresh() { foreach (var recycledRow in m_RowPool) { recycledRow.Clear(); } m_RowPool.Clear(); scrollView.Clear(); m_VisibleRowCount = 0; m_SelectedIndices.Clear(); m_SelectedItems.Clear(); m_SoftSelectIndex = -1; m_SoftSelectIndexWasPreviouslySelected = false; m_OriginalSelection.Clear(); var newSelectedIds = new List(); // O(n) if (m_SelectedIds.Count > 0) { // Add selected objects to working lists. for (var index = 0; index < m_ItemsSource.Count; ++index) { var id = GetIdFromIndex(index); if (!m_SelectedIds.Contains(id)) continue; m_SelectedIndices.Add(index); m_SelectedItems.Add(m_ItemsSource[index]); newSelectedIds.Add(id); } } m_SelectedIds.Clear(); m_SelectedIds.AddRange(newSelectedIds); if (!HasValidDataAndBindings()) return; m_LastHeight = scrollView.layout.height; if (float.IsNaN(m_LastHeight)) return; m_FirstVisibleIndex = Math.Min((int)(m_ScrollOffset / resolvedItemHeight) * columnCount, m_ItemsSource.Count - 1); ResizeHeight(m_LastHeight); if (!allowNoSelection && m_SelectedIds.Count == 0) { if (m_ItemsSource.Count > 0) SetSelectionInternal(new[] { m_FirstVisibleIndex >= 0 ? m_FirstVisibleIndex : 0 }, true, true); } else { PostSelection(true, true); } } /// /// Rebinds a single item if it is currently visible in the collection view. /// /// The item index. internal void RefreshItem(int index) { foreach (var recycledRow in m_RowPool) { if (recycledRow.ContainsIndex(index, out var indexInRow)) { var item = makeItem != null && index < itemsSource.Count ? makeItem.Invoke() : CreateDummyItemElement(); SetupItemElement(item); recycledRow.RemoveAt(indexInRow); recycledRow.Insert(indexInRow, item); bindItem.Invoke(item, recycledRow.indices[indexInRow]); recycledRow.SetSelected(indexInRow, m_SelectedIds.Contains(recycledRow.ids[indexInRow])); break; } } } /// /// Removes an item from the collection of selected items. /// /// The item index. public void RemoveFromSelection(int index) { RemoveFromSelectionInternal(index, true, true); } internal void RemoveFromSelectionInternal(int index, bool updatePrevious, bool notify) { if (!HasValidDataAndBindings()) return; if (m_SelectedIndices.Count == 1 && m_SelectedIndices[0] == index && !allowNoSelection) return; RemoveFromSelectionWithoutValidation(index); PostSelection(updatePrevious, notify); } /// /// Scrolls to a specific item index and makes it visible. /// /// Item index to scroll to. Specify -1 to make the last item visible. public void ScrollToItem(int index) { if (!HasValidDataAndBindings()) return; if (m_VisibleRowCount == 0 || index < -1) return; var pixelAlignedItemHeight = resolvedItemHeight; var lastRowIndex = Mathf.FloorToInt((itemsSource.Count - 1) / (float)columnCount); var maxOffset = Mathf.Max(0, lastRowIndex * pixelAlignedItemHeight - m_LastHeight + pixelAlignedItemHeight); var targetRowIndex = Mathf.FloorToInt(index / (float)columnCount); var targetOffset = targetRowIndex * pixelAlignedItemHeight; var currentOffset = scrollView.scrollOffset.y; var d = targetOffset - currentOffset; if (index == -1) { scrollView.scrollOffset = Vector2.up * maxOffset; } else if (d < 0) { scrollView.scrollOffset = Vector2.up * targetOffset; } else if (d > m_LastHeight - pixelAlignedItemHeight) { // need to scroll up so the item should be visible in last row targetOffset += pixelAlignedItemHeight - m_LastHeight; scrollView.scrollOffset = Vector2.up * Mathf.Min(maxOffset, targetOffset); } // else do nothing because the item is already entirely visible schedule.Execute(() => ResizeHeight(m_LastHeight)).ExecuteLater(2L); } /// /// Sets the currently selected item. /// /// The item index. public void SetSelection(int index) { if (index < 0 || itemsSource == null || index >= itemsSource.Count) { ClearSelection(); return; } SetSelection(new[] { index }); } /// /// Sets a collection of selected items. /// /// The collection of the indices of the items to be selected. public void SetSelection(IEnumerable indices) { SetSelectionInternal(indices, true, true); } /// /// Sets a collection of selected items without triggering a selection change callback. /// /// The collection of items to be selected. public void SetSelectionWithoutNotify(IEnumerable indices) { indices ??= Array.Empty(); switch (selectionType) { case SelectionType.None: return; case SelectionType.Single: var lastIndex = -1; var count = 0; foreach (var index in indices) { lastIndex = index; count++; } if (count > 1) indices = new[] { lastIndex }; break; case SelectionType.Multiple: break; default: throw new ArgumentOutOfRangeException(); } SetSelectionInternal(indices, true, false); } internal void AddToSelection(IList indexes, bool updatePrevious, bool notify) { if (!HasValidDataAndBindings() || indexes == null || indexes.Count == 0) return; foreach (var index in indexes) AddToSelectionWithoutValidation(index); PostSelection(updatePrevious, notify); } internal void SelectAll() { if (selectionType != SelectionType.Multiple) { return; } for (var index = 0; index < itemsSource.Count; index++) { var id = GetIdFromIndex(index); var item = m_ItemsSource[index]; foreach (var recycledRow in m_RowPool) { if (recycledRow.ContainsId(id, out var indexInRow)) recycledRow.SetSelected(indexInRow, true); } if (!m_SelectedIds.Contains(id)) { m_SelectedIds.Add(id); m_SelectedIndices.Add(index); m_SelectedItems.Add(item); } } PostSelection(true, true); } internal void SetSelectionInternal(IEnumerable indices, bool updatePrevious, bool sendNotification) { indices ??= Array.Empty(); switch (selectionType) { case SelectionType.None: return; case SelectionType.Single: var lastIndex = -1; var count = 0; foreach (var index in indices) { lastIndex = index; count++; } if (count > 1) indices = new[] { lastIndex }; break; case SelectionType.Multiple: break; default: throw new ArgumentOutOfRangeException(); } if (!allowNoSelection) { // Check if empty. using var enumerator = indices.GetEnumerator(); if (!enumerator.MoveNext()) return; } ClearSelectionWithoutValidation(); foreach (var index in indices) AddToSelectionWithoutValidation(index); PostSelection(updatePrevious, sendNotification); } void AddToSelectionWithoutValidation(int index) { if (m_ItemsSource == null || index < 0 || index >= m_ItemsSource.Count || m_SelectedIndices.Contains(index)) return; var id = GetIdFromIndex(index); var item = m_ItemsSource[index]; foreach (var recycledRow in m_RowPool) { if (recycledRow.ContainsId(id, out var indexInRow)) recycledRow.SetSelected(indexInRow, true); } m_SelectedIds.Add(id); m_SelectedIndices.Add(index); m_SelectedItems.Add(item); } internal VisualElement GetVisualElement(int index) { if (index < 0 || index >= m_ItemsSource.Count || !m_SelectedIndices.Contains(index)) return null; return GetVisualElementInternal(index); } internal VisualElement GetVisualElementWithoutSelection(int index) { if (index < 0 || index >= m_ItemsSource.Count) return null; return GetVisualElementInternal(index); } VisualElement GetVisualElementInternal(int index) { var id = GetIdFromIndex(index); foreach (var recycledRow in m_RowPool) { if (recycledRow.ContainsId(id, out var indexInRow)) return recycledRow.ElementAt(indexInRow); } return null; } void ClearSelectionWithoutValidation() { foreach (var recycledRow in m_RowPool) recycledRow.ClearSelection(); m_SelectedIds.Clear(); m_SelectedIndices.Clear(); m_SelectedItems.Clear(); } VisualElement CreateDummyItemElement() { var item = new VisualElement { pickingMode = PickingMode.Ignore }; SetupItemElement(item); return item; } void DoRangeSelection(int rangeSelectionFinalIndex, bool updatePrevious, bool notify) { var max = -1; var min = -1; foreach (var i in m_SelectedIndices) { if (max == -1 || i > max) max = i; if (min == -1 || i < min) min = i; } m_RangeSelectionOrigin = m_IsRangeSelectionDirectionUp ? max : min; ClearSelectionWithoutValidation(); // Add range var range = new List(); m_IsRangeSelectionDirectionUp = rangeSelectionFinalIndex < m_RangeSelectionOrigin; if (m_IsRangeSelectionDirectionUp) { for (var i = rangeSelectionFinalIndex; i <= m_RangeSelectionOrigin; i++) range.Add(i); } else { for (var i = rangeSelectionFinalIndex; i >= m_RangeSelectionOrigin; i--) range.Add(i); } AddToSelection(range, updatePrevious, notify); } void DoContextClickAfterSelect(PointerDownEvent evt) { contextClicked?.Invoke(evt); } void DoSoftSelect(PointerDownEvent evt, int clickCount) { var clickedIndex = GetIndexByWorldPosition(evt.position); if (clickedIndex > m_ItemsSource.Count - 1 || clickedIndex < 0) { if (evt.button == (int)MouseButton.LeftMouse && allowNoSelection) ClearSelection(); return; } m_SoftSelectIndex = clickedIndex; m_SoftSelectIndexWasPreviouslySelected = m_SelectedIndices.Contains(clickedIndex); if (clickCount == 1) { if (selectionType == SelectionType.None) return; if (selectionType == SelectionType.Multiple && evt.actionKey) { m_RangeSelectionOrigin = clickedIndex; // Add/remove single clicked element var clickedItemId = GetIdFromIndex(clickedIndex); if (m_SelectedIds.Contains(clickedItemId)) RemoveFromSelectionInternal(clickedIndex, false, false); else AddToSelection(clickedIndex, false, false); } else if (selectionType == SelectionType.Multiple && evt.shiftKey) { if (m_RangeSelectionOrigin == -1 || m_SelectedIndices.Count == 0) { m_RangeSelectionOrigin = clickedIndex; SetSelectionInternal(new[] { clickedIndex }, false, false); } else { DoRangeSelection(clickedIndex, false, false); } } else if (selectionType == SelectionType.Multiple && m_SoftSelectIndexWasPreviouslySelected) { // Do noting, selection will be processed OnPointerUp. // If drag and drop will be started GridViewDragger will capture the mouse and GridView will not receive the mouse up event. } else // single { m_RangeSelectionOrigin = clickedIndex; if (!(m_SelectedIndices.Count == 1 && m_SelectedIndices[0] == clickedIndex)) { SetSelectionInternal(new[] { clickedIndex }, false, false); } } } ScrollToItem(clickedIndex); } int GetIdFromIndex(int index) { if (m_GetItemId == null) return index; return m_GetItemId(index); } bool HasValidDataAndBindings() { return itemsSource != null && makeItem != null && bindItem != null; } void PostSelection(bool updatePreviousSelection, bool sendNotification) { if (AreSequencesEqual(m_PreviouslySelectedIndices, m_SelectedIndices)) return; if (updatePreviousSelection) { m_PreviouslySelectedIndices.Clear(); m_PreviouslySelectedIndices.AddRange(m_SelectedIndices); } if (sendNotification) { selectionChanged?.Invoke(m_SelectedItems); selectedIndicesChanged?.Invoke(m_SelectedIndices); } } void OnAttachToPanel(AttachToPanelEvent evt) { if (evt.destinationPanel == null) return; if (!UnityEngine.Device.Application.isMobilePlatform) scrollView.AddManipulator(dragger); scrollView.RegisterCallback(OnClick); scrollView.RegisterCallback(OnPointerDown); scrollView.RegisterCallback(OnPointerUp); scrollView.RegisterCallback(OnPointerMove); RegisterCallback(OnKeyDown); RegisterCallback(OnNavigationMove); RegisterCallback(OnNavigationCancel); scrollView.RegisterCallback(OnWheel, TrickleDown.TrickleDown); } void OnCustomStyleResolved(CustomStyleResolvedEvent e) { if (!m_ItemHeightIsInline && e.customStyle.TryGetValue(s_ItemHeightProperty, out var height)) { if (m_ItemHeight != height) { m_ItemHeight = height; Refresh(); } } } void OnDetachFromPanel(DetachFromPanelEvent evt) { if (evt.originPanel == null) return; scrollView.RemoveManipulator(dragger); scrollView.UnregisterCallback(OnClick); scrollView.UnregisterCallback(OnPointerDown); scrollView.UnregisterCallback(OnPointerUp); scrollView.UnregisterCallback(OnPointerMove); UnregisterCallback(OnKeyDown); UnregisterCallback(OnNavigationMove); UnregisterCallback(OnNavigationCancel); scrollView.UnregisterCallback(OnWheel, TrickleDown.TrickleDown); } void OnWheel(WheelEvent evt) { if (preventScrollWithModifiers && evt.modifiers != EventModifiers.None) evt.StopImmediatePropagation(); } void OnClick(ClickEvent evt) { if (!HasValidDataAndBindings()) return; if (evt.clickCount == 2) { var clickedIndex = GetIndexByWorldPosition(evt.position); if (clickedIndex >= 0 && clickedIndex < m_ItemsSource.Count) { doubleClicked?.Invoke(clickedIndex); Apply(GridOperations.Choose, evt.shiftKey); } } } void OnPointerMove(PointerMoveEvent evt) { m_HasPointerMoved = true; } void OnPointerDown(PointerDownEvent evt) { evt.StopImmediatePropagation(); if (!HasValidDataAndBindings()) return; if (!evt.isPrimary) return; var capturingElement = panel?.GetCapturingElement(evt.pointerId); // if the pointer is captured by a child of the scroll view, abort any selection if (capturingElement is VisualElement ve && ve != scrollView && ve.FindCommonAncestor(scrollView) != null) return; m_OriginalSelection.Clear(); m_OriginalSelection.AddRange(m_SelectedIndices); m_OriginalScrollOffset = m_ScrollOffset; m_SoftSelectIndex = -1; var clickCount = m_HasPointerMoved ? 1 : evt.clickCount; m_HasPointerMoved = false; DoSoftSelect(evt, clickCount); if (evt.button == (int)MouseButton.RightMouse) DoContextClickAfterSelect(evt); } void OnPointerUp(PointerUpEvent evt) { if (!HasValidDataAndBindings()) return; if (!evt.isPrimary) return; if (m_SoftSelectIndex == -1) return; var index = m_SoftSelectIndex; m_SoftSelectIndex = -1; if (m_SoftSelectIndexWasPreviouslySelected && evt.button == (int)MouseButton.LeftMouse && evt.modifiers == EventModifiers.None) { ProcessSingleClick(index); return; } PostSelection(true, true); } void CancelSoftSelect() { if (m_SoftSelectIndex != -1) { SetSelectionInternal(m_OriginalSelection, false, false); scrollView.verticalScroller.value = m_OriginalScrollOffset; } m_SoftSelectIndex = -1; } /// /// Returns the index of the item at the given position. /// /// /// The position is relative to the top left corner of the grid. No check is made to see if the index is valid. /// /// The position of the item in the world-space. /// The index of the item at the given position. public int GetIndexByWorldPosition(Vector2 worldPosition) { var localPosition = scrollView.contentContainer.WorldToLocal(worldPosition); return Mathf.FloorToInt(localPosition.y / resolvedItemHeight) * columnCount + Mathf.FloorToInt(localPosition.x / resolvedItemWidth); } internal VisualElement GetElementAt(int index) { foreach (var row in m_RowPool) { if (row.ContainsId(index, out var indexInRow)) return row[indexInRow]; } return null; } void OnItemsSourceCollectionChanged(object sender, NotifyCollectionChangedEventArgs args) { Refresh(); } void OnScroll(float offset) { if (!HasValidDataAndBindings()) return; m_ScrollOffset = offset; var pixelAlignedItemHeight = resolvedItemHeight; var firstVisibleIndex = Mathf.FloorToInt(offset / pixelAlignedItemHeight) * columnCount; scrollView.contentContainer.style.paddingTop = Mathf.FloorToInt(firstVisibleIndex / (float)columnCount) * pixelAlignedItemHeight; scrollView.contentContainer.style.height = (Mathf.CeilToInt(itemsSource.Count / (float)columnCount) * pixelAlignedItemHeight); if (firstVisibleIndex != m_FirstVisibleIndex) { m_FirstVisibleIndex = firstVisibleIndex; if (m_RowPool.Count > 0) { // we try to avoid rebinding a few items if (m_FirstVisibleIndex < m_RowPool[0].firstIndex) //we're scrolling up { //How many do we have to swap back var count = m_RowPool[0].firstIndex - m_FirstVisibleIndex; var inserting = m_ScrollInsertionList; for (var i = 0; i < count && m_RowPool.Count > 0; ++i) { var last = m_RowPool[^1]; inserting.Add(last); m_RowPool.RemoveAt(m_RowPool.Count - 1); //we remove from the end last.SendToBack(); //We send the element to the top of the list (back in z-order) } inserting.Reverse(); m_ScrollInsertionList = m_RowPool; m_RowPool = inserting; m_RowPool.AddRange(m_ScrollInsertionList); m_ScrollInsertionList.Clear(); } else if (m_FirstVisibleIndex > m_RowPool[0].firstIndex) //down { var inserting = m_ScrollInsertionList; var checkIndex = 0; while (checkIndex < m_RowPool.Count && m_FirstVisibleIndex > m_RowPool[checkIndex].firstIndex) { var first = m_RowPool[checkIndex]; inserting.Add(first); first.BringToFront(); //We send the element to the bottom of the list (front in z-order) checkIndex++; } m_RowPool.RemoveRange(0, checkIndex); //we remove them all at once m_RowPool.AddRange(inserting); // add them back to the end inserting.Clear(); } //Let's rebind everything for (var rowIndex = 0; rowIndex < m_RowPool.Count; rowIndex++) { for (var colIndex = 0; colIndex < columnCount; colIndex++) { var index = rowIndex * columnCount + colIndex + m_FirstVisibleIndex; var isFirstColumn = colIndex == 0; var isLastColumn = colIndex == columnCount - 1; if (index < itemsSource.Count) { var item = m_RowPool[rowIndex].ElementAt(colIndex); if (m_RowPool[rowIndex].indices[colIndex] == RecycledRow.kUndefinedIndex) { var newItem = makeItem != null ? makeItem.Invoke() : CreateDummyItemElement(); SetupItemElement(newItem); m_RowPool[rowIndex].RemoveAt(colIndex); m_RowPool[rowIndex].Insert(colIndex, newItem); item = newItem; } Setup(item, index); item.EnableInClassList(k_FirstColumnUssClassName, isFirstColumn); item.EnableInClassList(k_LastColumnUssClassName, isLastColumn); } else { var remainingOldItems = columnCount - colIndex; while (remainingOldItems > 0) { m_RowPool[rowIndex].RemoveAt(colIndex); m_RowPool[rowIndex].Insert(colIndex, CreateDummyItemElement()); m_RowPool[rowIndex][colIndex].EnableInClassList(k_FirstColumnUssClassName, isFirstColumn); m_RowPool[rowIndex][colIndex].EnableInClassList(k_LastColumnUssClassName, isLastColumn); m_RowPool[rowIndex].ids.RemoveAt(colIndex); m_RowPool[rowIndex].ids.Insert(colIndex, RecycledRow.kUndefinedIndex); m_RowPool[rowIndex].indices.RemoveAt(colIndex); m_RowPool[rowIndex].indices.Insert(colIndex, RecycledRow.kUndefinedIndex); remainingOldItems--; } } } } } } } void OnSizeChanged(GeometryChangedEvent evt) { if (!HasValidDataAndBindings()) return; if (Mathf.Approximately(evt.newRect.height, evt.oldRect.height)) return; ResizeHeight(evt.newRect.height); } void ProcessSingleClick(int clickedIndex) { m_RangeSelectionOrigin = clickedIndex; SetSelection(clickedIndex); } void RemoveFromSelectionWithoutValidation(int index) { if (!m_SelectedIndices.Contains(index)) return; var id = GetIdFromIndex(index); var item = m_ItemsSource[index]; foreach (var recycledRow in m_RowPool) { if (recycledRow.ContainsId(id, out var indexInRow)) recycledRow.SetSelected(indexInRow, false); } m_SelectedIds.Remove(id); m_SelectedIndices.Remove(index); m_SelectedItems.Remove(item); } void ResizeHeight(float height) { if (!HasValidDataAndBindings()) return; var pixelAlignedItemHeight = resolvedItemHeight; var rowCountForSource = Mathf.CeilToInt(itemsSource.Count / (float)columnCount); var contentHeight = rowCountForSource * pixelAlignedItemHeight; scrollView.contentContainer.style.height = contentHeight; var scrollableHeight = Mathf.Max(0, contentHeight - scrollView.contentViewport.layout.height); scrollView.verticalScroller.highValue = scrollableHeight; scrollView.verticalScroller.value = Mathf.Min(m_ScrollOffset, scrollView.verticalScroller.highValue); var rowCountForHeight = Mathf.FloorToInt(height / pixelAlignedItemHeight) + k_ExtraVisibleRows; var rowCount = Math.Min(rowCountForHeight, rowCountForSource); if (m_VisibleRowCount != rowCount) { if (m_VisibleRowCount > rowCount) { // Shrink var removeCount = m_VisibleRowCount - rowCount; for (var i = 0; i < removeCount; i++) { var lastIndex = m_RowPool.Count - 1; m_RowPool[lastIndex].Clear(); scrollView.Remove(m_RowPool[lastIndex]); m_RowPool.RemoveAt(lastIndex); } } else { // Grow var addCount = rowCount - m_VisibleRowCount; for (var i = 0; i < addCount; i++) { var recycledRow = new RecycledRow(resolvedItemHeight); for (var indexInRow = 0; indexInRow < columnCount; indexInRow++) { var index = m_RowPool.Count * columnCount + indexInRow + m_FirstVisibleIndex; var item = makeItem != null && index < itemsSource.Count ? makeItem.Invoke() : CreateDummyItemElement(); SetupItemElement(item); recycledRow.Add(item); if (index < itemsSource.Count) { Setup(item, index); } else { recycledRow.ids.Add(RecycledRow.kUndefinedIndex); recycledRow.indices.Add(RecycledRow.kUndefinedIndex); } var isFirstColumn = indexInRow == 0; var isLastColumn = indexInRow == columnCount - 1; item.EnableInClassList(k_FirstColumnUssClassName, isFirstColumn); item.EnableInClassList(k_LastColumnUssClassName, isLastColumn); } m_RowPool.Add(recycledRow); recycledRow.style.height = pixelAlignedItemHeight; scrollView.Add(recycledRow); } } m_VisibleRowCount = rowCount; } m_LastHeight = height; } void Setup(VisualElement item, int newIndex) { var newId = GetIdFromIndex(newIndex); if (!(item.parent is RecycledRow recycledRow)) throw new Exception("The item to setup can't be orphan"); var indexInRow = recycledRow.IndexOf(item); if (recycledRow.indices.Count <= indexInRow) { recycledRow.indices.Add(RecycledRow.kUndefinedIndex); recycledRow.ids.Add(RecycledRow.kUndefinedIndex); } if (recycledRow.indices[indexInRow] == newIndex) return; if (recycledRow.indices[indexInRow] != RecycledRow.kUndefinedIndex) unbindItem?.Invoke(item, recycledRow.indices[indexInRow]); recycledRow.indices[indexInRow] = newIndex; recycledRow.ids[indexInRow] = newId; bindItem.Invoke(item, recycledRow.indices[indexInRow]); recycledRow.SetSelected(indexInRow, m_SelectedIds.Contains(recycledRow.ids[indexInRow])); } static bool AreSequencesEqual(IList first, IList second) { if (first == null || second == null || first.Count != second.Count) return false; for (var i = 0; i < first.Count; i++) { if (!first[i].Equals(second[i])) return false; } return true; } void SetupItemElement(VisualElement item) { item.AddToClassList(k_ItemUssClassName); item.style.position = Position.Relative; if (itemSquare) { var itemSize = (float)itemHeight; item.style.height = item.style.width = itemSize; } else { item.style.flexBasis = 0; item.style.flexGrow = 1f; item.style.flexShrink = 1f; } } #if ENABLE_UXML_TRAITS /// /// Instantiates a using data from a UXML file. /// /// /// This class is added to every created from UXML. /// public new class UxmlFactory : UxmlFactory { } /// /// Defines for the . /// /// /// This class defines the GridView element properties that you can use in a UI document asset (UXML file). /// public new class UxmlTraits : BindableElement.UxmlTraits { readonly UxmlIntAttributeDescription m_ItemHeight = new UxmlIntAttributeDescription { name = "item-height", obsoleteNames = new[] { "itemHeight" }, defaultValue = defaultItemHeight }; readonly UxmlBoolAttributeDescription m_ItemSquare = new UxmlBoolAttributeDescription { name = "item-square", defaultValue = false }; readonly UxmlEnumAttributeDescription m_SelectionType = new UxmlEnumAttributeDescription { name = "selection-type", defaultValue = SelectionType.Single }; readonly UxmlBoolAttributeDescription m_ShowBorder = new UxmlBoolAttributeDescription { name = "show-border", defaultValue = false }; readonly UxmlBoolAttributeDescription m_PreventScrollWithModifiers = new UxmlBoolAttributeDescription { name = "prevent-scroll-with-modifiers", defaultValue = k_DefaultPreventScrollWithModifiers }; readonly UxmlBoolAttributeDescription m_AllowNoSelection = new UxmlBoolAttributeDescription { name = "allow-no-selection", defaultValue = true }; /// /// Returns an empty enumerable, because list views usually do not have child elements. /// /// An empty enumerable. public override IEnumerable uxmlChildElementsDescription { get { yield break; } } /// /// Initializes properties using values from the attribute bag. /// /// The object to initialize. /// The attribute bag. /// The creation context; unused. public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc) { base.Init(ve, bag, cc); var view = (GridView)ve; // Avoid setting itemHeight unless it's explicitly defined. // Setting itemHeight property will activate inline property mode. var itemHeight = 0; if (m_ItemHeight.TryGetValueFromBag(bag, cc, ref itemHeight)) view.itemHeight = itemHeight; view.itemSquare = m_ItemSquare.GetValueFromBag(bag, cc); view.showBorder = m_ShowBorder.GetValueFromBag(bag, cc); view.preventScrollWithModifiers = m_PreventScrollWithModifiers.GetValueFromBag(bag, cc); view.selectionType = m_SelectionType.GetValueFromBag(bag, cc); view.allowNoSelection = m_AllowNoSelection.GetValueFromBag(bag, cc); } } #endif internal class RecycledRow : VisualElement { public const int kUndefinedIndex = -1; public readonly List ids; public readonly List indices; public RecycledRow(float height) { AddToClassList(k_RowUssClassName); pickingMode = PickingMode.Ignore; style.height = height; indices = new List(); ids = new List(); } public int firstIndex => indices.Count > 0 ? indices[0] : kUndefinedIndex; public int lastIndex => indices.Count > 0 ? indices[^1] : kUndefinedIndex; public void ClearSelection() { for (var i = 0; i < childCount; i++) { SetSelected(i, false); } } public bool ContainsId(int id, out int indexInRow) { indexInRow = ids.IndexOf(id); return indexInRow >= 0; } public bool ContainsIndex(int index, out int indexInRow) { indexInRow = indices.IndexOf(index); return indexInRow >= 0; } public void SetSelected(int indexInRow, bool selected) { if (childCount > indexInRow && indexInRow >= 0) { if (selected) ElementAt(indexInRow).AddToClassList(itemSelectedVariantUssClassName); else ElementAt(indexInRow).RemoveFromClassList(itemSelectedVariantUssClassName); } } } } }