diff --git a/Android~/plugin/src/main/java/com/mopsicus/umi/MobileInput.java b/Android~/plugin/src/main/java/com/mopsicus/umi/MobileInput.java
index 96e9c26..22fcfad 100644
--- a/Android~/plugin/src/main/java/com/mopsicus/umi/MobileInput.java
+++ b/Android~/plugin/src/main/java/com/mopsicus/umi/MobileInput.java
@@ -124,13 +124,21 @@ public static void processMessage(int id, final String data) {
JSONObject json = new JSONObject(data);
String msg = json.getString("msg");
if (msg.equals(CREATE)) {
+ if (mobileInputList.get(id) != null) {
+ return;
+ }
MobileInput input = new MobileInput(Plugin.layout);
- input.Create(id, json);
mobileInputList.append(id, input);
+ input.Create(id, json);
} else {
MobileInput input = mobileInputList.get(id);
if (input != null) {
- input.processData(json);
+ if (msg.equals(REMOVE)) {
+ input.Remove();
+ mobileInputList.remove(id);
+ } else {
+ input.processData(json);
+ }
}
}
} catch (JSONException e) {
@@ -306,6 +314,7 @@ private void Create(int id, JSONObject data) {
String alignment = data.getString("align");
String customFont = data.getString("font");
boolean multiline = data.getBoolean("multiline");
+ boolean isVisible = data.optBoolean("is_visible", true);
caretColor = Color.argb(caretColor_a, caretColor_r, caretColor_g, caretColor_b);
isCaretChange = data.getBoolean("caret_color");
edit = new EditText(Plugin.activity.getApplicationContext());
@@ -508,6 +517,8 @@ public void onTextChanged(CharSequence s, int start, int before, int count) {
return false;
});
+ edit.setVisibility(isVisible ? View.VISIBLE : View.INVISIBLE);
+ edit.setEnabled(isVisible);
layout.addView(edit);
data = new JSONObject();
try {
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba2c46c..778d5eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
All notable changes to this project will be documented in this file.
+## [2.0.6] - 2026-07-28
+- ### Fixed
+- Native input lifecycle and visibility races
+
## [2.0.5] - 2024-12-10
- ### Added
- Set caret position
@@ -26,4 +30,4 @@ All notable changes to this project will be documented in this file.
- Keyboard autohide on tap #110
## [2.0.0] - 2024-03-26
-### Release v2
\ No newline at end of file
+### Release v2
diff --git a/Plugins/Android/Mobileinput.aar b/Plugins/Android/Mobileinput.aar
index 22cb6c8..3283913 100644
Binary files a/Plugins/Android/Mobileinput.aar and b/Plugins/Android/Mobileinput.aar differ
diff --git a/Plugins/iOS/MobileInput.mm b/Plugins/iOS/MobileInput.mm
index 4d74a6a..9fa4d11 100644
--- a/Plugins/iOS/MobileInput.mm
+++ b/Plugins/iOS/MobileInput.mm
@@ -331,13 +331,23 @@ + (void)processMessage:(int)inputId data:(NSString *)data {
NSDictionary *message = [Bridge jsonToDict:data];
NSString *msg = [message valueForKey:@"msg"];
if ([msg isEqualToString:CREATE]) {
+ NSNumber *key = [NSNumber numberWithInt:inputId];
+ if ([mobileInputList objectForKey:key]) {
+ return;
+ }
MobileInput *input = [[MobileInput alloc] initWith:mainViewController andTag:inputId];
+ [mobileInputList setObject:input forKey:key];
[input create:message];
- [mobileInputList setObject:input forKey:[NSNumber numberWithInt:inputId]];
} else {
- MobileInput *input = [mobileInputList objectForKey:[NSNumber numberWithInt:inputId]];
+ NSNumber *key = [NSNumber numberWithInt:inputId];
+ MobileInput *input = [mobileInputList objectForKey:key];
if (input) {
- [input processData:message];
+ if ([msg isEqualToString:REMOVE]) {
+ [input remove];
+ [mobileInputList removeObjectForKey:key];
+ } else {
+ [input processData:message];
+ }
}
}
}
@@ -608,6 +618,8 @@ - (void)create:(NSDictionary *)data {
BOOL withClearButton = [[data valueForKey:@"with_clear_button"] boolValue];
isMultiline = [[data valueForKey:@"multiline"] boolValue];
BOOL isChangeCaret = [[data valueForKey:@"caret_color"] boolValue];
+ NSNumber *visibleValue = [data valueForKey:@"is_visible"];
+ BOOL isVisible = visibleValue ? [visibleValue boolValue] : YES;
BOOL autoCorrection = NO;
BOOL password = NO;
NSString *inputType = [data valueForKey:@"input_type"];
@@ -804,6 +816,7 @@ - (void)create:(NSDictionary *)data {
}
editView = textField;
}
+ editView.hidden = !isVisible;
[mainViewController.view addSubview:editView];
NSMutableDictionary *msg = [[NSMutableDictionary alloc] init];
[msg setValue:READY forKey:@"msg"];
diff --git a/Runtime/MobileInput.cs b/Runtime/MobileInput.cs
index 3080307..ff77c4c 100644
--- a/Runtime/MobileInput.cs
+++ b/Runtime/MobileInput.cs
@@ -31,7 +31,21 @@ public abstract class MobileInputReceiver : MonoBehaviour {
///
/// Current input id
///
- int _id = 0;
+ int _id = -1;
+
+ ///
+ /// Receiver registration state
+ ///
+ bool _isRegistered = false;
+
+ ///
+ /// Check whether native messages can safely use this receiver id
+ ///
+ protected bool IsRegistered {
+ get {
+ return _isRegistered;
+ }
+ }
///
/// Init input and register interface
@@ -40,6 +54,7 @@ protected virtual void Start() {
#if !UNITY_EDITOR
_id = MobileInput.Register(this);
#endif
+ _isRegistered = true;
}
///
@@ -47,8 +62,11 @@ protected virtual void Start() {
///
protected virtual void OnDestroy() {
#if !UNITY_EDITOR
- MobileInput.RemoveReceiver(_id);
+ if (_isRegistered) {
+ MobileInput.RemoveReceiver(_id);
+ }
#endif
+ _isRegistered = false;
}
///
@@ -57,7 +75,9 @@ protected virtual void OnDestroy() {
/// Data
protected void Execute(JsonObject data) {
#if !UNITY_EDITOR
- MobileInput.Execute(_id, data);
+ if (_isRegistered) {
+ MobileInput.Execute(_id, data);
+ }
#endif
}
@@ -175,6 +195,30 @@ public class MobileInput : MonoBehaviour {
///
static bool _isInited = false;
+ ///
+ /// Current application focus state
+ ///
+ static bool _applicationHasFocus = true;
+
+ ///
+ /// Current application pause state
+ ///
+ static bool _applicationPaused = false;
+
+ ///
+ /// Notify inputs when temporary application visibility changes
+ ///
+ internal static event Action OnApplicationVisibilityChange = delegate { };
+
+ ///
+ /// Whether native inputs may currently be displayed
+ ///
+ internal static bool CanShowInputs {
+ get {
+ return _applicationHasFocus && !_applicationPaused;
+ }
+ }
+
#if UNITY_IOS
///
/// Send data to plugin input
@@ -360,7 +404,9 @@ public static int Register(MobileInputReceiver receiver) {
///
/// Input id
public static void RemoveReceiver(int id) {
- _instance._inputs.Remove(id);
+ if ((object)_instance != null) {
+ _instance._inputs.Remove(id);
+ }
}
///
@@ -406,6 +452,8 @@ public static void Init() {
Debug.Log($"[UMI] init");
#endif
_isInited = true;
+ _applicationHasFocus = Application.isFocused;
+ _applicationPaused = false;
var state = PlayerPrefs.GetInt(INIT_KEY, 0);
if (state == 0) {
UpdateFonts();
@@ -500,7 +548,22 @@ static void PrepareFontsAsset(string fileName) {
///
/// Handler to check data on focus change
///
+ void OnApplicationFocus(bool hasFocus) {
+ if (_applicationHasFocus == hasFocus) {
+ return;
+ }
+ _applicationHasFocus = hasFocus;
+ OnApplicationVisibilityChange();
+ }
+
+ ///
+ /// Handler to preserve callbacks and native visibility across app pause
+ ///
void OnApplicationPause(bool pauseStatus) {
+ if (_applicationPaused != pauseStatus) {
+ _applicationPaused = pauseStatus;
+ OnApplicationVisibilityChange();
+ }
if (!pauseStatus) {
if (_data != null) {
OnData(_data);
@@ -508,4 +571,4 @@ void OnApplicationPause(bool pauseStatus) {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/Runtime/MobileInputField.cs b/Runtime/MobileInputField.cs
index b5cf747..4f8ecbe 100644
--- a/Runtime/MobileInputField.cs
+++ b/Runtime/MobileInputField.cs
@@ -61,6 +61,17 @@ public enum ReturnKeyType {
Send
}
+ ///
+ /// Native input initialization state
+ ///
+ enum InitializationState {
+ NotStarted,
+ Scheduled,
+ WaitingForReady,
+ Ready,
+ Destroyed
+ }
+
#if (UNITY_IOS || UNITY_ANDROID) && !UNITY_EDITOR
///
/// InputField create event
@@ -221,14 +232,14 @@ public enum ReturnKeyType {
public UnityEvent OnReturnPressedEvent = null;
///
- /// Mobile input creation flag
+ /// Native input initialization state
///
- bool _isMobileInputCreated = false;
+ InitializationState _initializationState = InitializationState.NotStarted;
///
- /// Mobile input init start flag
+ /// Currently scheduled initialization coroutine
///
- bool _initStarted = false;
+ Coroutine _initializationCoroutine = null;
///
/// InputField object
@@ -246,9 +257,9 @@ public enum ReturnKeyType {
bool _isFocusOnCreate = false;
///
- /// Set visible on create flag
+ /// Last visibility requested through the public API
///
- bool _isVisibleOnCreate = true;
+ bool _requestedVisible = true;
///
/// Last field position cache
@@ -282,6 +293,7 @@ void Awake() {
throw new MissingComponentException();
}
_inputObjectText = _inputObject.textComponent;
+ MobileInput.OnApplicationVisibilityChange += OnApplicationVisibilityChanged;
}
///
@@ -289,20 +301,19 @@ void Awake() {
///
protected override void Start() {
base.Start();
- if (!_initStarted) {
- StartCoroutine(InitProcess());
- }
+ RequestInitialization();
}
///
/// Show native on enable
///
void OnEnable() {
- if (_isMobileInputCreated) {
+ if (_initializationState == InitializationState.Ready) {
SetRectNative(this._inputObjectText.rectTransform);
- SetVisible(true);
- } else if (!_initStarted) {
- StartCoroutine(InitProcess());
+ ApplyNativeVisibility();
+ ApplyFocusOnCreate();
+ } else if (_initializationState == InitializationState.NotStarted) {
+ RequestInitialization();
}
}
@@ -310,9 +321,13 @@ void OnEnable() {
/// Hide native on disable
///
void OnDisable() {
- if (_isMobileInputCreated) {
- SetFocus(false);
- SetVisible(false);
+ if (_initializationState == InitializationState.Scheduled) {
+ CancelScheduledInitialization();
+ } else if (_initializationState == InitializationState.Ready) {
+ SetFocusNative(false);
+ ApplyNativeVisibility();
+ } else if (_initializationState == InitializationState.WaitingForReady) {
+ ApplyNativeVisibility();
}
}
@@ -320,32 +335,20 @@ void OnDisable() {
/// Destructor
///
protected override void OnDestroy() {
+ MobileInput.OnApplicationVisibilityChange -= OnApplicationVisibilityChanged;
+ CancelScheduledInitialization();
RemoveNative();
base.OnDestroy();
}
-#if UNITY_ANDROID
///
- /// Handler for app focus lost
+ /// Reapply logical visibility after app focus/pause changes
///
- void OnApplicationFocus(bool hasFocus) {
- if (!_isMobileInputCreated || !Visible) {
- return;
- }
- SetVisible(hasFocus);
+ void OnApplicationVisibilityChanged() {
+ ApplyNativeVisibility();
+ ApplyFocusOnCreate();
}
- ///
- /// Handler for app focus lost
- ///
- void OnApplicationPause(bool hasPause) {
- if (!_isMobileInputCreated || !Visible) {
- return;
- }
- SetVisible(!hasPause);
- }
-#endif
-
///
/// Current InputField for external access
///
@@ -371,7 +374,7 @@ public string Text {
return (_inputObject == null) ? string.Empty : _inputObject.text;
}
set {
- if (!_isMobileInputCreated) {
+ if (_initializationState != InitializationState.Ready) {
_textOnCreate = value;
return;
}
@@ -380,18 +383,80 @@ public string Text {
}
}
+ ///
+ /// Initialization
+ ///
+ void RequestInitialization() {
+ if (_initializationState != InitializationState.NotStarted || !isActiveAndEnabled) {
+ return;
+ }
+ _initializationState = InitializationState.Scheduled;
+ _initializationCoroutine = StartCoroutine(InitProcess());
+ }
+
+ ///
+ /// Cancel initialization before CREATE has been sent
+ ///
+ void CancelScheduledInitialization() {
+ if (_initializationState != InitializationState.Scheduled) {
+ return;
+ }
+ if (_initializationCoroutine != null) {
+ StopCoroutine(_initializationCoroutine);
+ }
+ _initializationCoroutine = null;
+ _initializationState = InitializationState.NotStarted;
+ }
+
///
/// Initialization
///
IEnumerator InitProcess() {
- if (_initStarted) {
+ yield return WaitForEndOfFrame;
+
+ _initializationCoroutine = null;
+ if (_initializationState != InitializationState.Scheduled) {
yield break;
}
- _initStarted = true;
- yield return WaitForEndOfFrame;
+ if (!isActiveAndEnabled) {
+ _initializationState = InitializationState.NotStarted;
+ yield break;
+ }
+ if (!IsRegistered) {
+ _initializationCoroutine = StartCoroutine(WaitForRegistration());
+ yield break;
+ }
+ CreateAfterRegistration();
+ }
+
+ ///
+ /// Wait until base.Start has registered the receiver before CREATE
+ ///
+ IEnumerator WaitForRegistration() {
+ while (_initializationState == InitializationState.Scheduled && isActiveAndEnabled && !IsRegistered) {
+ yield return null;
+ }
+ _initializationCoroutine = null;
+ if (_initializationState != InitializationState.Scheduled) {
+ yield break;
+ }
+ if (!isActiveAndEnabled) {
+ _initializationState = InitializationState.NotStarted;
+ yield break;
+ }
+ CreateAfterRegistration();
+ }
+
+ ///
+ /// Prepare and send CREATE exactly once
+ ///
+ void CreateAfterRegistration() {
PrepareNativeEdit();
#if (UNITY_IOS || UNITY_ANDROID) && !UNITY_EDITOR
- CreateNativeEdit();
+ var initialVisibility = ShouldShowNative();
+ _initializationState = InitializationState.WaitingForReady;
+ Visible = initialVisibility;
+ CreateNativeEdit(initialVisibility);
var data = this._inputObjectText.text.Trim();
if (data.Length == 1) {
var category = Char.GetUnicodeCategory(data[0]);
@@ -402,6 +467,10 @@ IEnumerator InitProcess() {
_inputObject.placeholder.gameObject.SetActive(false);
_inputObject.enabled = false;
_inputObjectText.enabled = false;
+#else
+ _initializationState = InitializationState.Ready;
+ ApplyNativeVisibility(true);
+ ApplyFocusOnCreate();
#endif
}
@@ -414,7 +483,7 @@ void Update() {
#if UNITY_ANDROID && !UNITY_EDITOR
UpdateForceKeyeventForAndroid();
#endif
- if (_inputObject != null && _isMobileInputCreated) {
+ if (_inputObject != null && _initializationState == InitializationState.Ready) {
#if !UNITY_EDITOR
var touchCount = Input.touchCount;
if (touchCount > 0) {
@@ -473,8 +542,11 @@ Rect GetScreenRectFromRectTransform(RectTransform rect) {
var yMax = float.NegativeInfinity;
for (var i = 0; i < 4; i++) {
Vector3 coords;
- if (rect.GetComponentInParent
/// JSON
public override void Send(JsonObject data) {
+ string msg = data["msg"];
+ if (msg.Equals(READY)) {
+ Ready();
+ return;
+ }
+ if (!isActiveAndEnabled) {
+ ProcessData(data);
+ return;
+ }
StartCoroutine(SendDataProcess(data));
}
@@ -529,7 +610,7 @@ public override void Send(JsonObject data) {
/// Remove focus, keyboard when app lose focus
///
public override void Hide() {
- if (_inputObject != null && _isMobileInputCreated) {
+ if (_inputObject != null && _initializationState == InitializationState.Ready) {
SetFocus(false);
}
}
@@ -540,12 +621,17 @@ public override void Hide() {
/// JSON
IEnumerator SendDataProcess(JsonObject data) {
yield return WaitForEndOfFrame;
+ ProcessData(data);
+ }
+
+ ///
+ /// Process a native callback on Unity's main thread
+ ///
+ void ProcessData(JsonObject data) {
string msg = data["msg"];
if (msg.Equals(TEXT_CHANGE)) {
string text = data["text"];
OnTextChange(text);
- } else if (msg.Equals(READY)) {
- Ready();
} else if (msg.Equals(ON_FOCUS)) {
OnFocusChanged(true);
} else if (msg.Equals(ON_UNFOCUS)) {
@@ -571,7 +657,7 @@ string InvariantCultureString(float value) {
///
/// Create native input field
///
- void CreateNativeEdit() {
+ void CreateNativeEdit(bool isVisible) {
var rect = GetScreenRectFromRectTransform(_inputObjectText.rectTransform);
var data = new JsonObject();
data["msg"] = CREATE;
@@ -612,6 +698,7 @@ void CreateNativeEdit() {
data["input_type"] = _config.InputType;
data["keyboard_type"] = _config.KeyboardType;
data["keyboard_language"] = KeyboardLanguage;
+ data["is_visible"] = isVisible;
data["return_key_type"] = ReturnKey switch {
ReturnKeyType.Next => (JsonNode)"Next",
ReturnKeyType.Done => (JsonNode)"Done",
@@ -627,16 +714,15 @@ void CreateNativeEdit() {
/// New field successfully added
///
void Ready() {
- _isMobileInputCreated = true;
+ if (_initializationState != InitializationState.WaitingForReady) {
+ return;
+ }
+ _initializationState = InitializationState.Ready;
if (!string.IsNullOrEmpty(_textOnCreate)) {
Text = _textOnCreate;
}
- if (!_isVisibleOnCreate) {
- SetVisible(false);
- }
- if (_isFocusOnCreate) {
- SetFocus(true);
- }
+ ApplyNativeVisibility(true);
+ ApplyFocusOnCreate();
}
///
@@ -737,10 +823,15 @@ void SetTextNative(string text) {
/// Remove field
///
void RemoveNative() {
- _isMobileInputCreated = false;
- var data = new JsonObject();
- data["msg"] = REMOVE;
- Execute(data);
+ var removeRequired = _initializationState == InitializationState.WaitingForReady ||
+ _initializationState == InitializationState.Ready;
+ _initializationState = InitializationState.Destroyed;
+ Visible = false;
+ if (removeRequired) {
+ var data = new JsonObject();
+ data["msg"] = REMOVE;
+ Execute(data);
+ }
}
///
@@ -749,7 +840,7 @@ void RemoveNative() {
/// RectTransform
public void SetRectNative(RectTransform inputRect) {
var rect = GetScreenRectFromRectTransform(inputRect);
- if (_inputObject == null || !_isMobileInputCreated || _lastRect == rect) {
+ if (_inputObject == null || _initializationState != InitializationState.Ready || _lastRect == rect) {
return;
}
_lastRect = rect;
@@ -768,10 +859,34 @@ public void SetRectNative(RectTransform inputRect) {
/// true | false
public void SetFocus(bool isFocus) {
#if (UNITY_IOS || UNITY_ANDROID) && !UNITY_EDITOR
- if (!_isMobileInputCreated) {
+ if (_initializationState != InitializationState.Ready || (isFocus && !ShouldShowNative())) {
_isFocusOnCreate = isFocus;
return;
}
+ _isFocusOnCreate = false;
+ SetFocusNative(isFocus);
+#else
+ if (gameObject.activeInHierarchy) {
+ if (isFocus) {
+ _inputObject.ActivateInputField();
+ } else {
+ _inputObject.DeactivateInputField();
+ }
+ _isFocusOnCreate = false;
+ } else {
+ _isFocusOnCreate = isFocus;
+ }
+#endif
+ }
+
+ ///
+ /// Apply focus directly without changing a deferred focus request
+ ///
+ void SetFocusNative(bool isFocus) {
+#if (UNITY_IOS || UNITY_ANDROID) && !UNITY_EDITOR
+ if (_initializationState != InitializationState.Ready) {
+ return;
+ }
var data = new JsonObject();
data["msg"] = SET_FOCUS;
data["is_focus"] = isFocus;
@@ -783,20 +898,41 @@ public void SetFocus(bool isFocus) {
} else {
_inputObject.DeactivateInputField();
}
- } else {
- _isFocusOnCreate = isFocus;
}
#endif
+ }
+ ///
+ /// Apply deferred focus once the native input can safely receive it
+ ///
+ void ApplyFocusOnCreate() {
+ if (!_isFocusOnCreate || _initializationState != InitializationState.Ready || !ShouldShowNative()) {
+ return;
+ }
+ _isFocusOnCreate = false;
+ SetFocusNative(true);
}
///
- /// Set field visible
+ /// Check the effective visibility without changing the public request
///
- /// true | false
- public void SetVisible(bool isVisible) {
- if (!_isMobileInputCreated) {
- _isVisibleOnCreate = isVisible;
+ bool ShouldShowNative() {
+ return _requestedVisible &&
+ isActiveAndEnabled &&
+ MobileInput.CanShowInputs &&
+ _initializationState != InitializationState.Destroyed;
+ }
+
+ ///
+ /// Apply the effective visibility to a native input that has been created
+ ///
+ void ApplyNativeVisibility(bool force = false) {
+ if (_initializationState != InitializationState.WaitingForReady &&
+ _initializationState != InitializationState.Ready) {
+ return;
+ }
+ var isVisible = ShouldShowNative();
+ if (!force && Visible == isVisible) {
return;
}
var data = new JsonObject();
@@ -807,6 +943,16 @@ public void SetVisible(bool isVisible) {
_lastRect = new Rect();
}
+ ///
+ /// Set requested field visibility
+ ///
+ /// true | false
+ public void SetVisible(bool isVisible) {
+ _requestedVisible = isVisible;
+ ApplyNativeVisibility();
+ ApplyFocusOnCreate();
+ }
+
#if UNITY_ANDROID && !UNITY_EDITOR
///
@@ -841,4 +987,4 @@ private void UpdateForceKeyeventForAndroid() {
#endif
}
-}
\ No newline at end of file
+}
diff --git a/package.json b/package.json
index 6660418..8bfdaf1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "com.mopsicus.umi",
- "version": "2.0.0",
+ "version": "2.0.6",
"displayName": "UMI",
"description": "UMI – Unity plugin for Android and iOS, allows to use features of mobile native input fields.",
"license": "MIT",
@@ -41,4 +41,4 @@
"path": "Samples~/Demo"
}
]
- }
\ No newline at end of file
+ }