Skip to content

Instantly share code, notes, and snippets.

@thsbrown
Last active November 17, 2023 19:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thsbrown/4853e0b6f4d53d70433aca2a22a30bcc to your computer and use it in GitHub Desktop.
Save thsbrown/4853e0b6f4d53d70433aca2a22a30bcc to your computer and use it in GitHub Desktop.
Optimized Unity TMP_InputField for Steam Deck keyboard!
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);
}
}
/// <summary>
/// The current color of our input field based on <see cref="dirty"/>. Essentially just shor for Dirty ? errorColor : normalColor
/// </summary>
public Color DirtyStatusColor => Dirty ? errorColor : normalColor;
}
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;
private Callback<FloatingGamepadTextInputDismissed_t> floatingGamepadTextInputDismissedCallback;
#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 += OnDirtyChangedHandler;
#if UNITY_STANDALONE && !DISABLESTEAMWORKS
gamepadTextInputDismissedCallback = Callback<GamepadTextInputDismissed_t>.Create(OnGamepadTextInputDismissed);
floatingGamepadTextInputDismissedCallback = Callback<FloatingGamepadTextInputDismissed_t>.Create(OnFloatingGamepadTextInputDismissed);
#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 -= OnDirtyChangedHandler;
#if UNITY_STANDALONE && !DISABLESTEAMWORKS
gamepadTextInputDismissedCallback.Dispose();
floatingGamepadTextInputDismissedCallback.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);
//set our highlighter to the color of our input field
inputFieldSelectedHighlighter.color = errorableInput.DirtyStatusColor;
}
/// <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. Additionally show steam keyboard if we are running in steam.
/// </summary>
/// <param name="text"></param>
private void OnInputFieldSelectHandler(string text)
{
modalExitButtonInputActionListener.enabled = false;
preventUiSubmitInputAction = false;
preventUiCancelInputAction = false;
#if UNITY_STANDALONE && !DISABLESTEAMWORKS
OpenSteamKeyboard();
#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;
}
/// <summary>
/// If our input field dirty status changes and we are focusing our input field, update our <see cref="inputFieldSelectedHighlighter"/> color
/// and ensure we reselect it if it's dirty. Reselecting <see cref="inputFieldSelectedHighlighter"/> ensures steam deck keyboard reopens intuitively.
/// </summary>
/// <param name="isDirty"></param>
private void OnDirtyChangedHandler(bool isDirty)
{
//only do something if we are focusing our input field
if(EventSystem.current.currentSelectedGameObject != errorableInput.input.gameObject)
{
return;
}
inputFieldSelectedHighlighter.color = errorableInput.DirtyStatusColor;
//if the field is dirty, set our selection back to our controller, so pressing submit will open keyboard again
if (isDirty)
{
Select();
}
}
#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;
}
//if we submit via another input device such as a gamepad, mimic the behavior or pressing enter on the keyboard
//that is, to stop editing the input field.
if (context.control is not KeyControl)
{
errorableInput.input.DeactivateInputField();
}
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 Steam Handlers
#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;
}
private void OnFloatingGamepadTextInputDismissed(FloatingGamepadTextInputDismissed_t callback)
{
//if we close our floating keyboard while we are editing text, go back to highlight state
//so that pressing submit will open keyboard again.
if (errorableInput.input.isFocused)
{
Select();
}
}
#endif
#endregion
#if UNITY_STANDALONE && !DISABLESTEAMWORKS
/// <summary>
/// Opens the steam keyboard we are requesting to use a la <see cref="steamKeyboardToUse"/>
/// </summary>
/// <exception cref="ArgumentOutOfRangeException"></exception>
private void OpenSteamKeyboard()
{
if (!SteamManager.Initialized)
{
return;
}
switch (steamKeyboardToUse)
{
case SteamKeyboard.FloatingKeyboard:
//optimize this
var errorableInputRectTransform = errorableInput.gameObject.GetComponent<RectTransform>();
var canvas = errorableInputRectTransform.GetComponentInParent<Canvas>();
var rect = RectTransformUtility.PixelAdjustRect(errorableInputRectTransform, canvas);
SteamUtils.ShowFloatingGamepadTextInput(
EFloatingGamepadTextInputMode.k_EFloatingGamepadTextInputModeModeSingleLine, (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
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
}
}
}

Demo of my setup!

Demo.Standalone.INput.Field.Controller.mp4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment