Skip to content

Instantly share code, notes, and snippets.

@Oblongmana
Last active April 8, 2022 02:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Oblongmana/b9b5ddd7ae43691fff7cc4adb269184e to your computer and use it in GitHub Desktop.
Save Oblongmana/b9b5ddd7ae43691fff7cc4adb269184e to your computer and use it in GitHub Desktop.
Unity VisualElement (UnityEnginer.UIElements) combining a TextInput and ListView into something resembling the common web pattern of a TextField with dropdown, filtering as you type, and updating the text field if you pick an option. Details in class. https://unlicense.org
#if UNITY_EDITOR
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Unity.EditorCoroutines.Editor;
using UnityEngine;
using UnityEngine.UIElements;
/// <summary>
/// <para>
/// Unity VisualElement for use with UIElements that combines a TextInput and ListView into something resembling the common
/// web pattern of a TextField that gives you dropdown suggestions, filtering them as you type, and then updates the text field
/// if you pick an option from the dropdown. Use RegisterCallback&lt; ChangeEvent&lt; string &gt; &gt; to get the TextInput contents.
/// </para>
/// <para>
/// The suggestions are NOT in a floating dropdown (a huge amount of UIElements stuff is locked away as internal code at time of
/// writing, so couldn't work out how to get that working in a reasonable time, vs something like this which works without jank).
/// </para>
/// <para>
/// Put together for a fairly specific purpose while trying to make something useful for looking up and choosing different tiles,
/// but if you find it useful, have at it! Modify it any way you like, mention me or don't - this is available under The Unlicense,
/// included at the end of the file.
/// </para>
/// <para>
/// Not integrated with the UXML/UI Builder system as I'm only wrangling things with C# at the moment.
/// Feel free to add support, leave a link to your gist if you do!
/// </para>
/// </summary>
/// <example>
/// <code>
/// TextSelectField stringListTextSelectField = new TextSelectField("Item:", new List&lt; string &gt;(){ "a", "b", "c" });
/// stringListTextSelectField.RegisterCallback&lt; ChangeEvent&lt; string &gt; &gt;( (e) => { Debug.Log($"TextSelectField's input now contains {e.newValue}"); });
/// stringListTextSelectField.RegisterCallback&lt; ChangeEvent&lt; TextSelectField.SelectedObject &gt; &gt;( (e) => { Debug.Log($"Valid string selected: {e.newValue.obj}"); });
/// rootVisualElement.Add(stringListTextSelectField);
/// </code>
/// <code>
/// List&lt; FooBar &gt; foobars = new List&lt; FooBar &gt;(){ new FooBar(){ Foo= 1, Bar = true}, new FooBar(){ Foo= 2,Bar= false} };
/// TextSelectField foobarSelectField = new TextSelectField(
/// "FooBar:", foobars, 34,
/// () => {
/// VisualElement container = new VisualElement();
/// container.Add(new TextField());
/// container.Add(new Label());
/// return container;
/// },
/// (elem, obj) => {
/// (elem.ElementAt(0) as TextField).value = ((FooBar)obj).Foo.ToString();
/// (elem.ElementAt(1) as Label).text = ((FooBar)obj).Bar.ToString();
/// },
/// (obj) => ((FooBar)obj).Foo.ToString()
/// );
/// rootVisualElement.Add(foobarSelectField);
/// foobarSelectField.RegisterCallback&lt; ChangeEvent&lt; string &gt; &gt;( (e) => { Debug.Log($"TextSelectField's input now contains {e.newValue}"); });
/// foobarSelectField.RegisterCallback&lt; ChangeEvent&lt; TextSelectField.SelectedObject &gt; &gt;( (e) => { Debug.Log($"Valid FooBar selected: {((FooBar)e.newValue.obj).Foo}"); });
/// </code>
/// </example>
public class TextSelectField : VisualElement
{
/// <summary>
/// Simple container for an object. Used for transmitting events to anyone registering for changes in selection, using
/// <c>RegisterCallback&lt; ChangeEvent&lt; TextSelectField.SelectedObject &gt; &gt;</c>. Necessary as ListView is IList based,
/// and can therefore have heterogenous elements - but we don't want to force users to subscribe to <c>ChangeEvent&lt; object &gt; &gt;</c>!
/// </summary>
public class SelectedObject
{
public readonly object obj;
public SelectedObject(object obj) => this.obj = obj;
}
protected readonly TextField _textInput;
public IStyle TextInputStyle => _textInput.style;
protected readonly ListView _selectList;
public IStyle SelectListStyle => _selectList.style;
private readonly IList _originalItemsSource;
private readonly List<object> _filteredItems;
private readonly Func<object, string> _getStringFromSourceItem;
private SelectedObject _selectedObject = new SelectedObject(null);
private bool _ignoreNextTypingEvent = false;
/// <summary>
/// Create a TextSelectField for the common List&lt; string &gt; use case, setting
/// up the necessary callbacks automatically.
/// </summary>
/// <param name="label">Label to appear beside the </param>
/// <param name="itemsSource"></param>
/// <param name="itemHeight"></param>
public TextSelectField(string label, List<string> itemsSource, int itemHeight = 16) : this(
label,
itemsSource,
itemHeight,
makeListViewItem: () => new Label(),
bindListViewItem: (elem, str) => (elem as Label).text = str as string,
getStringFromSourceItem: (obj) => obj as string
) { }
/// <summary>
/// Create a TextSelectField in a way that's similar to ListView, supplying appropriate functions.
/// See <see cref="TextSelectField"/> for examples.
/// </summary>
/// <param name="label">Label for the TextField</param>
/// <param name="itemsSource">an IList containing the items to display</param>
/// <param name="itemHeight">the height that your VisualElement that you make in makeListViewItem takes up - this must be fixed</param>
/// <param name="makeListViewItem">function to build the VisualElement for each item</param>
/// <param name="bindListViewItem">action to fill the item's VisualElement (from makeListViewItem) with data</param>
/// <param name="getStringFromSourceItem">a method to extract a string from a source item, for matching TextField search/populating the TextField when selecting an item from the list</param>
public TextSelectField(
string label,
IList itemsSource,
int itemHeight,
Func<VisualElement> makeListViewItem,
Action<VisualElement, object> bindListViewItem,
Func<object, string> getStringFromSourceItem) : base()
{
_originalItemsSource = itemsSource;
_filteredItems = _originalItemsSource.Cast<object>().ToList();
_getStringFromSourceItem = getStringFromSourceItem;
_textInput = new TextField(label);
Add(_textInput);
_selectList = new ListView(
_filteredItems,
itemHeight,
makeListViewItem,
(elem, i) => bindListViewItem(elem, _filteredItems[i])
);
SelectListStyle.height = 200;
SelectListStyle.borderTopWidth = SelectListStyle.borderLeftWidth = SelectListStyle.borderRightWidth = SelectListStyle.borderBottomWidth = 1;
SelectListStyle.borderTopColor = SelectListStyle.borderLeftColor = SelectListStyle.borderRightColor = SelectListStyle.borderBottomColor = Color.black;
SelectListStyle.display = DisplayStyle.None;
_selectList.onItemChosen += HandleDoubleClick; //Double-click
_selectList.onSelectionChanged += (objects) => HandleSingleClick(objects[0]); //Single-click Selection (selectionType single, so just getting first item)
_selectList.selectionType = SelectionType.Single;
Add(_selectList);
//When typing in TextFieldd, update the listview and potentially our actual selection
_textInput.RegisterCallback<ChangeEvent<string>>(HandleTyping);
//Down key is pressed in the TextField, focus the ListView
_textInput.RegisterCallback<KeyDownEvent>((evt) =>
{
if (evt.keyCode == KeyCode.DownArrow)
{
_selectList.Focus();
}
});
//Focus handling
_textInput.RegisterCallback<FocusInEvent>((_) => ShowSuggestionList());
_textInput.RegisterCallback<FocusOutEvent>((_) => HideSuggestionListIfUnfocused());
_selectList.RegisterCallback<FocusOutEvent>((_) => HideSuggestionListIfUnfocused());
}
/// <summary>
/// If you change the contents of the originally supplied list of items, call this to have the component update.
/// Will re-apply the existing entered text as a filter, possibly clearing the underlying selection
/// </summary>
public void RefreshList()
{
_filteredItems.Clear();
_filteredItems.AddRange(_originalItemsSource.Cast<object>().ToList().FindAll(item => _getStringFromSourceItem(item).ToLower().Contains(_textInput.value.ToLower())));
_selectList.Refresh();
UpdateSelectedAndDispatchChangeEvent();
}
/// <summary>
/// Handle a change in the value of the TextInput - show filtered listView, notify listeners.
/// </summary>
/// <param name="changeEvent"></param>
protected void HandleTyping(ChangeEvent<string> changeEvent)
{
//Because callbacks reg/un-reg seem to happen asynchronously, we need a quick way to internally update the text
// without triggering our own typing handling (but without making a SetValueWithoutNotify call, which would skip other subscribers too)
if (_ignoreNextTypingEvent)
{
_ignoreNextTypingEvent = false;
return;
}
// - Reveal ListView, filter and refresh it, and dispatch events
ShowSuggestionList();
RefreshList();
UpdateSelectedAndDispatchChangeEvent();
}
/// <summary>
/// Set the TextInput to match the object, set focus the TextInput, set our object, and close the ListView. Notify listeners.
/// </summary>
protected void HandleDoubleClick(object obj)
{
_textInput.value = _getStringFromSourceItem(obj);
_textInput.Q("unity-text-input").Focus(); //https://issuetracker.unity3d.com/issues/uielements-textfield-is-not-focused-and-you-are-not-able-to-type-in-characters-when-using-focus-method
SelectListStyle.display = DisplayStyle.None;
UpdateSelectedAndDispatchChangeEvent(obj);
}
/// <summary>
/// Set the TextInput to match the object (without triggering typing handling/filtering), and set our object. Notify listeners.
/// </summary>
protected void HandleSingleClick(object obj)
{
_ignoreNextTypingEvent = true; //changing the value will trigger our typing handler, but we don't want its functionality to fire here
_textInput.value = _getStringFromSourceItem(obj);
UpdateSelectedAndDispatchChangeEvent(obj);
}
protected void ShowSuggestionList()
{
bool wasHidden = SelectListStyle.display == DisplayStyle.None;
SelectListStyle.display = DisplayStyle.Flex;
//If the list was hidden, visually refresh it after display, in case it was refreshed while hidden, as that doesn't seem to update visuals.
if (wasHidden)
{
_selectList.Refresh();
}
}
/// <summary>
/// Hide listView if neither TextField nor ListView are focused
/// </summary>
protected void HideSuggestionListIfUnfocused()
{
IEnumerator ReCheckFocus()
{
yield return new EditorWaitForSeconds(.1f);
if (_selectList != null && focusController?.focusedElement != _selectList && _textInput != null && focusController?.focusedElement != _textInput)
{
SelectListStyle.display = DisplayStyle.None;
}
}
EditorCoroutineUtility.StartCoroutine(ReCheckFocus(), this);
}
/// <summary>
/// Invoke when the text changes, or the selection is changed directly - notifies listeners of change in selected object.
/// If the object doesn't change, no notification will be sent. Note a change to/from null is a change.
/// </summary>
/// <param name="newSelectedObject">Optional - directly supply the new object. Only set when selection was directly picked. When left blank, we'll check if text matches any objects and set/dispatch on that basis</param>
protected void UpdateSelectedAndDispatchChangeEvent(object newSelectedObject = null)
{
SelectedObject oldSelection = _selectedObject;
// Set current selection
if (newSelectedObject == null)
{
//See if current text matches an object
string lowerInput = _textInput.value.ToLower();
newSelectedObject = _originalItemsSource.Cast<object>().ToList().Find(item => _getStringFromSourceItem(item).ToLower() == lowerInput);
}
_selectedObject = new SelectedObject(newSelectedObject); //may be null!
//Dispatch a change event if appropriate
if (_selectedObject.obj != oldSelection.obj)
{
//Send objects in wrapped form. Necessary as ListView is IList based,
// and can therefore have heterogenous elements - but we don't want to force
// users to subscribe to ChangeEvent<object>!
using (ChangeEvent<SelectedObject> changeEvent = ChangeEvent<SelectedObject>.GetPooled(oldSelection, _selectedObject))
{
changeEvent.target = this;
_selectList.SendEvent(changeEvent);
}
}
}
}
// This is free and unencumbered software released into the public domain.
// Anyone is free to copy, modify, publish, use, compile, sell, or
// distribute this software, either in source code form or as a compiled
// binary, for any purpose, commercial or non-commercial, and by any
// means.
// In jurisdictions that recognize copyright laws, the author or authors
// of this software dedicate any and all copyright interest in the
// software to the public domain. We make this dedication for the benefit
// of the public at large and to the detriment of our heirs and
// successors. We intend this dedication to be an overt act of
// relinquishment in perpetuity of all present and future rights to this
// software under copyright law.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
// IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
// OTHER DEALINGS IN THE SOFTWARE.
// For more information, please refer to <https://unlicense.org>
#endif
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment