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().renderMode == RenderMode.ScreenSpaceOverlay) { + var canvas = rect.GetComponentInParent(); + if (canvas.renderMode == RenderMode.ScreenSpaceOverlay) { coords = corners[i]; + } else if (canvas.renderMode == RenderMode.ScreenSpaceCamera) { + coords = canvas.worldCamera.WorldToScreenPoint (corners[i]); } else { coords = RectTransformUtility.WorldToScreenPoint(Camera.main, corners[i]); } @@ -522,6 +594,15 @@ void OnTextEditEnd(string text) { /// /// 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 + }