Last active
April 8, 2022 02:04
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#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< ChangeEvent< string > > 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< string >(){ "a", "b", "c" }); | |
/// stringListTextSelectField.RegisterCallback< ChangeEvent< string > >( (e) => { Debug.Log($"TextSelectField's input now contains {e.newValue}"); }); | |
/// stringListTextSelectField.RegisterCallback< ChangeEvent< TextSelectField.SelectedObject > >( (e) => { Debug.Log($"Valid string selected: {e.newValue.obj}"); }); | |
/// rootVisualElement.Add(stringListTextSelectField); | |
/// </code> | |
/// <code> | |
/// List< FooBar > foobars = new List< FooBar >(){ 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< ChangeEvent< string > >( (e) => { Debug.Log($"TextSelectField's input now contains {e.newValue}"); }); | |
/// foobarSelectField.RegisterCallback< ChangeEvent< TextSelectField.SelectedObject > >( (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< ChangeEvent< TextSelectField.SelectedObject > ></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< object > ></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< string > 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