Skip to content

Instantly share code, notes, and snippets.

@thsbrown
Last active November 16, 2023 01:48
Show Gist options
  • Save thsbrown/32765b7aaad3c76340d26070bd9eec79 to your computer and use it in GitHub Desktop.
Save thsbrown/32765b7aaad3c76340d26070bd9eec79 to your computer and use it in GitHub Desktop.
A relatively hacky way to work around TMP_InputField
using System;
using DG.Tweening;
using Sirenix.OdinInspector;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
/// <summary>
/// An input that allows for easy error reporting alongside it
/// </summary>
public class ErrorableInput : MonoBehaviour
{
/// <summary>
/// The input to validate and use
/// </summary>
[Tooltip("The input to validate and use")]
public TMP_InputField input;
/// <summary>
/// The image that we utilize to display our input borders.
/// </summary>
[Tooltip("The image that we utilize to display our input borders.")]
public Image borderImage;
/// <summary>
/// Displays error message for the input
/// </summary>
[Tooltip("Displays error message for the input")]
public TextMeshProUGUI errorText;
/// <summary>
/// The inline character at the beginning of our input field.
/// </summary>
[Tooltip("The inline character at the beginning of our input field.")]
public TextMeshProUGUI inputPrefixCharacter;
/// <summary>
/// The inline character at the end of our input field.
/// </summary>
[Tooltip("The inline character at the end of our input field.")]
public TextMeshProUGUI inputSuffixCharacter;
/// <summary>
/// The color we will color our field in the case of an error.
/// </summary>
[Tooltip("The color we will color our field in the case of an error.")]
[ColorPalette("CCE")]
public Color errorColor;
/// <summary>
/// The color we will color when there is no error.
/// </summary>
[Tooltip("The color we will color when there is no error.")]
[ColorPalette("CCE")]
public Color normalColor;
/// <summary>
/// Fired when <see cref="Dirty"/> value has changed. The value passed along is the new value of dirty.
/// </summary>
public event Action<bool> OnDirtyChanged;
private bool dirty;
private const float ERROR_TRANSITION_DURATION = 0.2f;
private void Awake()
{
input.onValueChanged.AddListener(ClearErrorsOnValueInputChangedHandler);
}
/// <summary>
/// Displays an error with the given error message.
/// </summary>
/// <param name="errorMessage">The error message to display</param>
public void DisplayError(string errorMessage)
{
Dirty = true;
errorText.text = errorMessage;
inputPrefixCharacter.DOColor(errorColor, ERROR_TRANSITION_DURATION);
borderImage.DOColor(errorColor, ERROR_TRANSITION_DURATION);
errorText.gameObject.SetActive(true);
inputSuffixCharacter.gameObject.SetActive(true);
}
/// <summary>
/// Clears the input field of visual errors
/// </summary>
public void ClearError(bool clearInputText = false)
{
if (clearInputText)
{
input.text = "";
}
Dirty = false;
errorText.text = "";
inputPrefixCharacter.DOColor(normalColor, ERROR_TRANSITION_DURATION);
borderImage.DOColor(normalColor, ERROR_TRANSITION_DURATION);
errorText.gameObject.SetActive(false);
inputSuffixCharacter.gameObject.SetActive(false);
}
/// <summary>
/// Clears displayed errors when the input value has been modified and is marked dirty
/// </summary>
/// <param name="text">The incoming text</param>
private void ClearErrorsOnValueInputChangedHandler(string text)
{
if (!dirty)
{
return;
}
ClearError();
}
/// <summary>
/// When true will display allow errors to be displayed to user onValueChanged
/// </summary>
public bool Dirty
{
get => dirty;
private set
{
dirty = value;
OnDirtyChanged?.Invoke(dirty);
}
}
}
using System;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Controls;
using UnityEngine.UI;
using UnityEngine.UI.ProceduralImage;
#if !DISABLESTEAMWORKS
using Steamworks;
#endif
namespace _Game_Assets.Scripts.Runtime.Input
{
public class StandaloneInputFieldController : Selectable, ISubmitHandler
{
/// <summary>
/// The errorable input associated with the input field we are controlling.
/// </summary>
[Tooltip("The errorable input associated with the input field we are controlling.")]
public ErrorableInput errorableInput;
/// <summary>
/// The image component that will be highlighted when our input field is "selected".
/// </summary>
[Tooltip("The image component that will be highlighted when our input field is \"selected\".")]
public ProceduralImage inputFieldSelectedHighlighter;
/// <summary>
/// The input action asset that is being utilized for our UI events.
/// </summary>
[Tooltip("The input action asset that is being utilized for our UI events.")]
public InputActionAsset uiInputActionAsset;
/// <summary>
/// Reference to the action invoker on the exit button that will close our canvas controller
/// </summary>
[Tooltip("Reference to the action invoker on the exit button that will close our canvas controller")]
public InputActionListener modalExitButtonInputActionListener;
/// <summary>
/// The button associated with this input field that will submit the input field when pressed.
/// </summary>
[Tooltip("The button associated with this input field that will submit the input field when pressed.")
public Button associatedSubmitButton;
/// <summary>
/// The keyboard that will be shown when the game is running in steam. Most commonly on the steam deck.
/// </summary>
[Tooltip("The keyboard that will be shown when the game is running in steam. Most commonly on the steam deck.")]
public SteamKeyboard steamKeyboardToUse;
private bool preventUiSubmitInputAction = true;
private bool preventUiCancelInputAction = true;
private const string UI_ACTION_MAP_NAME = "UI";
private const string UI_SUBMIT_ACTION_NAME = "Submit";
private const string UI_CANCEL_ACTION_NAME = "Cancel";
#if UNITY_STANDALONE && !DISABLESTEAMWORKS
private Callback<GamepadTextInputDismissed_t> gamepadTextInputDismissedCallback;
#endif
protected override void OnEnable()
{
base.OnEnable();
errorableInput.input.onSelect.AddListener(OnInputFieldSelectHandler);
errorableInput.input.onDeselect.AddListener(OnInputFieldDeselectHandler);
uiInputActionAsset.FindActionMap(UI_ACTION_MAP_NAME).FindAction(UI_SUBMIT_ACTION_NAME).performed += OnUISubmitInputActionPerformed;
uiInputActionAsset.FindActionMap(UI_ACTION_MAP_NAME).FindAction(UI_CANCEL_ACTION_NAME).performed += OnUICancelInputActionPerformed;
errorableInput.OnDirtyChanged += OnDirtyChangedUpdateControllerHighlighterHandler;
#if UNITY_STANDALONE && !DISABLESTEAMWORKS
gamepadTextInputDismissedCallback = Callback<GamepadTextInputDismissed_t>.Create(OnGamepadTextInputDismissed);
#endif
}
protected override void OnDisable()
{
base.OnDisable();
errorableInput.input.onSelect.RemoveListener(OnInputFieldSelectHandler);
errorableInput.input.onDeselect.RemoveListener(OnInputFieldDeselectHandler);
uiInputActionAsset.FindActionMap(UI_ACTION_MAP_NAME).FindAction(UI_SUBMIT_ACTION_NAME).performed -= OnUISubmitInputActionPerformed;
uiInputActionAsset.FindActionMap(UI_ACTION_MAP_NAME).FindAction(UI_CANCEL_ACTION_NAME).performed -= OnUICancelInputActionPerformed;
errorableInput.OnDirtyChanged -= OnDirtyChangedUpdateControllerHighlighterHandler;
#if UNITY_STANDALONE && !DISABLESTEAMWORKS
gamepadTextInputDismissedCallback.Dispose();
#endif
}
#region Standalone Input Field Controller Handlers
/// <summary>
/// When we are selected, colorize our input field highlighter image to show that our input field is selected.
/// </summary>
/// <param name="eventData"></param>
public override void OnSelect(BaseEventData eventData)
{
base.OnSelect(eventData);
OnDirtyChangedUpdateControllerHighlighterHandler(errorableInput.Dirty);
}
/// <summary>
/// When we select something besides the input field we represent (another gameobject) disable our highlighting as we
/// are no longer "selecting" our input field
/// </summary>
/// <param name="eventData"></param>
public override async void OnDeselect(BaseEventData eventData)
{
base.OnDeselect(eventData);
//wait one frame to give baseEventData.selectedObject a chance to update so we can see what we actually selected
await UniTask.NextFrame();
//if we selected our input field we want to stay highlighted, otherwise we selected our submit button or something else so we want to unhighlight
if (eventData.selectedObject == errorableInput.input.gameObject)
{
return;
}
inputFieldSelectedHighlighter.color = Color.clear;
}
/// <summary>
/// When our input field controller is submitted, we want to enter into edit mode of our input field so select it.
/// </summary>
/// <param name="eventData"></param>
public void OnSubmit(BaseEventData eventData)
{
errorableInput.input.Select();
}
#endregion
#region Input Field Handlers
/// <summary>
/// Our input field is now selected so ensure back input exits input field not the menu and submit input
/// executes the associated submit button on click behavior.
/// </summary>
/// <param name="text"></param>
private void OnInputFieldSelectHandler(string text)
{
modalExitButtonInputActionListener.enabled = false;
preventUiSubmitInputAction = false;
preventUiCancelInputAction = false;
#if UNITY_STANDALONE && !DISABLESTEAMWORKS
if (SteamManager.Initialized)
{
switch (steamKeyboardToUse)
{
case SteamKeyboard.FloatingKeyboard:
//optimize this
var errorableInputRectTransform = errorableInput.gameObject.GetComponent<RectTransform>();
var canvas = errorableInputRectTransform.GetComponentInParent<Canvas>();
var rect = RectTransformUtility.PixelAdjustRect(errorableInputRectTransform, canvas);
//use multiline so keyboard wont dismiss when we press enter
SteamUtils.ShowFloatingGamepadTextInput(
EFloatingGamepadTextInputMode.k_EFloatingGamepadTextInputModeModeMultipleLines, (int)rect.x,
(int)rect.y, (int)rect.size.x, (int)rect.size.y);
break;
case SteamKeyboard.BigPictureKeyboard:
SteamUtils.ShowGamepadTextInput(
EGamepadTextInputMode.k_EGamepadTextInputModeNormal,
EGamepadTextInputLineMode.k_EGamepadTextInputLineModeSingleLine, "Enter Display Name",
(uint)errorableInput.input.characterLimit, errorableInput.input.text);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
#endif
}
/// <summary>
/// Our input field is no longer selected so ensure back input once again exits the menu and submit input does nothing.
/// </summary>
/// <param name="text"></param>
private void OnInputFieldDeselectHandler(string text)
{
modalExitButtonInputActionListener.enabled = true;
preventUiSubmitInputAction = true;
preventUiCancelInputAction = true;
#if UNITY_STANDALONE && !DISABLESTEAMWORKS
if (SteamManager.Initialized)
{
SteamUtils.DismissFloatingGamepadTextInput();
}
#endif
}
#endregion
#region Input System UI Handlers
/// <summary>
/// When we have submit input activate our associated submit button (unless we are preventing it).
/// </summary>
/// <param name="context"></param>
private void OnUISubmitInputActionPerformed(InputAction.CallbackContext context)
{
if (preventUiSubmitInputAction)
{
return;
}
//bug fix that causes input field to become uneditable when we submit via enter key vs other input method (confirm button on gamepad - A)
//doing this, we can reverse the hard coded behavior for the enter key in the TMP_InputField.cs script
if (context.control is KeyControl keyControl && keyControl.keyCode == Key.Enter)
{
errorableInput.input.ActivateInputField();
}
associatedSubmitButton.onClick.Invoke();
}
/// <summary>
/// When we have cancel input activate our associated cancel button (unless we are preventing it).
/// </summary>
/// <param name="context"></param>
private void OnUICancelInputActionPerformed(InputAction.CallbackContext context)
{
if (preventUiCancelInputAction)
{
return;
}
Select();
}
#endregion
#region General Handlers
/// <summary>
/// Make our input field highlighter image red if our input field is dirty, and normal if it is not.
/// </summary>
/// <param name="isDirty"></param>
private void OnDirtyChangedUpdateControllerHighlighterHandler(bool isDirty)
{
inputFieldSelectedHighlighter.color = isDirty ? errorableInput.errorColor : errorableInput.normalColor;
}
#if UNITY_STANDALONE && !DISABLESTEAMWORKS
private void OnGamepadTextInputDismissed(GamepadTextInputDismissed_t callback)
{
//the user cancelled so do nothing
if (!callback.m_bSubmitted)
{
return;
}
var length = SteamUtils.GetEnteredGamepadTextLength();
//according to steam return should only ever happen if length is > MaxInputLength
if (!SteamUtils.GetEnteredGamepadTextInput(out var enteredText, length))
{
return;
}
errorableInput.input.text = enteredText;
}
#endif
#endregion
public enum SteamKeyboard
{
/// <summary>
/// Keyboard that will float above game and stream text directly into the game.
/// </summary>
FloatingKeyboard,
/// <summary>
/// Keyboard that will take up the whole screen that requires a callback to get text
/// </summary>
BigPictureKeyboard
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment