TylerTemp / SaintsField

A Unity Inspector extension tool focusing on script fields inspector enhancement
MIT License
148 stars 9 forks source link
unity unity-editor unity-inspector unity3d

SaintsField

unity_version license_mit openupm openupm repo-stars

SaintsField is a Unity Inspector extension tool focusing on script fields like NaughtyAttributes but different.

Developed by: TylerTemp, 墨瞳

Unity: 2019.1 or higher

(Yes, the project name comes from, of course, Saints Row 2)

Highlights

  1. Works on deep nested fields!
  2. Supports both IMGUI and UI Toolkit! And it can properly handle IMGUI drawer even with UI Toolkit enabled!
  3. Use and only use PropertyDrawer and DecoratorDrawer (except SaintsEditor, which is disabled by default), thus it will be compatible with most Unity Inspector enhancements like NaughtyAttributes and your custom drawer.
  4. Allow stack on many cases. Only attributes that modified the label itself, and the field itself can not be stacked. All other attributes can mostly be stacked.
  5. Allow dynamic arguments in many cases

Installation

If you have DOTween installed, please also ensure you do: Tools - Demigaint - DOTween Utility Panel, click Create ASMDEF

If you're using unitypackage or git submodule but you put this project under another folder rather than Assets/SaintsField, please also do the following:

Change Log

3.3.1

Fix Dropdown & AdvancedDropdown not work on list/array.

See the full change log.

Out-Of-Box Attributes

All attributes under this section can be used in your project without any extra setup.

namespace: SaintsField

Label & Text

RichLabel

Special Note:

Use it on an array/list will apply it to all the direct child element instead of the field label itself. You can use this to modify elements of an array/list field, in this way:

  1. Ensure you make it a callback: isCallback=true, or the richTextXml starts with $
  2. It'll pass the element value and index to your function
  3. Return the desired label content from the function
using SaintsField;

[RichLabel("<color=indigo><icon=eye.png /></color><b><color=red>R</color><color=green>a</color><color=blue>i</color><color=yellow>i</color><color=cyan>n</color><color=magenta>b</color><color=pink>o</color><color=orange>w</color></b>: <color=violet><label /></color>")]
public string _rainbow;

[RichLabel("$" + nameof(LabelCallback))]
public bool _callbackToggle;
private string LabelCallback() => _callbackToggle ? "<color=green><icon=eye.png /></color> <label/>" : "<icon=eye-slash.png /> <label/>";

[Space]
[RichLabel("$" + nameof(_propertyLabel))]
public string _propertyLabel;
private string _rainbow;

[Serializable]
private struct MyStruct
{
    [RichLabel("<color=green>HI!</color>")]
    public float LabelFloat;
}

[SerializeField]
[RichLabel("<color=green>Fixed For Struct!</color>")]
private MyStruct _myStructWorkAround;

richlabel

Here is an example of using on a array:

using SaintsField;

[RichLabel(nameof(ArrayLabels), true)]
public string[] arrayLabels;

// if you do not care about the actual value, use `object` as the first parameter
private string ArrayLabels(object _, int index) => $"<color=pink>[{(char)('A' + index)}]";

label_array

AboveRichLabel / BelowRichLabel

Like RichLabel, but it's rendered above/below the field in full width of view instead.

using SaintsField;

[SerializeField]
[AboveRichLabel("┌<icon=eye.png/><label />┐")]
[RichLabel("├<icon=eye.png/><label />┤")]
[BelowRichLabel("$" + nameof(BelowLabel))]
[BelowRichLabel("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~", groupBy: "example")]
[BelowRichLabel("==================================", groupBy: "example")]
private int _intValue;

private string BelowLabel() => "└<icon=eye.png/><label />┘";

full_width_label

OverlayRichLabel

Like RichLabel, but it's rendered on top of the field.

Only supports string/number type of field. Does not work with any kind of TextArea (multiple line) and Range.

Parameters:

using SaintsField;

[OverlayRichLabel("<color=grey>km/s")] public double speed = double.MinValue;
[OverlayRichLabel("<icon=eye.png/>")] public string text;
[OverlayRichLabel("<color=grey>/int", padding: 1)] public int count = int.MinValue;
[OverlayRichLabel("<color=grey>/long", padding: 1)] public long longInt = long.MinValue;
[OverlayRichLabel("<color=grey>suffix", end: true)] public string atEnd;

overlay_rich_label

PostFieldRichLabel

Like RichLabel, but it's rendered at the end of the field.

Parameters:

using SaintsField;

[PostFieldRichLabel("<color=grey>km/s")] public float speed;
[PostFieldRichLabel("<icon=eye.png/>", padding: 0)] public GameObject eye;
[PostFieldRichLabel("$" + nameof(TakeAGuess))] public int guess;

public string TakeAGuess()
{
    if(guess > 20)
    {
        return "<color=red>too high";
    }

    if (guess < 10)
    {
        return "<color=blue>too low";
    }

    return "<color=green>acceptable!";
}

post_field_rich_label

InfoBox/BelowInfoBox

Draw an info box above/below the field.

BelowInfoBox is a shortcut for [InfoBox(..., below: true)]

using SaintsField;

[field: SerializeField] private bool _show;

[Space]
[InfoBox("Hi\nwrap long line content content content content content content content content content content content content content content content content content content content content content content content content content", EMessageType.None)]
[BelowInfoBox("$" + nameof(DynamicMessage), EMessageType.Warning)]
[BelowInfoBox("$" + nameof(DynamicMessageWithIcon))]
[BelowInfoBox("Hi\n toggle content ", EMessageType.Info, nameof(_show))]
public bool _content;

private (EMessageType, string) DynamicMessageWithIcon => _content ? (EMessageType.Error, "False!") : (EMessageType.None, "True!");
private string DynamicMessage() => _content ? "False" : "True";

infobox

Separator / BelowSeparator

Draw text, separator, spaces for field on above / below with rich text & dynamic text support.

Parameters:

using SaintsField;

[Space(50)]

[Separator("Start")]
[Separator("Center", EAlign.Center)]
[Separator("End", EAlign.End)]
[BelowSeparator("$" + nameof(Callback))]
public string s3;
public string Callback() => s3;

[Space(50)]

[Separator]
public string s1;

[Separator(10)]  // this behaves like a space
[Separator("[ Hi <color=LightBlue>Above</color> ]", EColor.Aqua, EAlign.Center)]
[BelowSeparator("[ Hi <color=Silver>Below</color> ]", EColor.Brown, EAlign.Center)]
[BelowSeparator(10)]
public string hi;

[BelowSeparator]
public string s2;

image

This is very useful when you what to separate parent fields from the inherent:

using SaintsField;

public class SeparatorParent : MonoBehaviour
{
    [BelowSeparator("End Of <b><color=Aqua><container.Type/></color></b>", EAlign.Center, space: 10)]
    public string parent;
}

public class SeparatorInherent : SeparatorParent
{
    public string inherent;
}

image

SepTitle

A separator with text. (Recommend to use Separator instead.)

using SaintsField;

[SepTitle("Separate Here", EColor.Pink)]
public string content1;

[SepTitle(EColor.Green)]
public string content2;

sep_title

General Buttons

There are 3 general buttons:

All of them have the same arguments:

Note: Compared to Button in SaintsEditor, these buttons can receive the value of the decorated field, and will not get parameter drawers.

using SaintsField;

[SerializeField] private bool _errorOut;

[field: SerializeField] private string _labelByField;

[AboveButton(nameof(ClickErrorButton), nameof(_labelByField), true)]
[AboveButton(nameof(ClickErrorButton), "Click <color=green><icon='eye.png' /></color>!")]
[AboveButton(nameof(ClickButton), "$" + nameof(GetButtonLabel), groupBy: "OK")]
[AboveButton(nameof(ClickButton), "$" + nameof(GetButtonLabel), groupBy:  "OK")]

[PostFieldButton(nameof(ToggleAndError), nameof(GetButtonLabelIcon), true)]

[BelowButton(nameof(ClickButton), "$" + nameof(GetButtonLabel), groupBy: "OK")]
[BelowButton(nameof(ClickButton), "$" + nameof(GetButtonLabel), groupBy: "OK")]
[BelowButton(nameof(ClickErrorButton), "Below <color=green><icon='eye.png' /></color>!")]
public int _someInt;

private void ClickErrorButton() => Debug.Log("CLICKED!");

private string GetButtonLabel() =>
    _errorOut
        ? "Error <color=red>me</color>!"
        : "No <color=green>Error</color>!";

private string GetButtonLabelIcon() => _errorOut
    ? "<color=red><icon='eye.png' /></color>"
    : "<color=green><icon='eye.png' /></color>";

private void ClickButton(int intValue)
{
    Debug.Log($"get value: {intValue}");
    if(_errorOut)
    {
        throw new Exception("Expected exception!");
    }
}

private void ToggleAndError()
{
    Toggle();
    if(_errorOut)
    {
        throw new Exception("Expected exception!");
    }
}

private void Toggle() => _errorOut = !_errorOut;

button

Field Modifier

GameObjectActive

A toggle button to toggle the GameObject.activeSelf of the field.

This does not require the field to be GameObject. It can be a component which already attached to a GameObject.

using SaintsField;

[GameObjectActive] public GameObject _go;
[GameObjectActive] public GameObjectActiveExample _component;

gameobjectactive

SpriteToggle

A toggle button to toggle the Sprite of the target.

The field itself must be Sprite.

using SaintsField;

[field: SerializeField] private Image _image;
[field: SerializeField] private SpriteRenderer _sprite;

[SerializeField
 , SpriteToggle(nameof(_image))
 , SpriteToggle(nameof(_sprite))
] private Sprite _sprite1;
[SerializeField
 , SpriteToggle(nameof(_image))
 , SpriteToggle(nameof(_sprite))
] private Sprite _sprite2;

spritetoggle

MaterialToggle

A toggle button to toggle the Material of the target.

The field itself must be Material.

using SaintsField;

public Renderer targetRenderer;
[MaterialToggle(nameof(targetRenderer))] public Material _mat1;
[MaterialToggle(nameof(targetRenderer))] public Material _mat2;

mattoggle

ColorToggle

A toggle button to toggle color for Image, Button, SpriteRenderer or Renderer

The field itself must be Color.

using SaintsField;

// auto find on the target object
[SerializeField, ColorToggle] private Color _onColor;
[SerializeField, ColorToggle] private Color _offColor;

[Space]
// by name
[SerializeField] private Image _image;
[SerializeField, ColorToggle(nameof(_image))] private Color _onColor2;
[SerializeField, ColorToggle(nameof(_image))] private Color _offColor2;

color_toggle

Expandable

Make serializable object expandable. (E.g. ScriptableObject, MonoBehavior)

Known issue:

  1. IMGUI: if the target itself has a custom drawer, the drawer will not be used, because PropertyDrawer is not allowed to create an Editor class, thus it'll just iterate and draw all fields in the object.

    For more information about why this is impossible under IMGUI, see Issue 25

  2. IMGUI: the Foldout will NOT be placed at the left space like a Unity's default foldout component, because Unity limited the PropertyDrawer to be drawn inside the rect Unity gives. Trying outside of the rect will make the target non-interactable. But in early Unity (like 2019.1), Unity will force Foldout to be out of rect on top leve, but not on array/list level... so you may see different outcomes on different Unity version.

  3. UI Toolkit: ReadOnly (and DisableIf, EnableIf) can NOT disable the expanded fields. This is because InspectorElement does not work with SetEnable(false), neither with pickingMode=Ignore. This can not be fixed unless Unity fixes it.

using SaintsField;

[Expandable] public ScriptableObject _scriptable;

expandable

ReferencePicker

A dropdown to pick a referenced value for Unity's SerializeReference.

You can use this to pick non UnityObject object like interface or polymorphism class.

Limitation:

  1. The target must have a public constructor with no required arguments.
  2. It'll try to copy field values when changing types but not guaranteed. struct will not get copied value (it's too tricky to deal a struct)
using SaintsField;

[Serializable]
public class Base1Fruit
{
    public GameObject base1;
}

[Serializable]
public class Base2Fruit: Base1Fruit
{
    public int base2;
}

[Serializable]
public class Apple : Base2Fruit
{
    public string apple;
    public GameObject applePrefab;
}

[Serializable]
public class Orange : Base2Fruit
{
    public bool orange;
}

[SerializeReference, ReferencePicker]
public Base2Fruit item;

public interface IRefInterface
{
    public int TheInt { get; }
}

// works for struct
[Serializable]
public struct StructImpl : IRefInterface
{
    [field: SerializeField]
    public int TheInt { get; set; }
    public string myStruct;
}

[Serializable]
public class ClassDirect: IRefInterface
{
    [field: SerializeField, Range(0, 10)]
    public int TheInt { get; set; }
}

// abstruct type will be skipped
public abstract class ClassSubAbs : ClassDirect
{
    public abstract string AbsValue { get; }
}

[Serializable]
public class ClassSub1 : ClassSubAbs
{
    public string sub1;
    public override string AbsValue => $"Sub1: {sub1}";
}

[Serializable]
public class ClassSub2 : ClassSubAbs
{
    public string sub2;
    public override string AbsValue => $"Sub2: {sub2}";
}

[SerializeReference, ReferencePicker]
public IRefInterface myInterface;

reference_picker

ParticlePlay

A button to play a particle system of the field value, or the one on the field value.

Unity allows play ParticleSystem in the editor, but only if you selected the target GameObject. It can only play one at a time.

This decorator allows you to play multiple ParticleSystem as long as you have the expected fields.

Parameters:

Note: because of the limitation from Unity, it can NOT detect if a ParticleSystem is finished playing

[ParticlePlay] public ParticleSystem particle;
// It also works if the field target has a particleSystem component
[ParticlePlay, FieldType(typeof(ParticleSystem), false)] public GameObject particle2;

ParticlePlay

Field Re-Draw

This will change the look & behavior of a field.

Rate

A rating stars tool for an int field.

Parameters:

using SaintsField;

[Rate(0, 5)] public int rate0To5;
[Rate(1, 5)] public int rate1To5;
[Rate(3, 5)] public int rate3To5;

stars

FieldType

Ask the inspector to display another type of field rather than the field's original type.

This is useful when you want to have a GameObject prefab, but you want this target prefab to have a specific component (e.g. your own MonoScript, or a ParticalSystem). By using this you force the inspector to sign the required object that has your expected component but still gives you the original typed value to field.

This can also be used when you just want a type reference to a prefab, but Unity does not allow you to pick a prefab because "performance consideration".

Overload:

For each argument:

using SaintsField;

[SerializeField, FieldType(typeof(SpriteRenderer))]
private GameObject _go;

[SerializeField, FieldType(typeof(FieldTypeExample))]
private ParticleSystem _ps;

// this allows you to pick a perfab with field component on, which Unity will only give an empty picker.
[FieldType(EPick.Assets)] public Dummy dummyPrefab;

field_type

Dropdown

A dropdown selector. Supports reference type, sub-menu, separator, and disabled select item.

If you want a searchable dropdown, see AdvancedDropdown.

Example

using SaintsField;

[Dropdown(nameof(GetDropdownItems))] public float _float;

public GameObject _go1;
public GameObject _go2;
[Dropdown(nameof(GetDropdownRefs))] public GameObject _refs;

private DropdownList<float> GetDropdownItems()
{
    return new DropdownList<float>
    {
        { "1", 1.0f },
        { "2", 2.0f },
        { "3/1", 3.1f },
        { "3/2", 3.2f },
    };
}

private DropdownList<GameObject> GetDropdownRefs => new DropdownList<GameObject>
{
    {_go1.name, _go1},
    {_go2.name, _go2},
    {"NULL", null},
};

dropdown

To control the separator and disabled item

using SaintsField;

[Dropdown(nameof(GetDropdownItems))]
public Color color;

private DropdownList<Color> GetDropdownItems()
{
    return new DropdownList<Color>
    {
        { "Black", Color.black },
        { "White", Color.white },
        DropdownList<Color>.Separator(),
        { "Basic/Red", Color.red, true },  // the third arg means it's disabled
        { "Basic/Green", Color.green },
        { "Basic/Blue", Color.blue },
        DropdownList<Color>.Separator("Basic/"),
        { "Basic/Magenta", Color.magenta },
        { "Basic/Cyan", Color.cyan },
    };
}

And you can always manually add it:

DropdownList<Color> dropdownList = new DropdownList<Color>();
dropdownList.Add("Black", Color.black);  // add an item
dropdownList.Add("White", Color.white, true);  // and a disabled item
dropdownList.AddSeparator();  // add a separator

color

The look in the UI Toolkit with slashAsSub: false:

dropdown_ui_toolkit

AdvancedDropdown

A dropdown selector. Supports reference type, sub-menu, separator, search, and disabled select item, plus icon.

Known Issue:

  1. IMGUI: Using Unity's AdvancedDropdown. Unity's AdvancedDropdown allows to click the disabled item and close the popup, thus you can still click the disable item. This is a BUG from Unity. I managed to "hack" it around to show again the popup when you click the disabled item, but you will see the flick of the popup.

    This issue is not fixable unless Unity fixes it.

    This bug only exists in IMGUI

  2. UI Toolkit:

    The group indicator uses ToolbarBreadcrumbs. Sometimes you can see text get wrapped into lines. This is because Unity's UI Toolkit has some layout issue, that it can not has the same layout even with same elements+style+boundary size.

    This issue is not fixable unless Unity fixes it. This issue might be different on different Unity (UI Toolkit) version.

Arguments

AdvancedDropdownList<T>

using SaintsField;

[AdvancedDropdown(nameof(AdvDropdown)), BelowRichLabel(nameof(drops), true)] public int drops;

public AdvancedDropdownList<int> AdvDropdown()
{
    return new AdvancedDropdownList<int>("Days")
    {
        // a grouped value
        new AdvancedDropdownList<int>("First Half")
        {
            // with icon
            new AdvancedDropdownList<int>("Monday", 1, icon: "eye.png"),
            // no icon
            new AdvancedDropdownList<int>("Tuesday", 2),
        },
        new AdvancedDropdownList<int>("Second Half")
        {
            new AdvancedDropdownList<int>("Wednesday")
            {
                new AdvancedDropdownList<int>("Morning", 3, icon: "eye.png"),
                new AdvancedDropdownList<int>("Afternoon", 8),
            },
            new AdvancedDropdownList<int>("Thursday", 4, true, icon: "eye.png"),
        },
        // direct value
        new AdvancedDropdownList<int>("Friday", 5, true),
        AdvancedDropdownList<int>.Separator(),
        new AdvancedDropdownList<int>("Saturday", 6, icon: "eye.png"),
        new AdvancedDropdownList<int>("Sunday", 7, icon: "eye.png"),
    };
}

IMGUI

advanced_dropdown

UI Toolkit

advanced_dropdown_ui_toolkit

There is also a parser to automatically separate items as sub items using /:

using SaintsField;

[AdvancedDropdown(nameof(AdvDropdown))] public int selectIt;

public AdvancedDropdownList<int> AdvDropdown()
{
    return new AdvancedDropdownList<int>("Days")
    {
        {"First Half/Monday", 1, false, "star.png"},  // enabled, with icon
        {"First Half/Tuesday", 2},

        {"Second Half/Wednesday/Morning", 3, false, "star.png"},
        {"Second Half/Wednesday/Afternoon", 4},
        {"Second Half/Thursday", 5, true, "star.png"},  // disabled, with icon
        "",  // root separator
        {"Friday", 6, true},  // disabled
        "",
        {"Weekend/Saturday", 7, false, "star.png"},
        "Weekend/",  // separator under `Weekend` group
        {"Weekend/Sunday", 8, false, "star.png"},
    };
}

image

You can use this to make a searchable dropdown:

using SaintsField;

[AdvancedDropdown(nameof(AdvDropdownNoNest))] public int searchableDropdown;

public AdvancedDropdownList<int> AdvDropdownNoNest()
{
    return new AdvancedDropdownList<int>("Days")
    {
        {"Monday", 1},
        {"Tuesday", 2, true},  // disabled
        {"Wednesday", 3, false, "star.png"},  // enabled with icon
        {"Thursday", 4, true, "star.png"},  // disabled with icon
        {"Friday", 5},
        "",  // separator
        {"Saturday", 6},
        {"Sunday", 7},
    };
}

image

PropRange

Very like Unity's Range but allow you to dynamically change the range, plus allow to set range step.

For each argument:

using SaintsField;

public int min;
public int max;

[PropRange(nameof(min), nameof(max))] public float rangeFloat;
[PropRange(nameof(min), nameof(max))] public int rangeInt;

[PropRange(nameof(min), nameof(max), step: 0.5f)] public float rangeFloatStep;
[PropRange(nameof(min), nameof(max), step: 2)] public int rangeIntStep;

range

MinMaxSlider

A range slider for Vector2 or Vector2Int

For each argument:

a full-featured example:

using SaintsField;

[MinMaxSlider(-1f, 3f, 0.3f)]
public Vector2 vector2Step03;

[MinMaxSlider(0, 20, 3)]
public Vector2Int vector2IntStep3;

[MinMaxSlider(-1f, 3f)]
public Vector2 vector2Free;

[MinMaxSlider(0, 20)]
public Vector2Int vector2IntFree;

// not recommended
[SerializeField]
[MinMaxSlider(0, 100, minWidth:-1, maxWidth:-1)]
private Vector2Int _autoWidth;

[field: SerializeField, MinMaxSlider(-100f, 100f)]
public Vector2 OuterRange { get; private set; }

[SerializeField, MinMaxSlider(nameof(GetOuterMin), nameof(GetOuterMax), 1)] public Vector2Int _innerRange;

private float GetOuterMin() => OuterRange.x;
private float GetOuterMax() => OuterRange.y;

[field: SerializeField]
public float DynamicMin { get; private set; }
[field: SerializeField]
public float DynamicMax { get; private set; }

[SerializeField, MinMaxSlider(nameof(DynamicMin), nameof(DynamicMax))] private Vector2 _propRange;
[SerializeField, MinMaxSlider(nameof(DynamicMin), 100f)] private Vector2 _propLeftRange;
[SerializeField, MinMaxSlider(-100f, nameof(DynamicMax))] private Vector2 _propRightRange;

minmaxslider

EnumFlags

A toggle buttons group for enum flags (bit mask). It provides a button to toggle all bits on/off.

This field has compact mode and expanded mode.

For each argument:

Known Issue:

  1. IMGUI: If you have a lot of flags and you turn OFF autoExpand, The buttons WILL go off-view.
  2. UI Toolkit: when autoExpand=true, defaultExpanded will be ignored
using SaintsField;

[Serializable, Flags]
public enum BitMask
{
    None = 0,  // this will be hide as we will have an all/none button
    Mask1 = 1,
    Mask2 = 1 << 1,
    Mask3 = 1 << 2,
}

[EnumFlags] public BitMask myMask;

enum_flags

ResizableTextArea

This TextArea will always grow its height to fit the content. (minimal height is 3 rows).

Note: Unlike NaughtyAttributes, this does not have a text-wrap issue.

using SaintsField;

[SerializeField, ResizableTextArea] private string _short;
[SerializeField, ResizableTextArea] private string _long;
[SerializeField, RichLabel(null), ResizableTextArea] private string _noLabel;

resizabletextarea

AnimatorParam

A dropdown selector for an animator parameter.

using SaintsField;

[field: SerializeField]
public Animator Animator { get; private set;}

[AnimatorParam(nameof(Animator))]
private string animParamName;

[AnimatorParam(nameof(Animator))]
private int animParamHash;

animator_params

AnimatorState

A dropdown selector for animator state.

to get more useful info from the state, you can use AnimatorStateBase/AnimatorState type instead of string type.

AnimatorStateBase has the following properties:

AnimatorState added the following attribute(s):

Special Note: using AniamtorState/AnimatorStateBase with OnValueChanged, you can get a AnimatorStateChanged on the callback (rather than the value of the field). This is because AnimatorState expected any class/struct with satisfied fields.

using SaintsField;

[AnimatorState, OnValueChanged(nameof(OnChanged))]
public string stateName;

#if UNITY_EDITOR
[AnimatorState, OnValueChanged(nameof(OnChangedState))]
#endif
public AnimatorState state;

// This does not have a `animationClip`, thus it won't include a resource when serialized: only pure data.
[AnimatorState, OnValueChanged(nameof(OnChangedState))]
public AnimatorStateBase stateBase;

private void OnChanged(string changedValue) => Debug.Log(changedValue);
#if UNITY_EDITOR
private void OnChangedState(AnimatorStateChanged changedValue) => Debug.Log($"layerIndex={changedValue.layerIndex}, AnimatorControllerLayer={changedValue.layer}, AnimatorState={changedValue.state}, animationClip={changedValue.animationClip}, subStateMachineNameChain={string.Join("/", changedValue.subStateMachineNameChain)}");
#endif

animator_state

Layer

A dropdown selector for layer.

Note: want a bitmask layer selector? Unity already has it. Just use public LayerMask myLayerMask;

using SaintsField;

[Layer] public string layerString;
[Layer] public int layerInt;

// Unity supports multiple layer selector
public LayerMask myLayerMask;

layer

Scene

A dropdown selector for a scene in the build list, plus a "Edit Scenes In Build..." option to directly open the "Build Settings" window where you can change building scenes.

using SaintsField;

[Scene] public int _sceneInt;
[Scene] public string _sceneString;

image

SortingLayer

A dropdown selector for sorting layer, plus a "Edit Sorting Layers..." option to directly open "Sorting Layers" tab from "Tags & Layers" inspector where you can change sorting layers.

using SaintsField;

[SortingLayer] public string _sortingLayerString;
[SortingLayer] public int _sortingLayerInt;

image

Tag

A dropdown selector for a tag.

using SaintsField;

[Tag] public string tag;

tag

InputAxis

A string dropdown selector for an input axis, plus a "Open Input Manager..." option to directly open "Input Manager" tab from "Project Settings" window where you can change input axes.

using SaintsField;

[InputAxis] public string inputAxis;

image

LeftToggle

A toggle button on the left of the bool field. Only works on boolean field.

IMGUI: To use with RichLabel, you need to add 6 spaces ahead as a hack

using SaintsField;

[LeftToggle] public bool myToggle;
[LeftToggle, RichLabel("      <color=green><label />")] public bool richToggle;

left_toggle

CurveRange

A curve drawer for AnimationCurve which allow to set bounds and color

Override 1:

Override 2:

using SaintsField;

[CurveRange(-1, -1, 1, 1)]
public AnimationCurve curve;

[CurveRange(EColor.Orange)]
public AnimationCurve curve1;

[CurveRange(0, 0, 5, 5, EColor.Red)]
public AnimationCurve curve2;

curverange

ProgressBar

A progress bar for float or int field. This behaves like a slider but more fancy.

Note: Unlike NaughtyAttributes (which is read-only), this is interactable.

Parameters:

using SaintsField;

[ProgressBar(10)] public int myHp;
// control step for float rather than free value
[ProgressBar(0, 100f, step: 0.05f, color: EColor.Blue)] public float myMp;

[Space]
public int minValue;
public int maxValue;

[ProgressBar(nameof(minValue)
        , nameof(maxValue)  // dynamic min/max
        , step: 0.05f
        , backgroundColorCallback: nameof(BackgroundColor)  // dynamic background color
        , colorCallback: nameof(FillColor)  // dynamic fill color
        , titleCallback: nameof(Title)  // dynamic title, does not support rich label
    ),
]
[RichLabel(null)]  // make this full width
public float fValue;

private EColor BackgroundColor() => fValue <= 0? EColor.Brown: EColor.CharcoalGray;

private Color FillColor() => Color.Lerp(Color.yellow, EColor.Green.GetColor(), Mathf.Pow(Mathf.InverseLerp(minValue, maxValue, fValue), 2));

private string Title(float curValue, float min, float max, string label) => curValue < 0 ? $"[{label}] Game Over: {curValue}" : $"[{label}] {curValue / max:P}";

progress_bar

ResourcePath

A tool to pick an resource path (a string) with:

  1. required types or interfaces
  2. display a type instead of showing a string
  3. pick a suitable object using a custom picker

Parameters:

Known Issue: IMGUI, manually sign a null object by using Unity's default pick will sign an empty string instead of null. Use custom pick to avoid this inconsistency.

using SaintsField;

// resource: display as a MonoScript, requires a BoxCollider
[ResourcePath(typeof(Dummy), typeof(BoxCollider))]
[InfoBox(nameof(myResource), true)]
public string myResource;

// AssetDatabase path
[Space]
[ResourcePath(EStr.AssetDatabase, typeof(Dummy), typeof(BoxCollider))]
[InfoBox(nameof(myAssetPath), true)]
public string myAssetPath;

// GUID
[Space]
[ResourcePath(EStr.Guid, typeof(Dummy), typeof(BoxCollider))]
[InfoBox(nameof(myGuid), true)]
public string myGuid;

// prefab resource
[ResourcePath(typeof(GameObject))]
[InfoBox(nameof(resourceNoRequire), true)]
public string resourceNoRequire;

// requires to have a Dummy script attached, and has interface IMyInterface
[ResourcePath(typeof(Dummy), typeof(IMyInterface))]
[InfoBox(nameof(myInterface), true)]
public string myInterface;

resource_path

Field Utilities

AssetPreview

Show an image preview for prefabs, Sprite, Texture2D, etc. (Internally use AssetPreview.GetAssetPreview)

Note: Recommended to use AboveImage/BelowImage for image/sprite/texture2D.

using SaintsField;

[AssetPreview(20, 100)] public Texture2D _texture2D;
[AssetPreview(50)] public GameObject _go;
[AssetPreview(above: true)] public Sprite _sprite;

asset_preview

AboveImage/BelowImage

Show an image above/below the field.

using SaintsField;

[AboveImage(nameof(spriteField))]
// size and group
[BelowImage(nameof(spriteField), maxWidth: 25, groupBy: "Below1")]
[BelowImage(nameof(spriteField), maxHeight: 20, align: EAlign.End, groupBy: "Below1")]
public Sprite spriteField;

// align
[BelowImage(nameof(spriteField), maxWidth: 20, align: EAlign.FieldStart)]
[BelowImage(nameof(spriteField), maxWidth: 20, align: EAlign.Start)]
[BelowImage(nameof(spriteField), maxWidth: 20, align: EAlign.Center)]
[BelowImage(nameof(spriteField), maxWidth: 20, align: EAlign.End)]
public string alignField;

show_image

OnValueChanged

Call a function every time the field value is changed

Special Note: AnimatorState will have a different OnValueChanged parameter passed in. See AnimatorState for more detail.

using SaintsField;

// no params
[OnValueChanged(nameof(Changed))]
public int value;
private void Changed()
{
    Debug.Log($"changed={value}");
}

// with params to get the new value
[OnValueChanged(nameof(ChangedAnyType))]
public GameObject go;

// it will pass the index too if it's inside an array/list
[OnValueChanged(nameof(ChangedAnyType))]
public SpriteRenderer[] srs;

// it's ok to set it as the super class
private void ChangedAnyType(object anyObj, int index=-1)
{
    Debug.Log($"changed={anyObj}@{index}");
}

ReadOnly/DisableIf/EnableIf

A tool to set field enable/disable status. Supports callbacks (function/field/property) and enum types. by using multiple arguments and decorators, you can make logic operation with it.

ReadOnly equals DisableIf, EnableIf is the opposite of DisableIf

Arguments:

For callback (functions, fields, properties):

For ReadOnly/DisableIf: The field will be disabled if ALL condition is true (and operation)

For EnableIf: The field will be enabled if ANY condition is true (or operation)

For multiple attributes: The field will be disabled if ANY condition is true (or operation)

Logic example:

A simple example:

using SaintsField;

[ReadOnly(nameof(ShouldBeDisabled))] public string disableMe;

private bool ShouldBeDisabled  // change the logic here
{
    return true;
}

It also supports enum types. The syntax is like this:

using SaintsField;

[Serializable]
public enum EnumToggle
{
    Off,
    On,
}
public EnumToggle enum1;
[ReadOnly(nameof(enum1), EnumToggle.On)] public string enumReadOnly;

A more complex example:

using SaintsField;

[Serializable]
public enum EnumToggle
{
    Off,
    On,
}

public EnumToggle enum1;
public EnumToggle enum2;
public bool bool1;
public bool bool2 {
    return true;
}

// example of checking two normal callbacks and two enum callbacks
[EnableIf(nameof(bool1), nameof(bool2), nameof(enum1), EnumToggle.On, nameof(enum2), EnumToggle.On)] public string bool12AndEnum12;

A more complex example about logic operation:

using SaintsField;

[ReadOnly] public string directlyReadOnly;

[SerializeField] private bool _bool1;
[SerializeField] private bool _bool2;
[SerializeField] private bool _bool3;
[SerializeField] private bool _bool4;

[SerializeField]
[ReadOnly(nameof(_bool1))]
[ReadOnly(nameof(_bool2))]
[RichLabel("readonly=1||2")]
private string _ro1and2;

[SerializeField]
[ReadOnly(nameof(_bool1), nameof(_bool2))]
[RichLabel("readonly=1&&2")]
private string _ro1or2;

[SerializeField]
[ReadOnly(nameof(_bool1), nameof(_bool2))]
[ReadOnly(nameof(_bool3), nameof(_bool4))]
[RichLabel("readonly=(1&&2)||(3&&4)")]
private string _ro1234;

readonly

EMode example:

using SaintsField;

public bool boolVal;

[DisableIf(EMode.Edit)] public string disEditMode;
[DisableIf(EMode.Play)] public string disPlayMode;

[DisableIf(EMode.Edit, nameof(boolVal))] public string disEditAndBool;
[DisableIf(EMode.Edit), DisableIf(nameof(boolVal))] public string disEditOrBool;

[EnableIf(EMode.Edit)] public string enEditMode;
[EnableIf(EMode.Play)] public string enPlayMode;

[EnableIf(EMode.Edit, nameof(boolVal))] public string enEditOrBool;
// dis=!editor || dis=!bool => en=editor&&bool
[EnableIf(EMode.Edit), EnableIf(nameof(boolVal))] public string enEditAndBool;

It also supports value comparison like ==, >, <=. Read more in the "Value Comparison for Show/Hide/Enable/Disable-If" section.

ShowIf / HideIf

Show or hide the field based on a condition. . Supports callbacks (function/field/property) and enum types. by using multiple arguments and decorators, you can make logic operation with it.

Arguments:

You can use multiple ShowIf, HideIf, and even a mix of the two.

For ShowIf: The field will be shown if ALL condition is true (and operation)

For HideIf: The field will be hidden if ANY condition is true (or operation)

For multiple attributes: The field will be shown if ANY condition is true (or operation)

For example, [ShowIf(A...), ShowIf(B...)] will be shown if ShowIf(A...) || ShowIf(B...) is true.

HideIf is the opposite of ShowIf. Please note "the opposite" is like the logic operation, like !(A && B) is !A || !B, !(A || B) is !A && !B.

A simple example:

using SaintsField;

[ShowIf(nameof(ShouldShow))]
public int showMe;

public bool ShouldShow()  // change the logic here
{
    return true;
}

It also supports enum types. The syntax is like this:

using SaintsField;

[Serializable]
public enum EnumToggle
{
    Off,
    On,
}
public EnumToggle enum1;
[ShowIf(nameof(enum1), EnumToggle.On)] public string enum1Show;

A more complex example:

using SaintsField;

[Serializable]
public enum EnumToggle
{
    Off,
    On,
}

public EnumToggle enum1;
public EnumToggle enum2;
public bool bool1;
public bool bool2 {
    return true;
}

// example of checking two normal callbacks and two enum callbacks
[ShowIf(nameof(bool1), nameof(bool2), nameof(enum1), EnumToggle.On, nameof(enum2), EnumToggle.On)] public string bool12AndEnum12;

A more complex example about logic operation:

using SaintsField;

public bool _bool1;
public bool _bool2;
public bool _bool3;
public bool _bool4;

[ShowIf(nameof(_bool1))]
[ShowIf(nameof(_bool2))]
[RichLabel("<color=red>show=1||2")]
public string _showIf1Or2;

[ShowIf(nameof(_bool1), nameof(_bool2))]
[RichLabel("<color=green>show=1&&2")]
public string _showIf1And2;

[HideIf(nameof(_bool1))]
[HideIf(nameof(_bool2))]
[RichLabel("<color=blue>show=!1||!2")]
public string _hideIf1Or2;

[HideIf(nameof(_bool1), nameof(_bool2))]
[RichLabel("<color=yellow>show=!(1||2)=!1&&!2")]
public string _hideIf1And2;

[ShowIf(nameof(_bool1))]
[HideIf(nameof(_bool2))]
[RichLabel("<color=magenta>show=1||!2")]
public string _showIf1OrNot2;

[ShowIf(nameof(_bool1), nameof(_bool2))]
[ShowIf(nameof(_bool3), nameof(_bool4))]
[RichLabel("<color=orange>show=(1&&2)||(3&&4)")]
public string _showIf1234;

[HideIf(nameof(_bool1), nameof(_bool2))]
[HideIf(nameof(_bool3), nameof(_bool4))]
[RichLabel("<color=pink>show=!(1||2)||!(3||4)=(!1&&!2)||(!3&&!4)")]
public string _hideIf1234;

showifhideif

Example about EMode:

using SaintsField;

public bool boolValue;

[ShowIf(EMode.Edit)] public string showEdit;
[ShowIf(EMode.Play)] public string showPlay;

[ShowIf(EMode.Edit, nameof(boolValue))] public string showEditAndBool;
[ShowIf(EMode.Edit), ShowIf(nameof(boolValue))] public string showEditOrBool;

[HideIf(EMode.Edit)] public string hideEdit;
[HideIf(EMode.Play)] public string hidePlay;

[HideIf(EMode.Edit, nameof(boolValue))] public string hideEditOrBool;
[HideIf(EMode.Edit), HideIf(nameof(boolValue))] public string hideEditAndBool;

It also supports value comparison like ==, >, <=. Read more in the "Value Comparison for Show/Hide/Enable/Disable-If" section.

Required

Reminding a given reference type field to be required.

This will check if the field value is a truly value, which means:

  1. ValuedType like struct will always be truly because struct is not nullable and Unity will fill a default value for it no matter what
  2. It works on reference type and will NOT skip Unity's life-circle null check
  3. You may not want to use it on int, float (because only 0 is not truly) or bool, but it's still allowed if you insist

Parameters:

using SaintsField;

[Required("Add this please!")] public Sprite _spriteImage;
// works for the property field
[field: SerializeField, Required] public GameObject Go { get; private set; }
[Required] public UnityEngine.Object _object;
[SerializeField, Required] private float _wontWork;

[Serializable]
public struct MyStruct
{
    public int theInt;
}

[Required]
public MyStruct myStruct;

image

ValidateInput

Validate the input of the field when the value changes.

using SaintsField;

// string callback
[ValidateInput(nameof(OnValidateInput))]
public int _value;
private string OnValidateInput() => _value < 0 ? $"Should be positive, but gets {_value}" : null;

// property validate
[ValidateInput(nameof(boolValidate))]
public bool boolValidate;

// bool callback
[ValidateInput(nameof(BoolCallbackValidate))]
public string boolCallbackValidate;
private bool BoolCallbackValidate() => boolValidate;

// with callback params
[ValidateInput(nameof(ValidateWithReqParams))]
public int withReqParams;
private string ValidateWithReqParams(int v) => $"ValidateWithReqParams: {v}";

// with optional callback params
[ValidateInput(nameof(ValidateWithOptParams))]
public int withOptionalParams;

private string ValidateWithOptParams(string sth="a", int v=0) => $"ValidateWithOptionalParams[{sth}]: {v}";

// with array index callback
[ValidateInput(nameof(ValidateValArr))]
public int[] valArr;

private string ValidateValArr(int v, int index) => $"ValidateValArr[{index}]: {v}";

validateinput

MinValue / MaxValue

Limit for int/float field

They have the same overrides:

using SaintsField;

public int upLimit;

[MinValue(0), MaxValue(nameof(upLimit))] public int min0Max;
[MinValue(nameof(upLimit)), MaxValue(10)] public float fMinMax10;

minmax

GetComponent

Automatically sign a component to a field, if the field value is null and the component is already attached to current target. (First one found will be used)

using SaintsField;

[GetComponent] public BoxCollider otherComponent;
[GetComponent] public GameObject selfGameObject;  // get the GameObject itself
[GetComponent] public RectTransform selfRectTransform;  // useful for UI

[GetComponent] public GetComponentExample selfScript;  // yeah you can get your script itself
[GetComponent] public Dummy otherScript;  // other script

get_component

GetComponentInChildren

Automatically sign a component to a field, if the field value is null and the component is already attached to itself or its child GameObjects. (First one found will be used)

NOTE: Like GetComponentInChildren by Unity, this will check the target object itself.

using SaintsField;

[GetComponentInChildren] public BoxCollider childBoxCollider;
// by setting compType, you can sign it as a different type
[GetComponentInChildren(compType: typeof(Dummy))] public BoxCollider childAnotherType;
// and GameObject field works too
[GetComponentInChildren(compType: typeof(BoxCollider))] public GameObject childBoxColliderGo;

get_component_in_children

FindComponent

Automatically find a component under the current target. This is very similar to Unity's transform.Find, except it accepts many paths, and it's returning value is not limited to transform

using SaintsField;

[FindComponent("sub/dummy")] public Dummy subDummy;
[FindComponent("sub/dummy")] public GameObject subDummyGo;
[FindComponent("sub/noSuch", "sub/dummy")] public Transform subDummyTrans;

find_component

GetComponentInParent / GetComponentInParents

Automatically sign a component to a field, if the field value is null and the component is already attached to its parent GameObject(s). (First one found will be used)

Note:

  1. Like Unity's GetComponentInParent, this will check the target object itself.
  2. GetComponentInParent will only check the target & its direct parent. GetComponentInParents will search all the way up to the root.

Parameters:

using SaintsField;

[GetComponentInParent] public SpriteRenderer directParent;
[GetComponentInParent(typeof(SpriteRenderer))] public GameObject directParentDifferentType;
[GetComponentInParent] public BoxCollider directNoSuch;

[GetComponentInParents] public SpriteRenderer searchParent;
[GetComponentInParents(compType: typeof(SpriteRenderer))] public GameObject searchParentDifferentType;
[GetComponentInParents] public BoxCollider searchNoSuch;

get_component_in_parents

GetComponentInScene

Automatically sign a component to a field, if the field value is null and the component is in the currently opened scene. (First one found will be used)

using SaintsField;

[GetComponentInScene] public Dummy dummy;
// by setting compType, you can sign it as a different type
[GetComponentInScene(compType: typeof(Dummy))] public RectTransform dummyTrans;
// and GameObject field works too
[GetComponentInScene(compType: typeof(Dummy))] public GameObject dummyGo;

get_component_in_scene

GetComponentByPath

Automatically sign a component to a field by a given path.

The path is a bit like html's XPath but with less functions:

Path Meaning
/ Separator. Using at start means the root of the current scene.
// Separator. Any descendant children
. Node. Current node
.. Node. Parent node
* All nodes
name Node. Any nodes with this name
[last()] Index Filter. Last of results
[index() > 1] Index Filter. Node index that is greater than 1
[0] Index Filter. First node in the results

For example:

using SaintsField;

// starting from root, search any object with name "Dummy"
[GetComponentByPath("///Dummy")] public GameObject dummy;
// first child of current object
[GetComponentByPath("./*[1]")] public GameObject direct1;
// child of current object which has index greater than 1
[GetComponentByPath("./*[index() > 1]")] public GameObject directPosTg1;
// last child of current object
[GetComponentByPath("./*[last()]")] public GameObject directLast;
// re-sign the target if mis-match
[GetComponentByPath(EGetComp.NoResignButton | EGetComp.ForceResign, "./DirectSub")] public GameObject directSubWatched;
// without "ForceResign", it'll display a reload button if mis-match
// with multiple paths, it'll search from left to right
[GetComponentByPath("/no", "./DirectSub1")] public GameObject directSubMulti;
// if no match, it'll show an error message
[GetComponentByPath("/no", "///sth/else/../what/.//ever[last()]/goes/here")] public GameObject notExists;

get_component_by_path

GetPrefabWithComponent

Automatically sign a prefab to a field, if the field value is null and the prefab has the component. (First one found will be used)

Recommended to use it with FieldType!

using SaintsField;

[GetPrefabWithComponent] public Dummy dummy;
// get the prefab itself
[GetPrefabWithComponent(compType: typeof(Dummy))] public GameObject dummyPrefab;
// works so good with `FieldType`
[GetPrefabWithComponent(compType: typeof(Dummy)), FieldType(typeof(Dummy))] public GameObject dummyPrefabFieldType;

get_prefab_with_component

GetScriptableObject

Automatically sign a ScriptableObject file to this field. (First one found will be used)

Recommended to use it with Expandable!

using SaintsField;

[GetScriptableObject] public Scriptable mySo;
[GetScriptableObject("RawResources/ScriptableIns")] public Scriptable mySoSuffix;

GetScriptableObject

AddComponent

Automatically add a component to the current target if the target does not have this component. (This will not sign the component added)

Recommended to use it with GetComponent!

using SaintsField;

[AddComponent, GetComponent] public Dummy dummy;
[AddComponent(typeof(BoxCollider)), GetComponent] public GameObject thisObj;

add_component

ButtonAddOnClick

Add a callback to a button's onClick event. Note this at this point does only supports callback with no arguments.

Note: SaintsEditor has a more powerful OnButtonClick. If you have SaintsEditor enabled, it's recommended to use OnButtonClick instead.

using SaintsField;

[GetComponent, ButtonAddOnClick(nameof(OnClick))] public Button button;

private void OnClick()
{
    Debug.Log("Button clicked!");
}

buttonaddonclick

RequireType

Allow you to specify the required component(s) or interface(s) for a field.

If the signed field does not meet the requirement, it'll:

customPicker will allow you to pick an object which are already meet the requirement(s).

Overload:

For each argument:

using SaintsField;

public interface IMyInterface {}

public class MyInter1: MonoBehaviour, IMyInterface {}
public class MySubInter: MyInter1 {}

public class MyInter2: MonoBehaviour, IMyInterface {}

[RequireType(typeof(IMyInterface))] public SpriteRenderer interSr;
[RequireType(typeof(IMyInterface), typeof(SpriteRenderer))] public GameObject interfaceGo;

[RequireType(true, typeof(IMyInterface))] public SpriteRenderer srNoPickerFreeSign;
[RequireType(true, typeof(IMyInterface))] public GameObject goNoPickerFreeSign;

RequireType

ArraySize

A decorator that limit the size of the array or list.

Note: Because of the limitation of PropertyDrawer:

  1. Delete an element will first be deleted, then the array will duplicated the last element.
  2. UI Toolkit: you might see the UI flicked when you remove an element.

Enable SaintsEditor if possible, otherwise:

  1. When the field is 0 length, it'll not be filled to target size.
  2. You can always change it to 0 size.

Parameters:

using SaintsField;

[ArraySize(3)]
public string[] myArr;

image

SaintsRow

SaintsRow attribute allows you to draw Button, Layout, ShowInInspector, DOTweenPlay etc (all SaintsEditor attributes) in a Serializable object (usually a class or a struct).

This attribute does NOT need SaintsEditor enabled. It's an out-of-box tool.

Parameters:

Special Note:

  1. After applying this attribute, only pure PropertyDrawer, and decorators from SaintsEditor works on this target. Which means, using third party's PropertyDrawer is fine, but decorator of Editor level (e.g. Odin's Button, NaughtyAttributes' Button) will not work.
  2. IMGUI: ELayout.Horizontal does not work here
  3. IMGUI: DOTweenPlay might be a bit buggy displaying the playing/pause/stop status for each function.
using SaintsField;
using SaintsField.Playa;

[Serializable]
public struct Nest
{
    public string nest2Str;  // normal field
    [Button]  // function button
    private void Nest2Btn() => Debug.Log("Call Nest2Btn");
    // static field (non serializable)
    [ShowInInspector] public static Color StaticColor => Color.cyan;
    // const field (non serializable)
    [ShowInInspector] public const float Pi = 3.14f;
    // normal attribute drawer works as expected
    [BelowImage(maxWidth: 25)] public SpriteRenderer spriteRenderer;

    [DOTweenPlay]  // DOTween helper
    private Sequence PlayColor()
    {
        return DOTween.Sequence()
            .Append(spriteRenderer.DOColor(Color.red, 1f))
            .Append(spriteRenderer.DOColor(Color.green, 1f))
            .Append(spriteRenderer.DOColor(Color.blue, 1f))
            .SetLoops(-1);
    }
    [DOTweenPlay("Position")]
    private Sequence PlayTween2()
    {
        return DOTween.Sequence()
                .Append(spriteRenderer.transform.DOMove(Vector3.up, 1f))
                .Append(spriteRenderer.transform.DOMove(Vector3.right, 1f))
                .Append(spriteRenderer.transform.DOMove(Vector3.down, 1f))
                .Append(spriteRenderer.transform.DOMove(Vector3.left, 1f))
                .Append(spriteRenderer.transform.DOMove(Vector3.zero, 1f))
            ;
    }
}

[SaintsRow]
public Nest n1;

saints_row

alternatively, you can make a drawer for your data type to omit [SaintsRow] everywhere:

using SaintsField.Editor.Playa;

[CustomPropertyDrawer(typeof(Nest))]
public class MySaintsRowAttributeDrawer: SaintsRowAttributeDrawer {}

To show a Serializable inline like it's directly in the MonoBehavior:

using SaintsField;

[Serializable]
public struct MyStruct
{
    public int structInt;
    public bool structBool;
}

[SaintsRow(inline: true)]
public MyStruct myStructInline;

public string normalStringField;

saints_row_inline

Other Tools

Addressable

These tools are for Unity Addressable. It's there only if you have Addressable installed.

Namespace: SaintsField.Addressable

If you encounter issue because of version incompatible with your installation, you can add a macro SAINTSFIELD_ADDRESSABLE_DISABLE to disable this component (See "Add a Macro" section for more information)

AddressableLabel

A picker to select an addressable label.

using SaintsField.Addressable;

[AddressableLabel]
public string addressableLabel;

addressable_label

AddressableAddress

A picker to select an addressable address (key).

using SaintsField.Addressable;

[AddressableAddress]  // from all group
public string address;

[AddressableAddress("Packed Assets")]  // from this group
public string addressInGroup;

[AddressableAddress(null, "Label1", "Label2")]  // either has label `Label1` or `Label2`
public string addressLabel1Or2;

// must have both label `default` and `Label1`
// or have both label `default` and `Label2`
[AddressableAddress(null, "default && Label1", "default && Label2")]
public string addressLabelAnd;

addressable_address

AI Navigation

These tools are for Unity AI Navigation (NavMesh). It's there only if you have AI Navigation installed.

Namespace: SaintsField.AiNavigation

Adding marco SAINTSFIELD_AI_NAVIGATION_DISABLED to disable this component. (See "Add a Macro" section for more information)

NavMeshAreaMask

Select NavMesh area bit mask for an integer field. (So the integer value can be used in SamplePathPosition)

using SaintsField.AiNavigation;

[NavMeshAreaMask]
public int areaMask;

nav_mesh_area_mask

NavMeshArea

Select a NavMesh area for a string or an interger field.

using SaintsField.AiNavigation;

[NavMeshArea]  // then you can use `areaSingleMask1 | areaSingleMask2` to get multiple masks
public int areaSingleMask;

[NavMeshArea(false)]  // then you can use `1 << areaValue` to get areaSingleMask
public int areaValue;

[NavMeshArea]  // then you can use `NavMesh.GetAreaFromName(areaName)` to get areaValue
public int areaName;

nav_mesh_area

SaintsArray/SaintsList

Unity does not allow to serialize two dimensional array or list. SaintsArray and SaintsList are there to help.

using SaintsField;

// two dimensional array
public SaintsArray<GameObject>[] gameObjects2;
public SaintsArray<SaintsArray<GameObject>> gameObjects2Nest;
// four dimensional array, if you like.
// it can be used with array, but ensure the `[]` is always at the end.
public SaintsArray<SaintsArray<SaintsArray<GameObject>>>[] gameObjects4;

image

SaintsArray implements IReadOnlyList, SaintsList implements IList:

using SaintsField;

// SaintsArray
GameObject firstGameObject = saintsArrayGo[0];
Debug.Log(saintsArrayGo.value); // the actual array value

// SaintsList
saintsListGo.Add(new GameObject());
saintsListGo.RemoveAt(0);
Debug.Log(saintsListGo.value);  // the actual list value

These two can be easily converted to array/list:

using SaintsField;

// SaintsArray to Array
GameObject[] arrayGo = saintsArrayGo;
// Array to SaintsArray
SaintsArray<GameObject> expSaintsArrayGo = (SaintsArray<GameObject>)arrayGo;

// SaintsList to List
List<GameObject> ListGo = saintsListGo;
// List to SaintsList
SaintsList<GameObject> expSaintsListGo = (SaintsList<GameObject>)ListGo;

Because it's actually a struct, you can also implement your own Array/List, using [SaintsArray]. Here is an example of customize your own struct:

using SaintsField;

// example: using IWrapProp so you don't need to specify the type name everytime
[Serializable]
public class MyList : IWrapProp
{
    [SerializeField] public List<string> myStrings;

#if UNITY_EDITOR
    public string EditorPropertyName => nameof(myStrings);
#endif
}

[SaintsArray]
public MyList[] myLis;

// example: any Serializable which hold a serialized array/list is fine
[Serializable]
public struct MyArr
{
    [RichLabel(nameof(MyInnerRichLabel), true)]
    public int[] myArray;

    private string MyInnerRichLabel(object _, int index) => $"<color=pink> Inner [{(char)('A' + index)}]";
}

[RichLabel(nameof(MyOuterLabel), true), SaintsArray("myArray")]
public MyArr[] myArr;

private string MyOuterLabel(object _, int index) => $"<color=Lime> Outer {index}";

image

alternatively, you can make a custom drawer for your data type to avoid adding [SaintsArray] to every field:

// Put it under an Editor folder, or with UNITY_EDITOR
#if UNITY_EDITOR
using SaintsField.Editor.Drawers.TypeDrawers;

[CustomPropertyDrawer(typeof(MyList))]
public class MyArrayDrawer: SaintsArrayDrawer {}
#endif

SaintsInterface<,>

SaintsInterface is a simple tool to serialize a UnityEngine.Object (usually your script component) with a required interface.

You can access the interface with the .I field, and actual object with .V field.

It provides a drawer to let you only select the object that implements the interface.

For SaintsInterface<TObject, TInterface>:

using SaintsField;

public SaintsInterface<Component, IInterface1> myInter1;

// for old unity
[Serializable]
public class Interface1 : SaintsInterface<Component, IInterface1>
{
}

public Interface1 myInherentInterface1;

private void Awake()
{
    Debug.Log(myInter1.I);  // the actual interface
    Debug.Log(myInter1.V);  // the actual serialized object
}

image

Special Note:

Though you can inherit SaintsInterface, but don't inherit it with 2 steps of generic, for example:

// Don't do this!
class AnyObjectInterface<T>: SaintsInterface<UnityEngine.Object, T> {}
class MyInterface: AnyObjectInterface<IInterface1> {}

The drawer will fail for AnyObjectInterface and MyInterface because in Unity's C# runtime, it can not report correctly generic arguments. For more information, see the comment of the answer in this stackoverflow.

SaintsEditor

SaintsField is a UnityEditor.Editor level component.

Namespace: SaintsField.Playa

Compared with NaughtyAttributes and MarkupAttributes:

  1. NaughtyAttributes has Button, and has a way to show a non-field property(ShowNonSerializedField, ShowNativeProperty), but it does not retain the order of these fields, but only draw them at the end. It has layout functions (Foldout, BoxGroup) but it has not Tab layout, and much less powerful compared to MarkupAttributes. It's IMGUI only.
  2. MarkupAttributes is super powerful in layout, but it does not have a way to show a non-field property. It's IMGUI only. It also supports shader editor.
  3. SaintsEditor

    • Layout like markup attributes. Compared to MarkupAttributes, it allows a non-field property (e.g. a button or a ShowInInspector inside a group) (like OdinInspector). it has LayoutGrooup/LayoutEnd for convenience coding.
    • It provides Button (with less functions) and a way to show a non-field property (ShowInInspector).
    • It tries to retain the order, and allows you to use [Ordered] when it can not get the order (c# does not allow to obtain all the orders).
    • Supports both UI Toolkit and IMGUI.

Please note, any Editor level component can not work together with each other (it will not cause trouble, but only one will actually work). Which means, OdinInspector, NaughtyAttributes, MarkupAttributes, SaintsEditor can not work together.

If you are interested, here is how to use it.

Setup SaintsEditor

Window - Saints - Apply SaintsEditor. After the project finish re-compile, go Window - Saints - SaintsEditor to tweak configs.

If you want to do it manually, check ApplySaintsEditor.cs for more information

DOTweenPlay

A method decorator to play a DOTween animation returned by the method.

The method should not have required parameters, and need to return a Tween or a Sequence (Sequence is actually also a tween).

Parameters:

using SaintsField.Playa;
using SaintsField;

[GetComponent]
public SpriteRenderer spriteRenderer;

[DOTweenPlay]
private Sequence PlayColor()
{
    return DOTween.Sequence()
        .Append(spriteRenderer.DOColor(Color.red, 1f))
        .Append(spriteRenderer.DOColor(Color.green, 1f))
        .Append(spriteRenderer.DOColor(Color.blue, 1f))
        .SetLoops(-1);  // Yes you can make it a loop
}

[DOTweenPlay("Position")]
private Sequence PlayTween2()
{
    return DOTween.Sequence()
        .Append(spriteRenderer.transform.DOMove(Vector3.up, 1f))
        .Append(spriteRenderer.transform.DOMove(Vector3.right, 1f))
        .Append(spriteRenderer.transform.DOMove(Vector3.down, 1f))
        .Append(spriteRenderer.transform.DOMove(Vector3.left, 1f))
        .Append(spriteRenderer.transform.DOMove(Vector3.zero, 1f))
    ;
}

The first row is global control. Stop it there will stop all preview.

The check of each row means auto play when you click the start in the global control.

dotween_play

Setup

To use DOTweenPlay: Tools - Demigaint - DOTween Utility Panel, click Create ASMDEF

DOTweenPlayGroup / DOTweenPlayEnd

A convenient way to add many method to DOTweenPlay.

using SaintsField.Playa;

[DOTweenPlayGroup(groupBy: "Color")]
private Sequence PlayColor()
{
    return DOTween.Sequence()
        .Append(spriteRenderer.DOColor(Color.red, 1f))
        .Append(spriteRenderer.DOColor(Color.green, 1f))
        .Append(spriteRenderer.DOColor(Color.blue, 1f))
        .SetLoops(-1);
}

private Sequence PlayColor2()  // this will be automaticlly added to DOTweenPlay
{
    return DOTween.Sequence()
        .Append(spriteRenderer.DOColor(Color.cyan, 1f))
        .Append(spriteRenderer.DOColor(Color.magenta, 1f))
        .Append(spriteRenderer.DOColor(Color.yellow, 1f))
        .SetLoops(-1);
}

// this will be automaticlly added to DOTweenPlay
// Note: if you want to add this in DOTweenPlay but also stop the grouping, use:
// [DOTweenPlay("Color", keepGrouping: false)]
private Sequence PlayColor3()
{
    return DOTween.Sequence()
        .Append(spriteRenderer.DOColor(Color.yellow, 1f))
        .Append(spriteRenderer.DOColor(Color.magenta, 1f))
        .Append(spriteRenderer.DOColor(Color.cyan, 1f))
        .SetLoops(-1);
}

[DOTweenPlayEnd("Color")]
public Sequence DoNotIncludeMe() => DOTween.Sequence();    // this will NOT be added

image

Button

Draw a button for a function. If the method have arguments (required or optional), it'll draw inputs for these arguments.

using SaintsField.Playa;

[Button]
private void EditorButton()
{
    Debug.Log("EditorButton");
}

[Button("Label")]
private void EditorLabeledButton()
{
    Debug.Log("EditorLabeledButton");
}

button

Example with arguments:

using SaintsField.Playa;

[Button]
private void OnButtonParams(UnityEngine.Object myObj, int myInt, string myStr = "hi")
{
    Debug.Log($"{myObj}, {myInt}, {myStr}");
}

image

ShowInInspector

Show a non-field property.

using SaintsField.Playa;

// const
[ShowInInspector, Ordered] public const float MyConstFloat = 3.14f;
// static
[ShowInInspector, Ordered] public static readonly Color MyColor = Color.green;

// auto-property
[ShowInInspector, Ordered]
public Color AutoColor
{
    get => Color.green;
    set {}
}

show_in_inspector

Ordered

SaintsEditor uses reflection to get each field. However, c# reflection does not give all the orders: PropertyInfo, MethodInfo and FieldInfo does not order with each other.

Thus, if the order is incorrect, you can use [Ordered] to specify the order. But also note: Ordered ones are always after the ones without an Ordered. So if you want to add it, add it to every field.

using SaintsField.Playa;

[Ordered] public string myStartField;

[ShowInInspector, Ordered] public const float MyConstFloat = 3.14f;
[ShowInInspector, Ordered] public static readonly Color MyColor = Color.green;

[ShowInInspector, Ordered]
public Color AutoColor
{
    get => Color.green;
    set {}
}

[Button, Ordered]
private void EditorButton()
{
    Debug.Log("EditorButton");
}

[Ordered] public string myOtherFieldUnderneath;

ordered

Layout

A layout decorator to group fields.

Options are:

Known Issue

Horizental style is buggy, for the following reasons:

  1. On IMGUI, HorizontalScope does NOT shrink when there are many items, and will go off-view without a scrollbar. Both Odin and Markup-Attributes have the same issue. However, Markup-Attribute uses labelWidth to make the situation a bit better, which SaintsEditor does not provide (at this point at least).
  2. On UI Toolkit we have the well-behaved layout system, but because Unity will try to align the first label, all the field except the first one will get the super-shrank label width which makes it unreadable.

layout_compare_with_other

Appearance

layout

Example

using SaintsField;
using SaintsField.Playa;

[Layout("Titled", ELayout.Title | ELayout.TitleOut)]
public string titledItem1, titledItem2;

// title
[Layout("Titled Box", ELayout.Background | ELayout.TitleOut)]
public string titledBoxItem1;
[Layout("Titled Box")]  // you can omit config when you already declared one somewhere (no need to be the first one)
public string titledBoxItem2;

// foldout
[LayoutStart("Collapse", ELayout.CollapseBox)]
public string collapseItem1;
public string collapseItem2;

[LayoutStart("Foldout", ELayout.FoldoutBox)]
public string foldoutItem1;
public string foldoutItem2;

// tabs
[Layout("Tabs", ELayout.Tab | ELayout.Collapse)]
[LayoutStart("./Tab1")]
public string tab1Item1;
public int tab1Item2;

[LayoutStart("../Tab2")]
public string tab2Item1;
public int tab2Item2;

[LayoutStart("../Tab3")]
public string tab3Item1;
public int tab3Item2;

// nested groups
[LayoutStart("Nested", ELayout.Background | ELayout.TitleOut)]
public int nestedOne;

[LayoutStart("./Nested Group 1", ELayout.TitleOut)]
public int nestedTwo;
public int nestedThree;

[LayoutStart("./Nested Group 2", ELayout.TitleOut)]
public int nestedFour;
public string nestedFive;

// Unlabeled Box
[Layout("Unlabeled Box", ELayout.Background)]
public int unlabeledBoxItem1, unlabeledBoxItem2;

// Foldout In A Box
[Layout("Foldout In A Box", ELayout.Foldout | ELayout.Background | ELayout.TitleOut)]
public int foldoutInABoxItem1, foldoutInABoxItem2;

// Complex example. Button and ShowInInspector works too
[Ordered]
[Layout("Root", ELayout.Tab | ELayout.Foldout | ELayout.Background)]
[Layout("Root/V1")]
[SepTitle("Basic", EColor.Pink)]
public string hv1Item1;

[Ordered]
[Layout("Root/V1/buttons", ELayout.Horizontal)]
[Button("Root/V1 Button1")]
public void RootV1Button()
{
    Debug.Log("Root/V1 Button");
}
[Ordered]
[Layout("Root/V1/buttons")]
[Button("Root/V1 Button2")]
public void RootV1Button2()
{
    Debug.Log("Root/V1 Button");
}

[Ordered]
[Layout("Root/V1")]
[ShowInInspector]
public static Color color1 = Color.red;

[Ordered]
[DOTweenPlay("Tween1", "Root/V1")]
public Tween RootV1Tween1()
{
    return DOTween.Sequence();
}

[Ordered]
[DOTweenPlay("Tween2", "Root/V1")]
public Tween RootV1Tween2()
{
    return DOTween.Sequence();
}

[Ordered]
[Layout("Root/V1")]
public string hv1Item2;

// public string below;

[Ordered]
[Layout("Root/V2")]
public string hv2Item1;

[Ordered]
[Layout("Root/V2/H", ELayout.Horizontal), RichLabel(null)]
public string hv2Item2, hv2Item3;

[Ordered]
[Layout("Root/V2")]
public string hv2Item4;

[Ordered]
[Layout("Root/V3", ELayout.Horizontal)]
[ResizableTextArea, RichLabel(null)]
public string hv3Item1, hv3Item2;

[Ordered]
[Layout("Root/Buggy")]
[InfoBox("Sadly, Horizontal is buggy either in UI Toolkit or IMGUI", above: true)]
public string buggy = "See below:";

[Ordered]
[Layout("Root/Buggy/H", ELayout.Horizontal)]
public string buggy1, buggy2, buggy3;

[Ordered]
[Layout("Title+Tab", ELayout.Tab | ELayout.TitleBox)]
[Layout("Title+Tab/g1")]
public string titleTabG11, titleTabG21;

[Ordered]
[Layout("Title+Tab/g2")]
public string titleTabG12, titleTabG22;

[Ordered]
[Layout("All Together", ELayout.Tab | ELayout.Foldout | ELayout.Title | ELayout.TitleOut | ELayout.Background)]
[Layout("All Together/g1")]
public string allTogetherG11, allTogetherG21;

[Ordered]
[Layout("All Together/g2")]
public string allTogetherG12, allTogetherG22;

layout

LayoutStart / LayoutEnd

LayoutStart allows you to continuously grouping fields with layout, until a new group appears. LayoutEnd will stop the grouping.

LayoutStart(name) is the same as Layout(name, keepGrouping: true)

For LayoutStart:

For LayoutEnd:

It supports ./SubGroup to create a nested subgroup:

using SaintsField.Playa;

[LayoutStart("Root", ELayout.FoldoutBox)]
public string root1;
public string root2;

[LayoutStart("./Sub", ELayout.FoldoutBox)]  // equals "Root/Sub"
public string sub1;
public string sub2;
[LayoutEnd(".")]

[LayoutStart("./Another", ELayout.FoldoutBox)]  // equals "Root/Another"
public string another1;
public string another2;

[LayoutEnd(".")]  // equals "Root"
public string root3;  // this should still belong to "Root"
public string root4;

[LayoutEnd]  // this should close any existing group
public string outOfAll;

[LayoutStart("Tabs", ELayout.Tab | ELayout.Collapse)]
[LayoutStart("./Tab1")]
public string tab1Item1;
public int tab1Item2;
[LayoutEnd(".")]

[LayoutStart("./Tab2")]
public string tab2Item1;
public int tab2Item2;

image

example of using LayoutStart with LayoutEnd:

using SaintsField.Playa;

public string beforeGroup;

[LayoutStart("Group", ELayout.Background | ELayout.TitleOut)]
public string group1;
public string group2;  // starts from this will be automatically grouped into "Group"
public string group3;

[LayoutEnd("Group")]  // this will end the "Group"
public string afterGroup;

image

example of using new group name to stop grouping:

using SaintsField.Playa;

public string breakBefore;

[LayoutStart("break", ELayout.Background | ELayout.TitleOut)]
public string breakGroup1;
public string breakGroup2;

// this group will stop the grouping of "break"
[LayoutStart("breakIn", ELayout.Background | ELayout.TitleOut)]
public string breakIn1;
public string breakIn2;

[LayoutStart("break")]  // this will be grouped into "break", and also end the "breakIn" group
public string breakGroup3;
public string breakGroup4;

[LayoutEnd("break")]  // end, it will not be grouped
public string breakAfter;

image

example of using keepGrouping: false to stop grouping, but keep the last one in group:

using SaintsField.Playa;

public string beforeGroupLast;

[LayoutStart("GroupLast")]
public string groupLast1;
public string groupLast2;
public string groupLast3;
[Layout("GroupLast", ELayout.Background | ELayout.TitleOut)]  // close this group, but be included
public string groupLast4;

public string afterGroupLast;

image

PlayaShowIf/PlayaHideIf

This is the same as ShowIf, HideIf, plus it's allowed to be applied to array, Button, ShowInInspector

Different from ShowIf/HideIf:

  1. apply on an array will directly show or hide the array itself, rather than each element.
  2. Callback function can not receive value and index
using SaintsField.Playa;

public bool boolValue;

[PlayaHideIf] public int[] justHide;
[PlayaShowIf] public int[] justShow;

[PlayaHideIf(nameof(boolValue))] public int[] hideIf;
[PlayaShowIf(nameof(boolValue))] public int[] showIf;

[PlayaHideIf(EMode.Edit)] public int[] hideEdit;
[PlayaHideIf(EMode.Play)] public int[] hidePlay;
[PlayaShowIf(EMode.Edit)] public int[] showEdit;
[PlayaShowIf(EMode.Play)] public int[] showPlay;

[ShowInInspector, PlayaHideIf(nameof(boolValue))] public const float HideIfConst = 3.14f;
[ShowInInspector, PlayaShowIf(nameof(boolValue))] public const float ShowIfConst = 3.14f;
[ShowInInspector, PlayaHideIf(EMode.Edit)] public const float HideEditConst = 3.14f;
[ShowInInspector, PlayaHideIf(EMode.Play)] public const float HidePlayConst = 3.14f;
[ShowInInspector, PlayaShowIf(EMode.Edit)] public const float ShowEditConst = 3.14f;
[ShowInInspector, PlayaShowIf(EMode.Play)] public const float ShowPlayConst = 3.14f;

[ShowInInspector, PlayaHideIf(nameof(boolValue))] public static readonly Color HideIfStatic = Color.green;
[ShowInInspector, PlayaShowIf(nameof(boolValue))] public static readonly Color ShowIfStatic = Color.green;
[ShowInInspector, PlayaHideIf(EMode.Edit)] public static readonly Color HideEditStatic = Color.green;
[ShowInInspector, PlayaHideIf(EMode.Play)] public static readonly Color HidePlayStatic = Color.green;
[ShowInInspector, PlayaShowIf(EMode.Edit)] public static readonly Color ShowEditStatic = Color.green;
[ShowInInspector, PlayaShowIf(EMode.Play)] public static readonly Color ShowPlayStatic = Color.green;

[Button, PlayaHideIf(nameof(boolValue))] private void HideIfBtn() => Debug.Log("HideIfBtn");
[Button, PlayaShowIf(nameof(boolValue))] private void ShowIfBtn() => Debug.Log("ShowIfBtn");
[Button, PlayaHideIf(EMode.Edit)] private void HideEditBtn() => Debug.Log("HideEditBtn");
[Button, PlayaHideIf(EMode.Play)] private void HidePlayBtn() => Debug.Log("HidePlayBtn");
[Button, PlayaShowIf(EMode.Edit)] private void ShowEditBtn() => Debug.Log("ShowEditBtn");
[Button, PlayaShowIf(EMode.Play)] private void ShowPlayBtn() => Debug.Log("ShowPlayBtn");

image

It also supports value comparison like ==, >, <=. Read more in the "Value Comparison for Show/Hide/Enable/Disable-If" section.

PlayaEnableIf/PlayaDisableIf

This is the same as EnableIf, DisableIf, plus it can be applied to array, Button

Different from EnableIf/DisableIf in the following:

  1. apply on an array will directly enable or disable the array itself, rather than each element.
  2. Callback function can not receive value and index
  3. this method can not detect foldout, which means using it on Expandable, EnumFlags, the foldout button will also be disabled. For this case, use DisableIf/EnableIf instead.
using SaintsField.Playa;

[PlayaDisableIf] public int[] justDisable;
[PlayaEnableIf] public int[] justEnable;

[PlayaDisableIf(nameof(boolValue))] public int[] disableIf;
[PlayaEnableIf(nameof(boolValue))] public int[] enableIf;

[PlayaDisableIf(EMode.Edit)] public int[] disableEdit;
[PlayaDisableIf(EMode.Play)] public int[] disablePlay;
[PlayaEnableIf(EMode.Edit)] public int[] enableEdit;
[PlayaEnableIf(EMode.Play)] public int[] enablePlay;

[Button, PlayaDisableIf(nameof(boolValue))] private void DisableIfBtn() => Debug.Log("DisableIfBtn");
[Button, PlayaEnableIf(nameof(boolValue))] private void EnableIfBtn() => Debug.Log("EnableIfBtn");
[Button, PlayaDisableIf(EMode.Edit)] private void DisableEditBtn() => Debug.Log("DisableEditBtn");
[Button, PlayaDisableIf(EMode.Play)] private void DisablePlayBtn() => Debug.Log("DisablePlayBtn");
[Button, PlayaEnableIf(EMode.Edit)] private void EnableEditBtn() => Debug.Log("EnableEditBtn");
[Button, PlayaEnableIf(EMode.Play)] private void EnablePlayBtn() => Debug.Log("EnablePlayBtn");

image

It also supports value comparison like ==, >, <=. Read more in the "Value Comparison for Show/Hide/Enable/Disable-If" section.

PlayaArraySize

Deprecated. Use ArraySize instead.

PlayaRichLabel

This is like RichLabel, but it can change label of an array/list

Please note: at the moment it only works for serialized property, and is only tested on array/list. It's suggested to use RichLabel for non-array/list serialized fields.

Parameters:

using SaintsField.Playa;

[PlayaRichLabel("<color=lame>It's Labeled!")]
public List<string> myList;

[PlayaRichLabel(nameof(MethodLabel), true)]
public string[] myArray;

private string MethodLabel(string[] values)
{
    return $"<color=green><label /> {string.Join("", values.Select(_ => "<icon=star.png />"))}";
}

PlayaRichLabel

PlayaInfoBox/PlayaBelowInfoBox

This is like InfoBox, but it can be applied to array/list/button etc.

using SaintsField.Playa;

[PlayaInfoBox("Please Note: special label like <icon=star.png/> only works for <color=lime>UI Toolkit</color> <color=red>(not IMGUI)</color> in InfoBox.")]
[PlayaBelowInfoBox("$" + nameof(DynamicFromArray))]  // callback
public string[] strings = {};

public string dynamic;

private string DynamicFromArray(string[] value) => value.Length > 0? string.Join("\n", value): "null";

[PlayaInfoBox("MethodWithButton")]
[Button("Click Me!")]
[PlayaBelowInfoBox("GroupExample", groupBy: "group")]
[PlayaBelowInfoBox("$" + nameof(dynamic), groupBy: "group")]
public void MethodWithButton()
{
}

[PlayaInfoBox("Method")]
[PlayaBelowInfoBox("$" + nameof(dynamic))]
public void Method()
{
}

image

OnButtonClick

This is a method decorator, which will bind this method to the target button's click event.

Parameters:

using SaintsField.Playa;

[OnButtonClick]
public void OnButtonClickVoid()
{
    Debug.Log("OnButtonClick Void");
}

[OnButtonClick(value: 2)]
public void OnButtonClickInt(int value)
{
    Debug.Log($"OnButtonClick ${value}");
}

[OnButtonClick(value: true)]
public void OnButtonClickBool(bool value)
{
    Debug.Log($"OnButtonClick ${value}");
}

[OnButtonClick(value: 0.3f)]
public void OnButtonClickFloat(float value)
{
    Debug.Log($"OnButtonClick ${value}");
}

private GameObject ThisGo => this.gameObject;

[OnButtonClick(value: nameof(ThisGo), isCallback: true)]
public void OnButtonClickComp(UnityEngine.Object value)
{
    Debug.Log($"OnButtonClick ${value}");
}

image

Note:

  1. In UI Toolkit, it will only check once when you select the GameObject. In IMGUI, it'll constantly check as long as you're on this object.
  2. It'll only check the method name. Which means, if you change the value of the callback, it'll not update the callback value.

OnEvent

This is a method decorator, which will bind this method to the target UnityEvent (allows generic type) invoke event.

Parameters:

Note:

  1. In UI Toolkit, it will only check once when you select the GameObject. In IMGUI, it'll constantly check as long as you're on this object.
  2. It'll only check the method name. Which means, if you change the value of the callback, it'll not update the callback value.

Example:

public UnityEvent<int, int> intIntEvent;

[OnEvent(nameof(intIntEvent))]
public void OnInt2(int int1, int int2)  // dynamic parameter binding
{
}

[OnEvent(nameof(intIntEvent), value: 1)]
public void OnInt1(int int1)  // static parameter binding
{
}

image

Example of using dot(s):

// CustomEventChild.cs
public class CustomEventChild : MonoBehaviour
{
    [field: SerializeField] private UnityEvent<int> _intEvent;
}

// CustomEventExample.cs
public class CustomEventExample : SaintsMonoBehaviour
{
    public CustomEventChild _child;

    // it will find the `_intEvent` on the `_child` field
    [OnEvent(nameof(_child) + "._intEvent")]  
    public void OnChildInt(int int1)
    {
    }
}

ListDrawerSettings

Allow you to search and paging a large list/array.

Parameters:

using SaintsField.Playa;

[Serializable]
public struct MyData
{
    public int myInt;
    public string myString;
    public GameObject myGameObject;
    public string[] myStrings;
}

[ListDrawerSettings(searchable: true, numberOfItemsPerPage: 3)]
public MyData[] myDataArr;

image

The first input is where you can search. The next input can adjust how many items per page. The last part is the paging.

About GroupBy

group with any decorator that has the same groupBy for this field. The same group will share even the width of the view width between them.

This only works for decorator draws above or below the field. The above drawer will not groupd with the below drawer, and vice versa.

"" means no group.

Value Comparison for Show/Hide/Enable/Disable-If

This applies to ShowIf, HideIf, EnableIf, DisableIf, PlayaShowIf, PlayaHideIf, PlayaEnableIf, PlayaDisableIf.

These decorators accept many objects.

By Default

Passing many strings, each string is represent either a field, property or a method. SaintsField will check the value accordingly. If it's a method, it'll also receive the field's value and index if it's inside an array/list.

Value Equality

You can also pass a string, then followed by a value you want to compare with. For example:

using SaintsField;

public int myInt;

[EnableIf(nameof(myInt), 2] public int enableIfEquals2;

This field will only be enabled if the myInt is equal to 2.

This can be mixed with many pairs:

using SaintsField;

public int myInt1;
public int myInt2;

[EnableIf(nameof(myInt1), 2, nameof(myInt2), 3] public int enableIfMultipleCondition;

multiple conditions are logically chained accordingly. See each section of these decorators for more information.

Value Comparison

The string can have ! prefix to negate the comparison.

And ==, !=, > etc. suffix for more comparison you want. The suffix supports:

Comparison Suffixes:

Bitwise Suffixes:

Some examples:

using SaintsField;

public bool boolValue;
[EnableIf("!" + nameof(boolValue)), RichLabel("Reverse!")] public string boolValueEnableN;

[Range(0, 2)] public int int01;

[EnableIf(nameof(int01), 1), RichLabel("default")] public string int01Enable1;
[EnableIf(nameof(int01) + ">=", 1), RichLabel(">=1")] public string int01EnableGe1;
[EnableIf("!" + nameof(int01) + "<=", 1), RichLabel("! <=1")] public string int01EnableNLe1;

[Range(0, 2)] public int int02;
// we need the "==$" to tell the next string is a value callback, not a condition checker
[EnableIf(nameof(int01) + "==$", nameof(int02)), RichLabel("==$")] public string int01Enable1Callback;
[EnableIf(nameof(int01) + "<$", nameof(int02)), RichLabel("<$")] public string int01EnableLt1Callback;
[EnableIf("!" + nameof(int01) + ">$", nameof(int02)), RichLabel("! >$")] public string int01EnableNGt1Callback;

// example of bitwise
[Serializable]
public enum EnumOnOff
{
    A = 1,
    B = 1 << 1,
}

[Space]
[Range(0, 3)] public int enumOnOffBits;

[EnableIf(nameof(enumOnOffBits) + "&", EnumOnOff.A), RichLabel("&01")] public string bitA;
[EnableIf(nameof(enumOnOffBits) + "^", EnumOnOff.B), RichLabel("^10")] public string bitB;
[EnableIf(nameof(enumOnOffBits) + "&==", EnumOnOff.B), RichLabel("hasFlag(B)")] public string hasFlagB;

Default Value Comparison

When passing the parameters, any parameter that is not a string, means it's a value comparison to the previous one.

(Which also means, to compare with a literal string value, you need to suffix the previous string with ==)

[ShowIf(nameof(MyFunc), 3)] means it will check if the result of MyFunc equals to 3.

However, if the later parameter is a bitMask (an enum with [Flags]), it'll check if the target has the required bit on:

using SaintsField;

[Flags, Serializable]
public enum EnumF
{
    A = 1,
    B = 1 << 1,
}

[EnumFlags]
public EnumF enumF;

[EnableIf(nameof(enumF), EnumF.A), RichLabel("hasFlag(A)")] public string enumFEnableA;
[EnableIf(nameof(enumF), EnumF.B), RichLabel("hasFlag(B)")] public string enumFEnableB;
[EnableIf(nameof(enumF), EnumF.A | EnumF.B), RichLabel("hasFlag(A | B)")] public string enumFEnableAB;

Add a Macro

Pick a way that is most convenient for you:

Using Saints Menu

Go to Window - Saints to enable/disable functions you want

Saints Menu

Using csc.rsp

  1. Create file Assets/csc.rsp
  2. Write marcos like this:

    #"Disable DOTween"
    -define:SAINTSFIELD_DOTWEEN_DISABLE
    
    #"Disable Addressable"
    -define:SAINTSFIELD_ADDRESSABLE_DISABLE
    
    #"Disable AI Navigation"
    -define:SAINTSFIELD_AI_NAVIGATION_DISABLED
    
    #"Disable UI Toolkit"
    -define:SAINTSFIELD_UI_TOOLKIT_DISABLE
    
    #"Apply SaintsEditor project wide"
    -define:SAINTSFIELD_SAINTS_EDITOR_APPLY
    
    #"Disable SaintsEditor IMGUI constant repaint"
    -define:SAINTSFIELD_SAINTS_EDITOR_IMGUI_CONSTANT_REPAINT_DISABLE

Note: csc.rsp can override settings by Saints Menu.

Using project settings

Edit - Project Settings - Player, find your platform, then go Other Settings - Script Compliation - Scripting Define Symbols to add your marcos. Don't forget to click Apply before closing the window.

Common Pitfalls & Compatibility

List/Array & Nesting

Directly using on list/array will apply to every direct element of the list, this is a limit from Unity.

Unlike NaughtyAttributes, SaintsField does not need a AllowNesting attribute to work on nested element.

public class ArrayLabelExample : MonoBehaviour
{
    // works for list/array
    [Scene] public int[] myScenes;

    [System.Serializable]
    public struct MyStruct
    {
        public bool enableFlag;

        [AboveRichLabel("No need for `[AllowNesting]`, it just works")]
        public int integer;
    }

    public MyStruct myStruct;
}

Order Matters

SaintsField only uses PropertyDrawer to draw the field, and will properly fall back to the rest drawers if there is one. This works for both 3rd party drawer, your custom drawer, and Unity's default drawer.

However, Unity only allows decorators to be loaded from top to bottom, left to right. Any drawer that does not properly handle the fallback will override PropertyDrawer follows by. Thus, ensure SaintsField is always the first decorator.

An example of working with NaughtyAttributes:

using SaintsField;

[RichLabel("<color=green>+NA</color>"),
 NaughtyAttributes.CurveRange(0, 0, 1, 1, NaughtyAttributes.EColor.Green),
 NaughtyAttributes.Label(" ")  // a little hack for label issue
]
public AnimationCurve naCurve;

[RichLabel("<color=green>+Native</color>"), Range(0, 5)]
public float nativeRange;

// this wont work. Please put `SaintsField` before other drawers
[Range(0, 5), RichLabel("<color=green>+Native</color>")]
public float nativeRangeHandled;

// this wont work too. Please put `SaintsField` before other drawers
[NaughtyAttributes.CurveRange(0, 0, 1, 1, NaughtyAttributes.EColor.Green)]
[RichLabel("<color=green>+NA</color>")]
public AnimationCurve naCurveHandled;

Fallback To Other Drawers

SaintsField is designed to be compatible with other drawers if

  1. the drawer itself respects the GUIContent argument in OnGUI for IMGUI, or add unity-label class to Label for UI Toolkit

    NOTE: NaughtyAttributes (IMGUI) uses property.displayName instead of GUIContent. You need to set Label(" ") if you want to use RichLabel. Might not work very well with NaughtyAttributes's meta attributes because they are editor level components.

  2. if the drawer hijacks the CustomEditor, it must fall to the rest drawers

    NOTE: In many cases Odin does not fallback to the rest drawers, but only to Odin and Unity's default drawers. So sometimes things will not work with Odin

Special Note:

NaughtyAttributes uses only IMGUI. If you're using Unity 2022.2+, NaughtyAttributes's editor will try fallback default drawers and Unity will decide to use UI Toolkit rendering SaintsField and cause troubles. Please disable SaintsField's UI Toolkit ability by Window - Saints - Disable UI Toolkit Support (See "Add a Macro" section for more information)

My (not full) test about compatibility: