Last active
July 12, 2024 08:47
-
-
Save thsbrown/4853e0b6f4d53d70433aca2a22a30bcc to your computer and use it in GitHub Desktop.
Optimized Unity TMP_InputField for Steam Deck keyboard!
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment