Files
BABA_YAGA/Packages/app.rive.rive-unity/Editor/Components/DataBindingPlaygroundWindow.cs
2026-05-19 17:39:03 +07:00

2091 lines
80 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections;
using System.Linq;
using Rive;
using Rive.Components;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace Rive.EditorTools
{
internal class DataBindingPlaygroundWindow : EditorWindow
{
private RiveWidget m_widget;
private ObjectField m_widgetField;
private HelpBox m_playModeHelpBox;
private VisualElement m_interactiveContainer;
private ScrollView m_propertiesScroll;
private readonly List<PropertyBinding> m_propertyBindings = new List<PropertyBinding>();
private readonly List<ListPropertyBinding> m_listBindings = new List<ListPropertyBinding>();
private readonly List<Action> m_triggerBindingDisposers = new List<Action>();
private bool m_isRefreshing;
private const double TriggerFiredHighlightSeconds = 0.75d;
private enum PlaygroundState
{
NotPlaying,
NoWidget,
NoFileMetadata,
NoViewModels,
NoArtboardMetadata,
NoDefaultViewModel,
WidgetNotLoaded,
NoViewModelInstance,
Ready
}
private FileMetadata m_fileMetadata;
private FileMetadata.ArtboardMetadata m_artboardMetadata;
private double m_nextRefreshTime;
private readonly Dictionary<string, ImageOutOfBandAsset> m_imageSelectionCache = new Dictionary<string, ImageOutOfBandAsset>();
private readonly Dictionary<string, ArtboardSelection> m_artboardSelectionCache = new Dictionary<string, ArtboardSelection>();
private readonly Dictionary<string, bool> m_viewModelExpansion = new Dictionary<string, bool>();
// Used for the artboard databinding dropdowns
private enum ArtboardViewModelBindingMode
{
ExistingInstance,
CreateNewInstance
}
private class ArtboardSelection
{
public Asset Asset;
public File File;
public string ArtboardName;
public ArtboardViewModelBindingMode ViewModelBindingMode = ArtboardViewModelBindingMode.ExistingInstance;
public string ExistingViewModelInstanceName;
public ViewModelInstance CustomViewModelInstance;
public readonly List<PropertyBinding> CustomBindings = new List<PropertyBinding>();
public readonly List<ListPropertyBinding> CustomListBindings = new List<ListPropertyBinding>();
public void DisposeCustomViewModelInstance()
{
CustomViewModelInstance?.Dispose();
CustomViewModelInstance = null;
CustomBindings.Clear();
CustomListBindings.Clear();
}
public void DisposeAll()
{
DisposeCustomViewModelInstance();
File = null;
}
}
private const string DocsBaseUrl = InspectorDocLinks.UnityDataBinding;
private class PropertyBinding
{
public string Path;
public VisualElement Control;
public Action<ViewModelInstance> Sync;
}
private class ListPropertyBinding
{
public string Path;
public VisualElement Control;
public Action<ViewModelInstance> Sync;
public List<ViewModelInstance> LastItems = new List<ViewModelInstance>();
public bool InitializedTypeSelection;
}
// Adapter that proxies a list property directly into a ListView without keeping a stale mirror.
private class ListPropertyAdapter : IList<ViewModelInstance>, IList
{
private readonly Func<ViewModelInstanceListProperty> m_propertyGetter;
private readonly Func<ViewModelInstance> m_factory;
public ListPropertyAdapter(Func<ViewModelInstanceListProperty> propertyGetter, Func<ViewModelInstance> factory)
{
m_propertyGetter = propertyGetter;
m_factory = factory;
}
private ViewModelInstanceListProperty Prop => m_propertyGetter?.Invoke();
public int Count => Prop?.Count ?? 0;
public bool IsReadOnly => false;
bool IList.IsFixedSize => false;
public ViewModelInstance this[int index]
{
get => Prop?.GetInstanceAt(index);
set => MoveOrReplace(index, value);
}
object IList.this[int index]
{
get => this[index];
set
{
MoveOrReplace(index, value as ViewModelInstance);
}
}
private void MoveOrReplace(int index, ViewModelInstance value)
{
var prop = Prop;
if (prop == null || value == null)
{
return;
}
// If the instance already exists in the list, move it.
int existingIndex = -1;
for (int i = 0; i < prop.Count; i++)
{
if (ReferenceEquals(prop.GetInstanceAt(i), value))
{
existingIndex = i;
break;
}
}
if (existingIndex == index)
{
return;
}
if (existingIndex >= 0)
{
prop.RemoveAt(existingIndex);
if (existingIndex < index)
{
index -= 1;
}
}
else if (index < prop.Count)
{
// Replace the current item at index if value not found elsewhere
prop.RemoveAt(index);
}
index = Mathf.Clamp(index, 0, prop.Count);
prop.Insert(value, index);
}
public void Add(ViewModelInstance item)
{
var prop = Prop;
if (prop == null)
{
return;
}
var toAdd = item ?? m_factory?.Invoke();
if (toAdd != null)
{
prop.Add(toAdd);
}
}
int IList.Add(object value)
{
var prop = Prop;
if (prop == null)
{
return -1;
}
Add(value as ViewModelInstance);
return prop.Count - 1;
}
public void Clear()
{
var prop = Prop;
if (prop == null)
{
return;
}
for (int i = prop.Count - 1; i >= 0; i--)
{
prop.RemoveAt(i);
}
}
public bool Contains(ViewModelInstance item)
{
var prop = Prop;
if (prop == null || item == null)
{
return false;
}
for (int i = 0; i < prop.Count; i++)
{
if (ReferenceEquals(prop.GetInstanceAt(i), item))
{
return true;
}
}
return false;
}
public void CopyTo(ViewModelInstance[] array, int arrayIndex)
{
var prop = Prop;
if (prop == null || array == null)
{
return;
}
int count = prop.Count;
for (int i = 0; i < count && arrayIndex + i < array.Length; i++)
{
array[arrayIndex + i] = prop.GetInstanceAt(i);
}
}
public IEnumerator<ViewModelInstance> GetEnumerator()
{
var prop = Prop;
if (prop == null)
{
yield break;
}
int count = prop.Count;
for (int i = 0; i < count; i++)
{
yield return prop.GetInstanceAt(i);
}
}
public int IndexOf(ViewModelInstance item)
{
var prop = Prop;
if (prop == null || item == null)
{
return -1;
}
for (int i = 0; i < prop.Count; i++)
{
if (ReferenceEquals(prop.GetInstanceAt(i), item))
{
return i;
}
}
return -1;
}
public void Insert(int index, ViewModelInstance item)
{
var prop = Prop;
if (prop == null)
{
return;
}
var toInsert = item ?? m_factory?.Invoke();
if (toInsert == null)
{
return;
}
index = Mathf.Clamp(index, 0, prop.Count);
prop.Insert(toInsert, index);
}
void IList.Insert(int index, object value) => Insert(index, value as ViewModelInstance);
public bool Remove(ViewModelInstance item)
{
var prop = Prop;
if (prop == null || item == null)
{
return false;
}
prop.Remove(item);
return true;
}
void IList.Remove(object value) => Remove(value as ViewModelInstance);
public void RemoveAt(int index)
{
var prop = Prop;
if (prop == null || index < 0 || index >= prop.Count)
{
return;
}
prop.RemoveAt(index);
}
bool IList.Contains(object value) => Contains(value as ViewModelInstance);
void ICollection.CopyTo(Array array, int index)
{
if (array == null)
{
return;
}
var prop = Prop;
if (prop == null)
{
return;
}
for (int i = 0; i < prop.Count && index + i < array.Length; i++)
{
array.SetValue(prop.GetInstanceAt(i), index + i);
}
}
bool ICollection.IsSynchronized => false;
private readonly object m_syncRoot = new object();
object ICollection.SyncRoot => m_syncRoot;
int IList.IndexOf(object value) => IndexOf(value as ViewModelInstance);
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
public static void Open(RiveWidget widget)
{
var window = GetWindow<DataBindingPlaygroundWindow>();
window.titleContent = new GUIContent("Rive Data Binding Playground");
window.minSize = new Vector2(420, 360);
window.SetTarget(widget);
window.Show();
}
private void OnEnable()
{
titleContent = new GUIContent("Rive Data Binding Playground");
minSize = new Vector2(420, 360);
BuildLayout();
EditorApplication.update += EditorUpdate;
}
private void OnDisable()
{
EditorApplication.update -= EditorUpdate;
ClearTriggerBindings();
m_propertyBindings.Clear();
m_listBindings.Clear();
m_widget = null;
foreach (var selection in m_artboardSelectionCache.Values)
{
selection.DisposeAll();
}
m_artboardSelectionCache.Clear();
m_viewModelExpansion.Clear();
}
private void EditorUpdate()
{
// throttle refreshes to avoid unnecessary work
if (EditorApplication.timeSinceStartup < m_nextRefreshTime)
{
return;
}
m_nextRefreshTime = EditorApplication.timeSinceStartup + 0.25f;
UpdateVisibility();
RefreshValues();
}
private void BuildLayout()
{
var root = rootVisualElement;
root.Clear();
root.style.paddingLeft = 10;
root.style.paddingRight = 10;
root.style.paddingTop = 8;
root.style.paddingBottom = 10;
var subtitleRow = new VisualElement
{
style =
{
flexDirection = FlexDirection.Row,
alignItems = Align.Center,
marginBottom = 14
}
};
var subtitle = new Label("Inspect and tweak ViewModel instance properties for the selected RiveWidget.");
subtitle.style.flexGrow = 1;
subtitle.style.fontSize = 12;
subtitle.style.color = new UnityEngine.Color(0.8f, 0.8f, 0.8f, 0.95f);
var docsLink = new Label("View Data Binding Docs");
StyleLinkLabel(docsLink, InspectorDocLinks.UnityDataBinding);
subtitleRow.Add(subtitle);
subtitleRow.Add(docsLink);
root.Add(subtitleRow);
m_playModeHelpBox = new HelpBox(
"Play with data binding values for the selected RiveWidget while in Play Mode. " +
"Changes are applied to the widget's current ViewModel instance.",
HelpBoxMessageType.Info);
m_playModeHelpBox.style.marginBottom = 10;
root.Add(m_playModeHelpBox);
m_widgetField = new ObjectField("Widget")
{
objectType = typeof(RiveWidget),
allowSceneObjects = true
};
m_widgetField.RegisterValueChangedCallback(evt =>
{
SetTarget(evt.newValue as RiveWidget);
});
root.Add(m_widgetField);
m_interactiveContainer = new VisualElement();
m_interactiveContainer.style.flexDirection = FlexDirection.Column;
m_interactiveContainer.style.flexGrow = 1;
root.Add(m_interactiveContainer);
m_propertiesScroll = new ScrollView
{
style =
{
flexGrow = 1
}
};
m_interactiveContainer.Add(m_propertiesScroll);
UpdateVisibility();
}
private void SetTarget(RiveWidget widget)
{
m_widget = widget;
m_widgetField?.SetValueWithoutNotify(widget);
RefreshMetadata();
RebuildProperties();
UpdateVisibility();
RefreshValues();
}
private void RefreshMetadata()
{
m_fileMetadata = m_widget?.Asset?.EditorOnlyMetadata;
m_artboardMetadata = null;
if (m_fileMetadata == null)
{
return;
}
var artboardName = !string.IsNullOrEmpty(m_widget?.ArtboardName)
? m_widget.ArtboardName
: m_fileMetadata.GetArtboardNames().FirstOrDefault();
if (!string.IsNullOrEmpty(artboardName))
{
m_artboardMetadata = m_fileMetadata.GetArtboard(artboardName);
}
}
private void RebuildProperties()
{
ClearTriggerBindings();
m_propertyBindings.Clear();
m_listBindings.Clear();
m_viewModelExpansion.Clear();
m_propertiesScroll.Clear();
if (m_widget == null)
{
m_propertiesScroll.Add(new Label("Select a RiveWidget to get started."));
return;
}
if (m_artboardMetadata == null)
{
m_propertiesScroll.Add(new HelpBox("No artboard metadata found for the selected widget.", HelpBoxMessageType.Warning));
return;
}
if (m_artboardMetadata.DefaultViewModel == null)
{
m_propertiesScroll.Add(new HelpBox("The current artboard does not have a default ViewModel.", HelpBoxMessageType.Warning));
return;
}
BuildViewModelSection(
m_artboardMetadata.DefaultViewModel,
string.Empty,
m_propertiesScroll,
0);
}
private FileMetadata.ViewModelMetadata FindViewModelMetadata(string viewModelName, FileMetadata metadataContext = null)
{
var metadata = metadataContext ?? m_fileMetadata;
if (metadata == null || string.IsNullOrEmpty(viewModelName))
{
return null;
}
return metadata.ViewModels.FirstOrDefault(vm => vm.Name == viewModelName);
}
private List<string> GetEnumOptions(FileMetadata.ViewModelPropertyMetadata property, FileMetadata metadataContext = null)
{
var metadata = metadataContext ?? m_fileMetadata;
if (metadata == null || metadata.Enums == null || string.IsNullOrEmpty(property.EnumTypeName))
{
return null;
}
var enumMeta = metadata.Enums.FirstOrDefault(e => e.Name == property.EnumTypeName);
return enumMeta?.Values?.ToList();
}
private void AddStringField(VisualElement parent, string path, string displayName, Func<ViewModelInstance> instanceProvider = null, string cacheKey = null, string pathLabelOverride = null, List<PropertyBinding> bindingList = null)
{
instanceProvider ??= GetCurrentInstance;
cacheKey ??= path;
bindingList ??= m_propertyBindings;
var field = new TextField();
field.RegisterValueChangedCallback(evt =>
{
var instance = instanceProvider();
var prop = instance?.GetStringProperty(path);
if (prop != null)
{
prop.Value = evt.newValue;
}
});
bindingList.Add(new PropertyBinding
{
Path = cacheKey,
Control = field,
Sync = instance =>
{
var current = instanceProvider();
var prop = current?.GetStringProperty(path);
field.SetEnabled(prop != null);
if (prop != null)
{
field.SetValueWithoutNotify(prop.Value ?? string.Empty);
}
}
});
parent.Add(CreatePropertyCard(displayName, pathLabelOverride ?? path, "String", GetDocUrl(ViewModelDataType.String),
null, field));
}
private void AddNumberField(VisualElement parent, string path, string displayName, Func<ViewModelInstance> instanceProvider = null, string cacheKey = null, string pathLabelOverride = null, List<PropertyBinding> bindingList = null)
{
instanceProvider ??= GetCurrentInstance;
cacheKey ??= path;
bindingList ??= m_propertyBindings;
var field = new FloatField();
field.RegisterValueChangedCallback(evt =>
{
var instance = instanceProvider();
var prop = instance?.GetNumberProperty(path);
if (prop != null)
{
prop.Value = evt.newValue;
}
});
bindingList.Add(new PropertyBinding
{
Path = cacheKey,
Control = field,
Sync = instance =>
{
var current = instanceProvider();
var prop = current?.GetNumberProperty(path);
field.SetEnabled(prop != null);
if (prop != null)
{
field.SetValueWithoutNotify(prop.Value);
}
}
});
parent.Add(CreatePropertyCard(displayName, pathLabelOverride ?? path, "Number", GetDocUrl(ViewModelDataType.Number),
null, field));
}
private void AddBooleanField(VisualElement parent, string path, string displayName, Func<ViewModelInstance> instanceProvider = null, string cacheKey = null, string pathLabelOverride = null, List<PropertyBinding> bindingList = null)
{
instanceProvider ??= GetCurrentInstance;
cacheKey ??= path;
bindingList ??= m_propertyBindings;
var toggle = new Toggle();
toggle.RegisterValueChangedCallback(evt =>
{
var instance = instanceProvider();
var prop = instance?.GetBooleanProperty(path);
if (prop != null)
{
prop.Value = evt.newValue;
}
});
bindingList.Add(new PropertyBinding
{
Path = cacheKey,
Control = toggle,
Sync = instance =>
{
var current = instanceProvider();
var prop = current?.GetBooleanProperty(path);
toggle.SetEnabled(prop != null);
if (prop != null)
{
toggle.SetValueWithoutNotify(prop.Value);
}
}
});
parent.Add(CreatePropertyCard(displayName, pathLabelOverride ?? path, "Boolean", GetDocUrl(ViewModelDataType.Boolean),
null, toggle));
}
private void AddColorField(VisualElement parent, string path, string displayName, Func<ViewModelInstance> instanceProvider = null, string cacheKey = null, string pathLabelOverride = null, List<PropertyBinding> bindingList = null)
{
instanceProvider ??= GetCurrentInstance;
cacheKey ??= path;
bindingList ??= m_propertyBindings;
var field = new ColorField();
field.RegisterValueChangedCallback(evt =>
{
var instance = instanceProvider();
var prop = instance?.GetColorProperty(path);
if (prop != null)
{
prop.Value = evt.newValue;
}
});
bindingList.Add(new PropertyBinding
{
Path = cacheKey,
Control = field,
Sync = instance =>
{
var current = instanceProvider();
var prop = current?.GetColorProperty(path);
field.SetEnabled(prop != null);
if (prop != null)
{
field.SetValueWithoutNotify(prop.Value);
}
}
});
parent.Add(CreatePropertyCard(displayName, pathLabelOverride ?? path, "Color", GetDocUrl(ViewModelDataType.Color),
null, field));
}
private void AddImageField(VisualElement parent, string path, string displayName, Func<ViewModelInstance> instanceProvider = null, string cacheKey = null, string pathLabelOverride = null, List<PropertyBinding> bindingList = null)
{
instanceProvider ??= GetCurrentInstance;
cacheKey ??= path;
bindingList ??= m_propertyBindings;
var field = new ObjectField
{
objectType = typeof(ImageOutOfBandAsset),
allowSceneObjects = false
};
field.RegisterValueChangedCallback(evt =>
{
var instance = instanceProvider();
var prop = instance?.GetImageProperty(path);
if (prop == null)
{
return;
}
var asset = evt.newValue as ImageOutOfBandAsset;
if (asset == null)
{
m_imageSelectionCache[cacheKey] = null;
prop.Value = null;
return;
}
m_imageSelectionCache[cacheKey] = asset;
asset.Load();
prop.Value = asset;
asset.Unload();
});
bindingList.Add(new PropertyBinding
{
Path = cacheKey,
Control = field,
Sync = instance =>
{
var current = instanceProvider();
var prop = current?.GetImageProperty(path);
field.SetEnabled(prop != null);
// Reflect cached selection for UX (no getter available from runtime)
if (m_imageSelectionCache.TryGetValue(cacheKey, out var cached))
{
field.SetValueWithoutNotify(cached);
}
else
{
field.SetValueWithoutNotify(null);
}
}
});
parent.Add(CreatePropertyCard(displayName, pathLabelOverride ?? path, "Image", GetDocUrl(ViewModelDataType.AssetImage),
null, field));
}
private void AddEnumField(VisualElement parent, string path, FileMetadata.ViewModelPropertyMetadata property, Func<ViewModelInstance> instanceProvider = null, string cacheKey = null, string pathLabelOverride = null, List<PropertyBinding> bindingList = null, FileMetadata metadataContext = null)
{
instanceProvider ??= GetCurrentInstance;
cacheKey ??= path;
bindingList ??= m_propertyBindings;
var options = GetEnumOptions(property, metadataContext) ?? new List<string>();
var defaultOption = options.Count > 0 ? options[0] : string.Empty;
var popup = new PopupField<string>(null, options, defaultOption);
popup.RegisterValueChangedCallback(evt =>
{
if (string.IsNullOrEmpty(evt.newValue))
{
return;
}
var instance = instanceProvider();
var prop = instance?.GetEnumProperty(path);
if (prop != null)
{
prop.Value = evt.newValue;
}
});
bindingList.Add(new PropertyBinding
{
Path = cacheKey,
Control = popup,
Sync = instance =>
{
var currentInstance = instanceProvider();
var prop = currentInstance?.GetEnumProperty(path);
bool hasProp = prop != null;
popup.SetEnabled(hasProp && popup.choices.Count > 0);
if (!hasProp)
{
return;
}
var currentValue = prop.Value;
if (!options.Contains(currentValue))
{
options = prop.EnumValues?.Where(v => !string.IsNullOrEmpty(v)).ToList() ?? options;
popup.choices = options;
popup.SetEnabled(options.Count > 0);
}
if (!string.IsNullOrEmpty(currentValue) && options.Contains(currentValue))
{
popup.SetValueWithoutNotify(currentValue);
}
else if (options.Count > 0)
{
popup.SetValueWithoutNotify(options[0]);
}
}
});
parent.Add(CreatePropertyCard(property.Name, pathLabelOverride ?? path, $"Enum ({property.EnumTypeName})", GetDocUrl(ViewModelDataType.Enum),
null, popup));
}
private ArtboardSelection GetOrCreateArtboardSelection(string path)
{
if (!m_artboardSelectionCache.TryGetValue(path, out var selection))
{
selection = new ArtboardSelection();
m_artboardSelectionCache[path] = selection;
}
return selection;
}
private void DisposeArtboardSelection(string path)
{
if (m_artboardSelectionCache.TryGetValue(path, out var selection))
{
selection.DisposeAll();
}
}
private void AddArtboardField(VisualElement parent, string path, string displayName, Func<ViewModelInstance> instanceProvider = null, string cacheKey = null, string pathLabelOverride = null, List<PropertyBinding> bindingList = null)
{
instanceProvider ??= GetCurrentInstance;
cacheKey ??= path;
bindingList ??= m_propertyBindings;
var selection = GetOrCreateArtboardSelection(cacheKey);
var assetField = new ObjectField
{
objectType = typeof(Asset),
allowSceneObjects = false,
label = "Rive File",
tooltip = "Select the Rive file containing the desired artboard to bind"
};
assetField.style.marginLeft = 0;
var artboardDropdown = new DropdownField
{
choices = new List<string>(),
label = "Artboard",
tooltip = "Select the artboard to bind from the selected Rive file",
value = null
};
artboardDropdown.style.marginLeft = 0;
var vmBindingModeOptions = new List<string> { "Use Existing Instance", "New Instance" };
var vmBindingModeDropdown = new DropdownField(
"View Model Binding",
vmBindingModeOptions,
vmBindingModeOptions[(int)selection.ViewModelBindingMode])
{
tooltip = "Select whether to bind using a named instance from the selected artboard's default ViewModel or create a new instance"
};
vmBindingModeDropdown.style.marginLeft = 0;
vmBindingModeDropdown.style.display = DisplayStyle.None;
var vmInstanceDropdown = new DropdownField
{
choices = new List<string>(),
label = "Instance",
tooltip = "Select a named instance from the artboard's default ViewModel",
value = null
};
vmInstanceDropdown.style.marginLeft = 0;
vmInstanceDropdown.style.display = DisplayStyle.None;
var vmHintHelpBox = new HelpBox(string.Empty, HelpBoxMessageType.Info);
vmHintHelpBox.style.display = DisplayStyle.None;
vmHintHelpBox.style.marginTop = 4;
var customVmContainer = new VisualElement();
customVmContainer.style.flexDirection = FlexDirection.Column;
customVmContainer.style.display = DisplayStyle.None;
customVmContainer.style.marginTop = 6;
FileMetadata.ArtboardMetadata GetSelectedArtboardMetadata()
{
var metadata = selection.Asset?.EditorOnlyMetadata;
if (metadata == null || string.IsNullOrEmpty(selection.ArtboardName))
{
return null;
}
return metadata.GetArtboard(selection.ArtboardName);
}
FileMetadata.ViewModelMetadata GetDefaultViewModelMetadata()
{
return GetSelectedArtboardMetadata()?.DefaultViewModel;
}
ViewModel GetDefaultViewModelFromFile()
{
var vmMeta = GetDefaultViewModelMetadata();
if (selection.File == null || vmMeta == null || string.IsNullOrEmpty(vmMeta.Name))
{
return null;
}
return selection.File.GetViewModelByName(vmMeta.Name);
}
void EnsureCustomViewModelInstance()
{
if (selection.CustomViewModelInstance != null)
{
return;
}
var defaultVm = GetDefaultViewModelFromFile();
if (defaultVm == null)
{
return;
}
selection.CustomViewModelInstance = defaultVm.CreateInstance();
}
void RebuildCustomInstanceEditor()
{
selection.CustomBindings.Clear();
selection.CustomListBindings.Clear();
customVmContainer.Clear();
var defaultVmMeta = GetDefaultViewModelMetadata();
if (defaultVmMeta == null || selection.CustomViewModelInstance == null)
{
return;
}
BuildViewModelSection(
defaultVmMeta,
string.Empty,
customVmContainer,
0,
() => selection.CustomViewModelInstance,
displayPathPrefix: "custom-instance",
cachePathPrefix: $"{cacheKey}/custom-instance",
bindingList: selection.CustomBindings,
listBindingList: selection.CustomListBindings,
metadataContext: selection.Asset?.EditorOnlyMetadata,
viewModelResolver: name => string.IsNullOrEmpty(name) ? null : selection.File?.GetViewModelByName(name));
}
void ApplySelectionToProperty()
{
var instance = instanceProvider();
var prop = instance?.GetArtboardProperty(path);
if (prop == null)
{
return;
}
if (selection.File == null || string.IsNullOrEmpty(selection.ArtboardName))
{
prop.Value = null;
return;
}
BindableArtboard bindable = null;
var defaultVmMeta = GetDefaultViewModelMetadata();
bool hasDefaultVm = defaultVmMeta != null && !string.IsNullOrEmpty(defaultVmMeta.Name);
if (hasDefaultVm)
{
ViewModelInstance targetInstance = null;
if (selection.ViewModelBindingMode == ArtboardViewModelBindingMode.ExistingInstance)
{
var vm = GetDefaultViewModelFromFile();
if (vm != null && !string.IsNullOrEmpty(selection.ExistingViewModelInstanceName))
{
targetInstance = vm.CreateInstanceByName(selection.ExistingViewModelInstanceName);
}
}
else
{
EnsureCustomViewModelInstance();
targetInstance = selection.CustomViewModelInstance;
}
bindable = selection.File.BindableArtboard(selection.ArtboardName, targetInstance);
}
else
{
bindable = selection.File.BindableArtboard(selection.ArtboardName);
}
if (bindable != null)
{
prop.Value = bindable;
}
}
void RefreshViewModelControls()
{
var defaultVmMeta = GetDefaultViewModelMetadata();
bool hasDefaultVm = defaultVmMeta != null && !string.IsNullOrEmpty(defaultVmMeta.Name);
vmBindingModeDropdown.style.display = hasDefaultVm ? DisplayStyle.Flex : DisplayStyle.None;
vmHintHelpBox.style.display = hasDefaultVm ? DisplayStyle.None : (selection.Asset == null ? DisplayStyle.None : DisplayStyle.Flex);
vmHintHelpBox.text = "This artboard doesn't support databinding.";
if (!hasDefaultVm)
{
vmInstanceDropdown.style.display = DisplayStyle.None;
customVmContainer.style.display = DisplayStyle.None;
selection.ExistingViewModelInstanceName = null;
selection.DisposeCustomViewModelInstance();
return;
}
var instanceNames = defaultVmMeta.InstanceNames ?? new List<string>();
vmInstanceDropdown.choices = instanceNames.ToList();
if (!string.IsNullOrEmpty(selection.ExistingViewModelInstanceName) &&
vmInstanceDropdown.choices.Contains(selection.ExistingViewModelInstanceName))
{
vmInstanceDropdown.SetValueWithoutNotify(selection.ExistingViewModelInstanceName);
}
else
{
selection.ExistingViewModelInstanceName = vmInstanceDropdown.choices.FirstOrDefault();
vmInstanceDropdown.SetValueWithoutNotify(selection.ExistingViewModelInstanceName);
}
vmBindingModeDropdown.SetValueWithoutNotify(vmBindingModeOptions[(int)selection.ViewModelBindingMode]);
bool isExistingMode = selection.ViewModelBindingMode == ArtboardViewModelBindingMode.ExistingInstance;
vmInstanceDropdown.style.display = isExistingMode ? DisplayStyle.Flex : DisplayStyle.None;
vmInstanceDropdown.SetEnabled(isExistingMode && vmInstanceDropdown.choices.Count > 0);
if (isExistingMode)
{
customVmContainer.style.display = DisplayStyle.None;
}
else
{
EnsureCustomViewModelInstance();
if (customVmContainer.childCount == 0)
{
RebuildCustomInstanceEditor();
}
customVmContainer.style.display = selection.CustomViewModelInstance != null
? DisplayStyle.Flex
: DisplayStyle.None;
}
}
void PopulateArtboards(Asset asset)
{
artboardDropdown.choices = new List<string>();
artboardDropdown.value = null;
selection.ArtboardName = null;
artboardDropdown.style.display = asset == null ? DisplayStyle.None : DisplayStyle.Flex;
if (asset == null)
{
return;
}
var names = asset.EditorOnlyMetadata?.GetArtboardNames() ?? Array.Empty<string>();
artboardDropdown.choices = names.ToList();
if (names.Length > 0)
{
var chosenArtboard = selection.ArtboardName;
if (string.IsNullOrEmpty(chosenArtboard) || !names.Contains(chosenArtboard))
{
chosenArtboard = names[0];
}
selection.ArtboardName = chosenArtboard;
artboardDropdown.value = chosenArtboard;
}
}
assetField.RegisterValueChangedCallback(evt =>
{
var newAsset = evt.newValue as Asset;
if (!ReferenceEquals(selection.Asset, newAsset))
{
DisposeArtboardSelection(cacheKey);
selection.Asset = newAsset;
selection.File = null;
selection.ArtboardName = null;
}
PopulateArtboards(newAsset);
if (newAsset != null)
{
selection.File = File.Load(newAsset);
}
RefreshViewModelControls();
ApplySelectionToProperty();
});
artboardDropdown.RegisterValueChangedCallback(evt =>
{
selection.ArtboardName = evt.newValue;
selection.DisposeCustomViewModelInstance();
customVmContainer.Clear();
RefreshViewModelControls();
ApplySelectionToProperty();
});
vmBindingModeDropdown.RegisterValueChangedCallback(evt =>
{
selection.ViewModelBindingMode = evt.newValue == vmBindingModeOptions[(int)ArtboardViewModelBindingMode.CreateNewInstance]
? ArtboardViewModelBindingMode.CreateNewInstance
: ArtboardViewModelBindingMode.ExistingInstance;
if (selection.ViewModelBindingMode == ArtboardViewModelBindingMode.CreateNewInstance)
{
selection.DisposeCustomViewModelInstance();
customVmContainer.Clear();
}
RefreshViewModelControls();
ApplySelectionToProperty();
});
vmInstanceDropdown.RegisterValueChangedCallback(evt =>
{
selection.ExistingViewModelInstanceName = evt.newValue;
ApplySelectionToProperty();
});
bindingList.Add(new PropertyBinding
{
Path = cacheKey,
Control = artboardDropdown,
Sync = instance =>
{
var current = instanceProvider();
var prop = current?.GetArtboardProperty(path);
bool hasProp = prop != null;
assetField.SetEnabled(hasProp);
artboardDropdown.SetEnabled(hasProp && selection.Asset != null);
vmBindingModeDropdown.SetEnabled(hasProp);
artboardDropdown.style.display = selection.Asset == null ? DisplayStyle.None : DisplayStyle.Flex;
if (assetField.value != selection.Asset)
{
assetField.SetValueWithoutNotify(selection.Asset);
}
// Ensure file is loaded if we have an asset but no file yet
if (hasProp && selection.Asset != null && selection.File == null)
{
selection.File = File.Load(selection.Asset);
}
// Populate dropdown if empty but we have asset metadata
if (artboardDropdown.choices.Count == 0 && selection.Asset != null)
{
PopulateArtboards(selection.Asset);
}
// Keep dropdown selection in sync with cached choice
if (selection.ArtboardName != null && artboardDropdown.value != selection.ArtboardName)
{
artboardDropdown.SetValueWithoutNotify(selection.ArtboardName);
}
RefreshViewModelControls();
foreach (var customBinding in selection.CustomBindings)
{
customBinding.Sync?.Invoke(selection.CustomViewModelInstance);
}
foreach (var customListBinding in selection.CustomListBindings)
{
customListBinding.Sync?.Invoke(selection.CustomViewModelInstance);
}
}
});
var container = new VisualElement();
container.style.flexDirection = FlexDirection.Column;
container.Add(assetField);
container.Add(artboardDropdown);
container.Add(vmBindingModeDropdown);
container.Add(vmInstanceDropdown);
container.Add(vmHintHelpBox);
container.Add(customVmContainer);
parent.Add(CreatePropertyCard(displayName, pathLabelOverride ?? path, "Artboard", GetDocUrl(ViewModelDataType.Artboard),
null, container));
}
private void AddTriggerField(VisualElement parent, string path, string displayName, Func<ViewModelInstance> instanceProvider = null, string cacheKey = null, string pathLabelOverride = null, List<PropertyBinding> bindingList = null)
{
instanceProvider ??= GetCurrentInstance;
cacheKey ??= path;
bindingList ??= m_propertyBindings;
ViewModelInstanceTriggerProperty subscribedProp = null;
double lastTriggeredAt = -1d;
var button = new Button(() =>
{
var instance = instanceProvider();
var prop = instance?.GetTriggerProperty(path);
prop?.Trigger();
})
{
text = $"Fire Trigger"
};
var statusLabel = new Label("Fired");
statusLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
statusLabel.style.paddingLeft = 8;
statusLabel.style.paddingRight = 8;
statusLabel.style.paddingTop = 2;
statusLabel.style.paddingBottom = 2;
statusLabel.style.marginLeft = 8;
statusLabel.style.borderTopLeftRadius = 4;
statusLabel.style.borderTopRightRadius = 4;
statusLabel.style.borderBottomLeftRadius = 4;
statusLabel.style.borderBottomRightRadius = 4;
statusLabel.style.display = DisplayStyle.None;
Action onTriggered = () =>
{
lastTriggeredAt = EditorApplication.timeSinceStartup;
statusLabel.text = "Fired";
statusLabel.style.color = new UnityEngine.Color(0.12f, 0.95f, 0.45f, 1f);
statusLabel.style.backgroundColor = new UnityEngine.Color(0.1f, 0.35f, 0.18f, 0.55f);
statusLabel.style.display = DisplayStyle.Flex;
};
void UpdateTriggerStatus(bool hasProp)
{
if (!hasProp)
{
statusLabel.style.display = DisplayStyle.None;
return;
}
bool recentlyFired = lastTriggeredAt >= 0d &&
EditorApplication.timeSinceStartup - lastTriggeredAt <= TriggerFiredHighlightSeconds;
statusLabel.style.display = recentlyFired ? DisplayStyle.Flex : DisplayStyle.None;
}
void DisposeSubscription()
{
if (subscribedProp != null)
{
subscribedProp.OnTriggered -= onTriggered;
subscribedProp = null;
}
}
m_triggerBindingDisposers.Add(DisposeSubscription);
bindingList.Add(new PropertyBinding
{
Path = cacheKey,
Control = button,
Sync = instance =>
{
var current = instanceProvider();
var prop = current?.GetTriggerProperty(path);
button.SetEnabled(prop != null);
if (!ReferenceEquals(subscribedProp, prop))
{
DisposeSubscription();
subscribedProp = prop;
if (subscribedProp != null)
{
subscribedProp.OnTriggered += onTriggered;
}
}
UpdateTriggerStatus(prop != null);
}
});
var triggerControlRow = new VisualElement();
triggerControlRow.style.flexDirection = FlexDirection.Row;
triggerControlRow.style.alignItems = Align.Center;
triggerControlRow.Add(button);
triggerControlRow.Add(statusLabel);
parent.Add(CreatePropertyCard(displayName, pathLabelOverride ?? path, "Trigger", GetDocUrl(ViewModelDataType.Trigger),
null, triggerControlRow));
}
private void ClearTriggerBindings()
{
foreach (var dispose in m_triggerBindingDisposers)
{
dispose?.Invoke();
}
m_triggerBindingDisposers.Clear();
}
private void AddListField(
VisualElement parent,
string path,
string displayName,
Func<ViewModelInstance> instanceProvider = null,
string cacheKey = null,
string pathLabelOverride = null,
List<ListPropertyBinding> listBindingList = null,
FileMetadata metadataContext = null,
Func<string, ViewModel> viewModelResolver = null)
{
instanceProvider ??= GetCurrentInstance;
cacheKey ??= path;
listBindingList ??= m_listBindings;
metadataContext ??= m_fileMetadata;
viewModelResolver ??= name => string.IsNullOrEmpty(name) ? null : m_widget?.File?.GetViewModelByName(name);
var listContainer = new VisualElement();
listContainer.style.flexDirection = FlexDirection.Column;
var vmNames = metadataContext?.ViewModels?
.Select(vm => vm.Name)
.Where(n => !string.IsNullOrEmpty(n))
.Distinct()
.ToList() ?? new List<string>();
var typeRow = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center } };
var initialType = vmNames.FirstOrDefault();
var typeDropdown = new DropdownField("New Item Type", vmNames, initialType)
{
tooltip = "Select the ViewModel type for newly added list items"
};
typeDropdown.style.flexGrow = 1;
typeDropdown.style.flexShrink = 1;
typeDropdown.style.marginRight = 6;
typeDropdown.style.marginBottom = 6;
typeRow.Add(typeDropdown);
typeRow.style.marginBottom = 4;
listContainer.Add(typeRow);
var itemBindings = new Dictionary<int, List<PropertyBinding>>();
ViewModelInstance CreateInstanceForList()
{
string targetType = typeDropdown.value;
var listProp = instanceProvider()?.GetListProperty(path);
if (string.IsNullOrEmpty(targetType))
{
targetType = listProp?.Count > 0 ? listProp.GetInstanceAt(0)?.ViewModelName : null;
}
var vm = !string.IsNullOrEmpty(targetType) ? viewModelResolver(targetType) : null;
return vm?.CreateInstance();
}
var listView = new ListView
{
reorderable = true,
showAddRemoveFooter = true,
virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight,
selectionType = SelectionType.Single
};
listView.style.flexGrow = 1;
listView.style.minHeight = 80;
// Adapter that proxies directly to the live list property to avoid stale counts
var listAdapter = new ListPropertyAdapter(
() => instanceProvider()?.GetListProperty(path),
CreateInstanceForList);
listView.itemsSource = listAdapter;
listView.makeItem = () =>
{
var root = new VisualElement();
root.style.flexDirection = FlexDirection.Column;
root.style.paddingTop = 6;
root.style.paddingBottom = 6;
root.style.paddingLeft = 4;
root.style.paddingRight = 4;
root.style.borderBottomWidth = 1;
root.style.borderBottomColor = new UnityEngine.Color(0.25f, 0.25f, 0.25f, 0.25f);
return root;
};
listView.bindItem = (element, index) =>
{
element.Clear();
var bindings = new List<PropertyBinding>();
itemBindings[index] = bindings;
var listProp = instanceProvider()?.GetListProperty(path);
var instance = (listProp != null && index >= 0 && index < listProp.Count) ? listProp.GetInstanceAt(index) : null;
var header = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center } };
var title = new Label(instance?.ViewModelName ?? "Item");
title.style.unityFontStyleAndWeight = FontStyle.Bold;
title.style.flexGrow = 1;
//header.Add(title);
element.Add(header);
if (instance == null)
{
element.Add(new HelpBox("List item is null.", HelpBoxMessageType.Warning));
return;
}
var meta = FindViewModelMetadata(instance.ViewModelName, metadataContext);
if (meta == null)
{
element.Add(new HelpBox($"No metadata found for '{instance.ViewModelName}'.", HelpBoxMessageType.Warning));
return;
}
BuildViewModelSection(meta, string.Empty, element, 1, () => instance, $"{path}[{index}]", $"{path}[{index}]",
bindings, null, metadataContext, viewModelResolver);
};
listContainer.Add(listView);
var listBinding = new ListPropertyBinding
{
Path = cacheKey,
Control = listContainer,
};
listBinding.Sync = _ =>
{
var listProp = instanceProvider()?.GetListProperty(path);
bool hasProp = listProp != null;
listView.SetEnabled(hasProp);
typeDropdown.SetEnabled(hasProp);
if (!hasProp)
{
itemBindings.Clear();
listView.itemsSource = listAdapter;
listView.Rebuild();
listView.ClearSelection();
listBinding.LastItems.Clear();
listBinding.InitializedTypeSelection = false;
return;
}
// Detect structural changes to avoid unnecessary rebuilds that steal focus
var currentItems = new List<ViewModelInstance>(listProp.Count);
for (int i = 0; i < listProp.Count; i++)
{
currentItems.Add(listProp.GetInstanceAt(i));
}
bool structureChanged = currentItems.Count != listBinding.LastItems.Count;
if (!structureChanged)
{
for (int i = 0; i < currentItems.Count; i++)
{
if (!ReferenceEquals(currentItems[i], listBinding.LastItems[i]))
{
structureChanged = true;
break;
}
}
}
if (structureChanged)
{
itemBindings.Clear();
listView.itemsSource = listAdapter;
listView.Rebuild();
listView.ClearSelection();
listBinding.LastItems = currentItems;
}
else
{
listBinding.LastItems = currentItems;
if (listView.selectedIndex >= listProp.Count)
{
listView.ClearSelection();
}
}
string inferred = listProp.Count > 0 ? listProp.GetInstanceAt(0)?.ViewModelName : null;
if (!listBinding.InitializedTypeSelection)
{
if (!string.IsNullOrEmpty(inferred) && typeDropdown.choices.Contains(inferred))
{
typeDropdown.SetValueWithoutNotify(inferred);
}
else if (string.IsNullOrEmpty(typeDropdown.value) && typeDropdown.choices.Count > 0)
{
typeDropdown.SetValueWithoutNotify(typeDropdown.choices[0]);
}
listBinding.InitializedTypeSelection = true;
}
else
{
bool selectionMissing = string.IsNullOrEmpty(typeDropdown.value) || !typeDropdown.choices.Contains(typeDropdown.value);
if (selectionMissing && !string.IsNullOrEmpty(inferred) && typeDropdown.choices.Contains(inferred))
{
typeDropdown.SetValueWithoutNotify(inferred);
}
}
foreach (var kvp in itemBindings)
{
var idx = kvp.Key;
if (idx < 0 || idx >= listProp.Count)
{
continue;
}
var instance = listProp.GetInstanceAt(idx);
foreach (var binding in kvp.Value)
{
binding.Sync?.Invoke(instance);
}
}
};
listBindingList.Add(listBinding);
parent.Add(CreatePropertyCard(displayName, pathLabelOverride ?? path, "List", GetDocUrl(ViewModelDataType.List),
null, listContainer, false));
}
private void AddUnsupportedLabel(VisualElement parent, string name, ViewModelDataType type)
{
var label = new Label($"This property type is not configurable in the playground.");
label.style.color = new UnityEngine.Color(0.7f, 0.7f, 0.7f);
parent.Add(CreatePropertyCard(name, name, type.ToString(), GetDocUrl(type),
null, label));
}
private void RefreshValues()
{
if (m_isRefreshing)
{
return;
}
m_isRefreshing = true;
var instance = GetCurrentInstance();
try
{
foreach (var binding in m_propertyBindings.ToList())
{
binding.Sync?.Invoke(instance);
}
foreach (var listBinding in m_listBindings.ToList())
{
listBinding.Sync?.Invoke(instance);
}
}
finally
{
m_isRefreshing = false;
}
}
private void UpdateVisibility()
{
string message;
var state = GetPlaygroundState(out message);
bool ready = state == PlaygroundState.Ready;
if (m_playModeHelpBox != null)
{
m_playModeHelpBox.text = message;
m_playModeHelpBox.style.display = ready ? DisplayStyle.None : DisplayStyle.Flex;
}
if (m_widgetField != null)
{
// Only show the widget selector while in Play Mode
m_widgetField.style.display = EditorApplication.isPlaying ? DisplayStyle.Flex : DisplayStyle.None;
}
if (m_interactiveContainer != null)
{
m_interactiveContainer.style.display = ready ? DisplayStyle.Flex : DisplayStyle.None;
}
}
private PlaygroundState GetPlaygroundState(out string message)
{
if (!EditorApplication.isPlaying)
{
message = "Enter Play Mode to update values in the widget.";
return PlaygroundState.NotPlaying;
}
if (m_widget == null)
{
message = "Select a RiveWidget to get started.";
return PlaygroundState.NoWidget;
}
if (m_fileMetadata == null)
{
message = "Selected Rive file has no metadata yet. Reimport or select a Rive asset.";
return PlaygroundState.NoFileMetadata;
}
if (m_fileMetadata.ViewModels == null || m_fileMetadata.ViewModels.Count == 0)
{
message = "This Rive file has no ViewModels. Add data binding in the Rive Editor to use the playground.";
return PlaygroundState.NoViewModels;
}
if (m_artboardMetadata == null)
{
message = "No artboard metadata found for the selected widget.";
return PlaygroundState.NoArtboardMetadata;
}
if (m_artboardMetadata.DefaultViewModel == null)
{
message = "The current artboard has no default ViewModel.";
return PlaygroundState.NoDefaultViewModel;
}
if (m_widget.Status != WidgetStatus.Loaded || m_widget.StateMachine == null)
{
message = "Widget is not loaded yet.";
return PlaygroundState.WidgetNotLoaded;
}
if (m_widget.StateMachine.ViewModelInstance == null)
{
message = "No ViewModel instance bound to the state machine.";
return PlaygroundState.NoViewModelInstance;
}
message = string.Empty;
return PlaygroundState.Ready;
}
private Button CreateMoreActionsButton(string path, string displayName)
{
var button = new Button(() =>
{
var menu = new GenericMenu();
bool hasIndexPath = !string.IsNullOrEmpty(path) && path.Contains("[");
if (!string.IsNullOrEmpty(path) && !hasIndexPath)
{
menu.AddItem(new GUIContent("Copy Path"), false, () =>
{
EditorGUIUtility.systemCopyBuffer = path;
});
}
else
{
string label = hasIndexPath ? "Copy Path (not supported for list items)" : "Copy Path";
menu.AddDisabledItem(new GUIContent(label));
}
string nameOnly = !string.IsNullOrEmpty(path) ? path.Split('/').Last() : displayName;
menu.AddItem(new GUIContent("Copy Name"), false, () =>
{
EditorGUIUtility.systemCopyBuffer = nameOnly;
});
menu.ShowAsContext();
})
{
text = "⋮",
tooltip = "More actions",
};
button.style.width = 26;
button.style.marginLeft = 4;
button.style.height = 20;
button.style.backgroundColor = new UnityEngine.Color(0, 0, 0, 0);
button.style.borderLeftWidth = 0;
button.style.borderRightWidth = 0;
button.style.borderTopWidth = 0;
button.style.borderBottomWidth = 0;
button.style.fontSize = 14;
button.style.unityFontStyleAndWeight = FontStyle.Bold;
button.RegisterCallback<MouseEnterEvent>(_ =>
{
button.style.backgroundColor = new UnityEngine.Color(0.345f, 0.345f, 0.345f, 0.6f);
});
button.RegisterCallback<MouseLeaveEvent>(_ =>
{
button.style.backgroundColor = new UnityEngine.Color(0, 0, 0, 0);
});
return button;
}
private void StyleLinkLabel(Label label, string url)
{
label.style.fontSize = 11;
var linkColor = new UnityEngine.Color(0.55f, 0.78f, 1f, 1f);
label.style.color = linkColor;
label.style.marginLeft = 6;
// We keep the border width constant to avoid layout shift; toggle only the color.
label.style.borderBottomWidth = 1;
label.style.borderBottomColor = new StyleColor(new UnityEngine.Color(0, 0, 0, 0));
label.RegisterCallback<MouseEnterEvent>(_ =>
{
label.style.borderBottomColor = new StyleColor(linkColor);
});
label.RegisterCallback<MouseLeaveEvent>(_ =>
{
label.style.borderBottomColor = new StyleColor(new UnityEngine.Color(0, 0, 0, 0));
});
label.RegisterCallback<MouseUpEvent>(_ => Application.OpenURL(url));
}
private ViewModelInstance GetCurrentInstance()
{
return m_widget?.StateMachine?.ViewModelInstance;
}
private void BuildViewModelSection(
FileMetadata.ViewModelMetadata viewModel,
string accessPathPrefix,
VisualElement parent,
int depth,
Func<ViewModelInstance> instanceProvider = null,
string displayPathPrefix = null,
string cachePathPrefix = null,
List<PropertyBinding> bindingList = null,
List<ListPropertyBinding> listBindingList = null,
FileMetadata metadataContext = null,
Func<string, ViewModel> viewModelResolver = null)
{
instanceProvider ??= GetCurrentInstance;
bindingList ??= m_propertyBindings;
listBindingList ??= m_listBindings;
metadataContext ??= m_fileMetadata;
viewModelResolver ??= name => string.IsNullOrEmpty(name) ? null : m_widget?.File?.GetViewModelByName(name);
string resolvedDisplayPrefix = displayPathPrefix ?? accessPathPrefix;
string resolvedCachePrefix = cachePathPrefix ?? accessPathPrefix;
string expansionKey = string.IsNullOrEmpty(resolvedCachePrefix) ? "(root)" : resolvedCachePrefix;
string vmLabel = string.IsNullOrEmpty(accessPathPrefix)
? (string.IsNullOrEmpty(viewModel.Name) ? "Default ViewModel" : viewModel.Name)
: accessPathPrefix.Split('/').Last();
string viewModelNameLabel = string.IsNullOrEmpty(viewModel.Name) ? null : viewModel.Name;
string pathLabel = string.IsNullOrEmpty(resolvedDisplayPrefix) ? "(root)" : resolvedDisplayPrefix;
int childCount = viewModel.Properties?.Count ?? 0;
bool expanded = m_viewModelExpansion.TryGetValue(expansionKey, out var savedExpanded)
? savedExpanded
: depth == 0;
var header = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center } };
var expander = new Button();
expander.style.width = 20;
expander.style.height = 20;
expander.style.paddingLeft = 0;
expander.style.paddingRight = 0;
expander.style.marginRight = 3;
expander.text = expanded ? "▾" : "▸";
expander.tooltip = "Expand / collapse view model";
expander.style.fontSize = 30;
expander.style.backgroundColor = new UnityEngine.Color(0, 0, 0, 0);
expander.style.borderLeftWidth = 0;
expander.style.borderRightWidth = 0;
expander.style.borderTopWidth = 0;
expander.style.borderBottomWidth = 0;
var vmName = new Label(vmLabel) { style = { unityFontStyleAndWeight = FontStyle.Bold, flexGrow = 1 } };
var vmPill = new Label(string.IsNullOrEmpty(viewModelNameLabel)
? "View Model"
: $"View Model ({viewModelNameLabel})");
vmPill.style.unityTextAlign = TextAnchor.MiddleCenter;
vmPill.style.paddingLeft = 6;
vmPill.style.paddingRight = 6;
vmPill.style.paddingTop = 2;
vmPill.style.paddingBottom = 2;
vmPill.style.marginLeft = 4;
vmPill.style.borderTopLeftRadius = 4;
vmPill.style.borderTopRightRadius = 4;
vmPill.style.borderBottomLeftRadius = 4;
vmPill.style.borderBottomRightRadius = 4;
vmPill.style.backgroundColor = new UnityEngine.Color(0.25f, 0.25f, 0.35f, 0.9f);
vmPill.style.color = new UnityEngine.Color(0.9f, 0.9f, 1f, 1f);
var countLabel = new Label($"{childCount} properties");
countLabel.style.marginLeft = 6;
countLabel.style.fontSize = 11;
countLabel.style.color = new UnityEngine.Color(0.8f, 0.8f, 0.85f, 0.9f);
header.Add(expander);
header.Add(vmName);
header.Add(vmPill);
header.Add(countLabel);
header.Add(CreateMoreActionsButton(resolvedDisplayPrefix, vmLabel));
var container = new VisualElement();
container.style.marginLeft = 12;
container.style.marginTop = 8;
container.style.marginBottom = 8;
container.style.display = expanded ? DisplayStyle.Flex : DisplayStyle.None;
container.style.flexDirection = FlexDirection.Column;
foreach (var property in viewModel.Properties)
{
string propertyAccessPath = string.IsNullOrEmpty(accessPathPrefix)
? property.Name
: $"{accessPathPrefix}/{property.Name}";
string propertyDisplayPath = string.IsNullOrEmpty(resolvedDisplayPrefix)
? property.Name
: $"{resolvedDisplayPrefix}/{property.Name}";
string propertyCachePath = string.IsNullOrEmpty(resolvedCachePrefix)
? propertyAccessPath
: $"{resolvedCachePrefix}/{property.Name}";
switch (property.Type)
{
case ViewModelDataType.String:
AddStringField(container, propertyAccessPath, property.Name, instanceProvider, propertyCachePath, propertyDisplayPath, bindingList);
break;
case ViewModelDataType.Number:
AddNumberField(container, propertyAccessPath, property.Name, instanceProvider, propertyCachePath, propertyDisplayPath, bindingList);
break;
case ViewModelDataType.Boolean:
AddBooleanField(container, propertyAccessPath, property.Name, instanceProvider, propertyCachePath, propertyDisplayPath, bindingList);
break;
case ViewModelDataType.Color:
AddColorField(container, propertyAccessPath, property.Name, instanceProvider, propertyCachePath, propertyDisplayPath, bindingList);
break;
case ViewModelDataType.AssetImage:
AddImageField(container, propertyAccessPath, property.Name, instanceProvider, propertyCachePath, propertyDisplayPath, bindingList);
break;
case ViewModelDataType.Artboard:
AddArtboardField(container, propertyAccessPath, property.Name, instanceProvider, propertyCachePath, propertyDisplayPath, bindingList);
break;
case ViewModelDataType.Enum:
AddEnumField(container, propertyAccessPath, property, instanceProvider, propertyCachePath, propertyDisplayPath, bindingList, metadataContext);
break;
case ViewModelDataType.Trigger:
AddTriggerField(container, propertyAccessPath, property.Name, instanceProvider, propertyCachePath, propertyDisplayPath, bindingList);
break;
case ViewModelDataType.ViewModel:
var nestedMeta = FindViewModelMetadata(property.NestedViewModelName, metadataContext);
if (nestedMeta != null)
{
BuildViewModelSection(nestedMeta, propertyAccessPath, container, depth + 1, instanceProvider, propertyDisplayPath, propertyCachePath,
bindingList, listBindingList, metadataContext, viewModelResolver);
}
else
{
var missingLabel = new Label($"Nested view model '{property.NestedViewModelName}' not found.");
missingLabel.style.color = UnityEngine.Color.yellow;
container.Add(missingLabel);
}
break;
case ViewModelDataType.List:
AddListField(container, propertyAccessPath, property.Name, instanceProvider, propertyCachePath, propertyDisplayPath,
listBindingList, metadataContext, viewModelResolver);
break;
case ViewModelDataType.ListIndex:
AddUnsupportedLabel(container, property.Name, property.Type);
break;
default:
AddUnsupportedLabel(container, property.Name, property.Type);
break;
}
}
void ToggleExpanded()
{
expanded = !expanded;
container.style.display = expanded ? DisplayStyle.Flex : DisplayStyle.None;
expander.text = expanded ? "▾" : "▸";
m_viewModelExpansion[expansionKey] = expanded;
}
expander.clicked += ToggleExpanded;
// wrap in a card for clarity
var vmCard = new VisualElement();
vmCard.style.marginTop = depth == 0 ? 8 : 4;
vmCard.style.paddingTop = 8;
vmCard.style.paddingBottom = 8;
vmCard.style.paddingLeft = 8;
vmCard.style.paddingRight = 8;
vmCard.style.borderTopWidth = 1;
vmCard.style.borderBottomWidth = 1;
vmCard.style.borderLeftWidth = 1;
vmCard.style.borderRightWidth = 1;
vmCard.style.borderTopColor = new UnityEngine.Color(0.344f, 0.344f, 0.349f, 0.5f);
vmCard.style.borderBottomColor = new UnityEngine.Color(0.344f, 0.344f, 0.349f, 0.5f);
vmCard.style.borderLeftColor = new UnityEngine.Color(0.344f, 0.344f, 0.349f, 0.5f);
vmCard.style.borderRightColor = new UnityEngine.Color(0.344f, 0.344f, 0.349f, 0.5f);
vmCard.style.backgroundColor = new UnityEngine.Color(0.145f, 0.145f, 0.152f, 0.55f);
vmCard.style.borderTopLeftRadius = 6;
vmCard.style.borderTopRightRadius = 6;
vmCard.style.borderBottomLeftRadius = 6;
vmCard.style.borderBottomRightRadius = 6;
vmCard.Add(header);
vmCard.Add(container);
parent.Add(vmCard);
}
private string GetDocUrl(ViewModelDataType type)
{
switch (type)
{
case ViewModelDataType.String:
return InspectorDocLinks.UnityDataBindingProperties;
case ViewModelDataType.Number:
return InspectorDocLinks.UnityDataBindingProperties;
case ViewModelDataType.Boolean:
return InspectorDocLinks.UnityDataBindingProperties;
case ViewModelDataType.Color:
return InspectorDocLinks.UnityDataBindingProperties;
case ViewModelDataType.Trigger:
return InspectorDocLinks.UnityDataBindingProperties;
case ViewModelDataType.Enum:
return InspectorDocLinks.UnityDataBindingEnums;
case ViewModelDataType.ViewModel:
return InspectorDocLinks.UnityDataBindingViewModel;
case ViewModelDataType.AssetImage:
return InspectorDocLinks.UnityDataBindingImages;
case ViewModelDataType.List:
return InspectorDocLinks.UnityDataBindingLists;
case ViewModelDataType.ListIndex:
return InspectorDocLinks.UnityDataBindingListViewModelIndex;
case ViewModelDataType.Artboard:
return InspectorDocLinks.UnityDataBindingArtboards;
default:
return InspectorDocLinks.UnityDataBinding;
}
}
private VisualElement CreatePropertyCard(string displayName, string path, string typeLabel, string docUrl, string description, VisualElement control, bool showTypePill = false)
{
var card = new VisualElement();
card.style.marginTop = 6;
card.style.paddingTop = 8;
card.style.paddingBottom = 8;
card.style.paddingLeft = 10;
card.style.paddingRight = 10;
card.style.borderTopLeftRadius = 6;
card.style.borderTopRightRadius = 6;
card.style.borderBottomLeftRadius = 6;
card.style.borderBottomRightRadius = 6;
card.style.borderBottomWidth = 1;
card.style.borderTopWidth = 1;
card.style.borderLeftWidth = 1;
card.style.borderRightWidth = 1;
card.style.borderBottomColor = new UnityEngine.Color(0.25f, 0.25f, 0.25f, 0.4f);
card.style.borderTopColor = new UnityEngine.Color(0.25f, 0.25f, 0.25f, 0.4f);
card.style.borderLeftColor = new UnityEngine.Color(0.25f, 0.25f, 0.25f, 0.4f);
card.style.borderRightColor = new UnityEngine.Color(0.25f, 0.25f, 0.25f, 0.4f);
card.style.backgroundColor = new UnityEngine.Color(0.12f, 0.12f, 0.12f, 0.45f);
var header = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center } };
var nameLabel = new Label(displayName);
nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
nameLabel.style.flexGrow = 1;
if (showTypePill && !string.IsNullOrEmpty(typeLabel))
{
var typePill = new Label(typeLabel);
typePill.style.unityTextAlign = TextAnchor.MiddleCenter;
typePill.style.paddingLeft = 6;
typePill.style.paddingRight = 6;
typePill.style.paddingTop = 2;
typePill.style.paddingBottom = 2;
typePill.style.marginLeft = 4;
typePill.style.borderTopLeftRadius = 4;
typePill.style.borderTopRightRadius = 4;
typePill.style.borderBottomLeftRadius = 4;
typePill.style.borderBottomRightRadius = 4;
typePill.style.backgroundColor = new UnityEngine.Color(0.25f, 0.25f, 0.25f, 0.8f);
typePill.style.color = new UnityEngine.Color(0.85f, 0.85f, 0.85f, 1f);
header.Add(typePill);
}
var menuButton = CreateMoreActionsButton(path, displayName);
// Remove potential parenthesis text to account for enum values. E.g. "Enum (MyEnum)" should be "Enum"
string strippedTypeLabel = typeLabel;
if (!string.IsNullOrEmpty(strippedTypeLabel))
{
int parenIndex = strippedTypeLabel.IndexOf(" (", StringComparison.Ordinal);
if (parenIndex > 0)
{
strippedTypeLabel = strippedTypeLabel.Substring(0, parenIndex);
}
}
string docLabel = string.IsNullOrEmpty(typeLabel) ? "Docs" : $"{strippedTypeLabel} Property Documentation";
var docButton = new Label(docLabel);
StyleLinkLabel(docButton, docUrl);
docButton.style.marginLeft = 6;
docButton.style.alignSelf = Align.Center;
docButton.style.display = DisplayStyle.None;
header.Add(nameLabel);
header.Add(docButton);
header.Add(menuButton);
card.Add(header);
card.RegisterCallback<MouseEnterEvent>(_ =>
{
docButton.style.display = DisplayStyle.Flex;
});
card.RegisterCallback<MouseLeaveEvent>(_ =>
{
docButton.style.display = DisplayStyle.None;
});
string pathLineText = null;
if (!showTypePill)
{
pathLineText = $"{typeLabel}";
}
else if (!string.IsNullOrEmpty(path))
{
pathLineText = $"Path: {path}";
}
if (!string.IsNullOrEmpty(pathLineText))
{
var pathLabel = new Label(pathLineText);
pathLabel.style.fontSize = 11;
pathLabel.style.color = new UnityEngine.Color(0.75f, 0.75f, 0.75f, 0.9f);
pathLabel.style.marginTop = 2;
card.Add(pathLabel);
}
if (!string.IsNullOrEmpty(description))
{
var desc = new Label(description);
desc.style.fontSize = 11;
desc.style.color = new UnityEngine.Color(0.8f, 0.8f, 0.8f, 0.95f);
desc.style.marginTop = 4;
card.Add(desc);
}
control.style.marginTop = 6;
card.Add(control);
return card;
}
}
}