網頁嵌入插件最好的應該就是ZFBrowser了, 可是使用起來也是問題多多, 現在最要命的是網頁輸入不能打中文, 作者也沒打算接入IME, 只能自己想辦法了...
搞了半天只想到一個辦法, 就是通過Unity的IME去觸發中文輸入, 然后傳入網頁, 也就是說做一個透明的 InputField 蓋住網頁的輸入文本框, 然后在 Update 或是 onValueChanged 中把內容傳給網頁, 這樣基本就能實現中文輸入了.
因為對前端不熟悉, 我就做了一個簡單網頁做測試:
<html> <head> <title>My first page</title> <style> body { margin: 0; } </style> </head> <body> <h1>Test Input</h1> Field1: <input type="text" id="field1"> Field2: <input type="text" id="field2"> <br> <br> <script> function SetInputValue(id, str) { document.getElementById(id).value = str; } function SubmitInput(str) { document.getElementById("field2").value = "Submited : " + str; } </script> </body> </html>
這里網頁有兩個Text Area, 左邊作為輸入, 右邊作為回車后的調用測試:
然后Unity中直接用一個InputField放到 Field1 的位置上, 設置為透明, 通過Browser類提供的CallFunction方式調用就可以了:
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using UnityEngine.EventSystems; namespace UIModules.UITools { public class BrowserInputField : MonoBehaviour { [SerializeField] public ZenFulcrum.EmbeddedBrowser.Browser browser; [SerializeField] public InputField input; [Space(10.0f)] [Header("設置網頁 input 函數名稱")] [SerializeField] public string SetInputFuncName = "SetInputFuncName"; [Header("設置網頁 submit 函數名稱")] [SerializeField] public string SubmitFuncName = "SubmitFuncName"; [Header("網頁 input id")] [SerializeField] public string InputElementID = "InputElementID"; public bool inited { get; private set; } private void Awake() { this.RequireComponent<CanvasGroup>().alpha = 0.01f; Init(); } public void Init() { if(input && (false == inited)) { inited = true; input.RequireComponent<IME_InputFollower>(); // IME 跟隨 StartCoroutine(CaretAccess((_caret) => { if(_caret) { var group = _caret.RequireComponent<CanvasGroup>(); group.alpha = 1f; group.ignoreParentGroups = true; } })); } } IEnumerator CaretAccess(System.Action<Transform> access) { if(input) { var caret = input.transform.Find("InputField Input Caret"); while(caret == false && input) { caret = input.transform.Find("InputField Input Caret"); yield return null; } access.Invoke(caret); } } void Update() { if(browser && input) { browser.CallFunction(SetInputFuncName, new ZenFulcrum.EmbeddedBrowser.JSONNode[2] { new ZenFulcrum.EmbeddedBrowser.JSONNode(InputElementID), new ZenFulcrum.EmbeddedBrowser.JSONNode(input.isFocused ? input.text : (string.IsNullOrEmpty(input.text)?input.placeholder.GetComponent<Text>().text: input.text)) }); } } } }
這里InputField它會自動生成 Caret 就是輸入標記, 為了讓他能顯示出來, 需要等待到它創建出來之后設置透明度即可. 這里省掉了IME輸入法跟隨的代碼, 那是其它功能了.
恩, 因為字體大小不一樣, 所以Caret位置不准確, 反正是能輸入了.
這是靜態的寫法, 可以手動去擺 InputField, 可是在很多情況下是不適用的, 比如 Scroll View 里面的元素, 就需要動態去獲取了, 可是由於我們無法計算出網頁 input 的位置, 所以沒法動態地去設置一個InputField來對上網頁, 如果對輸入標記沒有要求的話 (就是那個打字時候會閃的 "|" 豎杠) , 就可以通過注冊網頁 input 的 onFocus 方法, 來 focus 一個 InputField, 從而觸發輸入法, 然后再像上面一樣監測輸入就行了, 而且不需要在網頁端寫輸入函數來調用了, 這個函數我們應該也是可以自己注冊進去的...
這個想法很好, 來測試看看能不能獲取網頁中的所有 input 節點吧, 在網頁那邊寫測試(因為我確實沒寫過網頁...) :
// ... 省略代碼了 Field1: <input type="text" id="field1"> Field2: <input type="text" id="field2"> <button type="button" onclick="Click()">Get ID</button> <br> <p id="show">Show</p> <script> function Click() { var inputs, index; var showInfo = ""; inputs = document.getElementsByTagName('input'); for (index = 0; index < inputs.length; ++index) { showInfo = showInfo + inputs[index].id + " "; } document.getElementById("show").innerHTML = showInfo; } </script> // ...
沒錯是能獲取ID, 那我從Unity那邊來添加這個函數試試:
private void OnGUI() { if(GUI.Button(new Rect(500, 100, 100, 50), "TestClick")) { var script = @" function TestClick() { var inputs, index; var array = new Array(); inputs = document.getElementsByTagName('input'); for (index = 0; index < inputs.length; ++index) { array.push(inputs[index].id); } return array; }"; var inputs = browser.EvalJS(script); if(inputs != null) { inputs.Done((_value) => { if(_value != null) { Debug.Log(_value.ToString()); } var retVal = browser.CallFunction("TestClick"); if(retVal != null) { retVal.Done((_ret) => { if(_ret != null) { Debug.Log(_ret.ToString()); } }); } }); } } }
我創建了一個 TestClick 方法, 通過 EvalJS 解釋到網頁中, 還好這些解釋語言的套路都差不多, 只是不知道它給我返回的是啥, 第一個解釋 js function的返回有點意外, 居然是個空 :
不過沒關系, 后面的函數調用返回是我要的 :
不錯, 返回了我要的節點名稱, 這樣函數就注冊進去然后調用成功了, 說明確實可以通過注入式的代碼完成調用, 然后我只需要把另一個設置 input 內容的代碼注入進去, 就可以隨時修改所有 input 對象了.
// 本作核心代碼
function SetInputValue(id, str) {
document.getElementById(id).value = str;
}
馬上加進去看看, 先整合一下代碼把請求提取出來 :
public static void WebBrowserFunctionRegister(ZenFulcrum.EmbeddedBrowser.Browser browser, string function, System.Action<ZenFulcrum.EmbeddedBrowser.JSONNode> succ = null) { if(browser) { var register = browser.EvalJS(function); if(register != null) { register.Done((_value) => { if(succ != null) { succ.Invoke(_value); } }); } } } public static void WebBrowserFunctionCall(ZenFulcrum.EmbeddedBrowser.Browser browser, string functionname, ZenFulcrum.EmbeddedBrowser.JSONNode[] param, System.Action<ZenFulcrum.EmbeddedBrowser.JSONNode> result = null) { if(browser) { var retVal = param != null && param.Length > 0 ? browser.CallFunction(functionname, param) : browser.CallFunction(functionname); if(retVal != null) { retVal.Done((_ret) => { if(result != null) { result.Invoke(_ret); } }); } } } private void OnGUI() { if(GUI.Button(new Rect(500, 100, 100, 50), "TestClick")) { var testClick = @" function TestClick() { var inputs, index; var array = new Array(); inputs = document.getElementsByTagName('input'); for (index = 0; index < inputs.length; ++index) { array.push(inputs[index].id); } return array; }"; var coreScript = @" function SetInputValue(id, str) { document.getElementById(id).value = str; } "; WebBrowserFunctionRegister(browser, testClick, (_) => { WebBrowserFunctionCall(browser, "TestClick", null, (_ret) => { WebBrowserFunctionRegister(browser, coreScript, (__) => { var list = LitJson.JsonMapper.ToObject<List<string>>(_ret.AsJSON); if(list != null) { foreach(var id in list) { WebBrowserFunctionCall(browser, "SetInputValue", new ZenFulcrum.EmbeddedBrowser.JSONNode[2] { new ZenFulcrum.EmbeddedBrowser.JSONNode(id), new ZenFulcrum.EmbeddedBrowser.JSONNode("測試:" + id), }); } } }); }); }); } }
因為我不確定它是不是都是異步的, 所以都用回調的形式來做了, 結果喜人, 確實能夠正確運行了:
幾乎成了, 下一步就是注冊一下 input 的 focus 事件, 在網頁觸發 focus 之后就創建一個 InputField 按照套路走就行了, 在 InputField 的focus取消的時候銷毀它, 就能完美解決輸入法問題了...
-------------------------------------------------------------------------------------------------
(2020.7.7)
之前的理論沒有問題, 不過可以更加簡化一點, 首先 ZFBrowser 解析的網頁, 它的 focus 跟 InputField 中的 focus 並不沖突, 並且在兩邊都 focus 的情況下, 網頁接收的輸入就是 Unity 調用的 IME, 所以就不需要同步 InputField 中的輸入到網頁那邊了, InputField 只作為啟動 IME 的入口即可.
然后發現很多網頁中的 input 元素並不使用 id, 而是直接 class 設置了調用邏輯, 比較面向過程, 而且W3C標准中, 每個控件或者元素, 並沒有一個GUID, 這就無法通過唯一ID定位到某個元素上了 ( [對Web頁面元素的絕對唯一引用方法] https://www.cnblogs.com/birdshome/archive/2006/09/28/uniqueid_usage.html )...
那么我們想要獲取和設置某個 input 的元素的時候, 就需要自己給沒有 id 的 input 元素添加ID了.
然后一個元素的調用函數, 不像C#中的delegate那么方便, 你要添加一個唯一調用, 只需要刪除原有回調再添加即可:
browser.onConsoleMessage -= OnConsoleMessage;
browser.onConsoleMessage += OnConsoleMessage;
C#怎么樣都不會錯誤添加多個同樣的回調. 可是JS沒有這個, 有些人自己寫了相似的, 可是不是面向對象, 肯定會出錯.
首先來看看怎樣給 input 元素添加 id, 然后添加 onfocus 方法給它, 讓它在焦點的時候能夠通知到 Unity 來創建 InputField 觸發 IME.
/* 創建唯一ID代碼 */
public const string InjectInputID_JS_Name = "InjectInputID"; public const string InjectInputID_JS = @" var inputID = 1; function InjectInputID() { var inputs, index; inputs = document.getElementsByTagName('input'); for (index = 0; index < inputs.length; ++index) { var rawID = inputs[index].id; if(rawID == null || rawID == ''){ inputs[index].id = 'custom_input_id_' + (inputID++); } } }"; // 某處調用注冊函數 WebBrowserFunctionRegister(browser, InjectInputID_JS);
上面的注入ID代碼使用了一個全局變量 inputID, 這樣在設置時就能避免網頁動態加載出來的新元素得到同樣的ID了.
/* 添加回調事件方法 */
public const string AddEventFunc_JS_Name = @"AddEventFunc"; public const string eventFuncNameTemplate_JS = "EVENTFUNC"; public const string eventTemplateName_JS = "EVENTNAME"; public const string AddEventFuncTemplate_JS = @" function AddEventFunc(elementID) { var tagElement = document.getElementById(elementID); if (tagElement != null) { var oldFuncStr = (tagElement.EVENTFUNC + '').replace(/(\n)+|(\r\n)+/g, ''); var rawFunc = oldFuncStr.substring(oldFuncStr.indexOf('{') + 1, oldFuncStr.indexOf('}')); var newFunc = function() { eval(rawFunc); console.log(elementID + ':EVENTNAME'); } if((newFunc + '').replace(/(\n)+|(\r\n)+/g, '') == oldFuncStr){ return; } tagElement.EVENTFUNC = newFunc; } }"; public enum ElementEventFunc { onclick, onsubmit, onfocus } public static string GenerateAddEventFunc_JS_Code(ElementEventFunc eventFunc, string customEvent) { return GenerateAddEventFunc_JS_Code(eventFunc.ToString(), customEvent); } public static string GenerateAddEventFunc_JS_Code(string eventFunc, string customEvent) { return AddEventFuncTemplate_JS.Replace(eventFuncNameTemplate_JS, eventFunc).Replace(eventTemplateName_JS, customEvent); } // 某處調用注冊函數 string focusFunc_JS = GenerateAddEventFunc_JS_Code(ElementEventFunc.onfocus, ElementEventFunc.onfocus.ToString() + ":" + browser.GetHashCode().ToString()); WebBrowserFunctionRegister(browser, focusFunc_JS);
這里使用了一個模板來創建 function, 因為考慮到以后可能會使用到其它事件的注冊, 不一定只有 focus 的.
說來瀏覽器的解釋執行代碼也挺神奇的, 一個函數可以直接以字符串的方式獲取, 好像叫Blob, 反正就像上面代碼中的, 比如是 onfocus 函數, 那么就成了:
var oldFuncStr = (tagElement.onfocus + '').replace(/(\n)+|(\r\n)+/g, '');
這樣就把 onfocus 的調用方法字符串得到了, 像是下面這樣:
<input type="text" id="field1", onfocus="OnFocus(this.id)"> <script> function OnFocus(id){ console.log(id); } function Test() { var tag = document.getElementById('field1'); console.log((tag.onfocus + '').replace(/(\n)+|(\r\n)+/g, '')); } Test() </script>
得到的 onfocus 字符串 :
function onfocus(event) { OnFocus(this.id)}
然后就是把里面的方法取出來, 封裝到新的方法里面去, 當然原有方法是字符串, 必須使用 eval 來進行編譯調用:
var rawFunc = oldFuncStr.substring(oldFuncStr.indexOf('{') + 1, oldFuncStr.indexOf('}')); var newFunc = function() { eval(rawFunc); console.log(elementID + ':onfocus:XXXX'); // 這里運行時XXXX被設置為unity Browser對象的哈希值 } if((newFunc + '').replace(/(\n)+|(\r\n)+/g, '') == oldFuncStr){ return; } tagElement.onfocus = newFunc; //
newFunc 就包含了老函數調用和新的 Log, 我們就是以監聽 log 來發送消息的, 中間有個比較 newFunc 和 oldFuncStr 的邏輯, 因為它不像delegate那樣可以不重復添加回調, 並且 JS 的回調會包含閉包信息之類的, 如果這個添加回調的添加了兩次, 它會造成死循環, 我不是很清楚為什么, 所以判斷相同的回調時不再進行添加. 這里就限定了只能添加一次回調, 邏輯是有問題的, 不過本工程中使用上已經夠了.
這樣就能注冊並監聽網頁 input 元素的 onfocus 事件了. 詳細注冊方法如下, 因為網頁回調的log必須帶有對應的網頁ID, 才能分清是哪個網頁的 input 被焦點了:
private Dictionary<Browser, HashSet<string>> m_focusTargets = new Dictionary<Browser, HashSet<string>>(); public const string GetInputs_JS_Name = "GetInputs"; public const string GetInputs_JS = @" function GetInputs() { var inputs, index; var array = new Array(); inputs = document.getElementsByTagName('input'); for (index = 0; index < inputs.length; ++index) { array.push(inputs[index].id); } return array; }"; private void RegisterBaseFunctions(Browser browser) { browser.onConsoleMessage -= OnFocus; browser.onConsoleMessage += OnFocus; WebBrowserFunctionRegister(browser, InjectInputID_JS); string focusFunc_JS = GenerateAddEventFunc_JS_Code(ElementEventFunc.onfocus, ElementEventFunc.onfocus.ToString() + ":" + browser.GetHashCode().ToString()); WebBrowserFunctionRegister(browser, GetInputs_JS); } private void OnFocus(string msg, string src) { if(string.IsNullOrEmpty(msg)) { return; } Debug.Log("OnFocus msg : " + msg); var sp = msg.Split(':'); if(sp != null && sp.Length >= 3) { var id = sp[0]; var hashCode = sp[2]; switch(sp[1]) { case "onfocus": { OnFocus(GetBrowserByHash(hashCode), id); } break; } } } public Browser GetBrowserByHash(string hashCode) { foreach(var browser in m_scanTargets.Keys) { if(browser && string.Equals(browser.GetHashCode().ToString(), hashCode, System.StringComparison.Ordinal)) { return browser; } } return null; } private void OnFocus(Browser browser, string id, string text = null) { if(browser) { // ...... } }
因為網頁會動態加載或者創建元素, 所以獲取 input 和注入回調需要在update或者協程中不斷地獲取, 來保證每個 input 的回調正確...
如果 input 的 id 是 "field1", 那么回調中傳回來的 message 就是 "field1:onfocus:XXXX" , XXXX就是 browser.GetHashCode().ToString()
在協程中去不斷檢測是否有新 input 元素:
// 在某處調用 StartCoroutine(CheckWebInput()); private IEnumerator CheckWebInput() { while(true) { yield return null; foreach(var tags in m_focusTargets) { var browser = tags.Key; BrowserInputCheck(browser, tags.Value); } } } // 因為網頁可能動態加載, 我們需要不斷地獲取網頁 input 元素, 來進行注冊 onfocus 回調 private void BrowserInputCheck(Browser browser, HashSet<string> exists) { if(browser && browser.IsLoaded) { WebBrowserFunctionCall(browser, InjectInputID_JS_Name, null, (_) => { WebBrowserFunctionCall(browser, GetInputs_JS_Name, null, (_ret) => { var inputIDs = LitJson.JsonMapper.ToObject<List<string>>(_ret.AsJSON); if(inputIDs != null && inputIDs.Count > 0) { foreach(var inputID in inputIDs) { if(exists.Contains(inputID) == false) { exists.Add(inputID); WebBrowserFunctionCall(browser, AddEventFunc_JS_Name, new JSONNode[1] { new JSONNode(inputID) }); } } } }); }); } }
當可以正常收到 onfocus 事件之后, 只需要對應創建 InputField 組件, 然后同樣把 Unity 的 Focus 給這個 InputField 就行了, 至於 html 的 blur (丟失焦點) 事件, 這里是不用監聽的, 因為 InputField 同樣會因為鼠標操作, 鍵盤 Enter/Return 按鍵, ESC 按鍵觸發 OnEditEnd 並丟失焦點, 所以我們只需要監聽沒有丟失焦點的情況即可.
在什么情況下 Unity的 InputField會丟失焦點而網頁 input 不會丟失焦點呢? 測試了一下包含以下情形:
1. Enter / Return / ESC 鍵盤都觸發了 InputField 的丟失焦點, 可是網頁並不一定會丟失焦點.
2. 鼠標移出網頁顯示的UI區域, 網頁會丟失焦點, 不過鼠標移回網頁它會自動觸發 onfocus, 這都沒有問題, 可是如果用戶再次點擊網頁 input 區域, 不會再次觸發 onfocus 事件, 此時由於點擊操作 InputField 會丟失焦點.
應對這些情況, 就需要做對應修改, 在 InputField 的 onEndEdit 回調中, 添加相關測試以及操作 (回調經過封裝處理, 變量 BrowserInputField 包含了相關組件引用) :
public const string HasFocusFunc_JS_Name = "GetHasFocus"; public const string HasFocusFunc_JS = @" function GetHasFocus(id) { var target = document.getElementById(id); return (target != null && target.id == document.activeElement.id); }"; public const string CancelFocus_JS_Name = "CancelFocus"; public const string CancelFocus_JS = @" function CancelFocus() { document.activeElement.blur(); }"; // 在某處進行注冊 WebBrowserFunctionRegister(browser, HasFocusFunc_JS); WebBrowserFunctionRegister(browser, CancelFocus_JS); // 封裝后的 InputField.onEndEdit 回調. BrowserInputField 包含相關組件 private void OnEditEnd(BrowserInputField inputField) { if(inputField) { if(Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.KeypadEnter) || Input.GetKeyDown(KeyCode.Escape)) { WebBrowserFunctionCall(inputField.browser, CancelFocus_JS_Name, null); } else { var ui = MathTools.GetMouseOnUI(); if(ui) { var guiData = ui.GetComponent<RuntimeData.BrowserGUINetData>(); if(guiData && guiData.browser == inputField.browser) { Core.CoroutineRoot.instance.RunWaitFrames(2, () => { WebBrowserFunctionCall(inputField.browser, HasFocusFunc_JS_Name, new JSONNode[1] { new JSONNode(inputField.InputElementID) }, (_ret) => { Common.DataTable value = _ret.AsJSON; if((bool)value) { inputField.FocusInputField(); } }); }); } } } } }
1. 當Enter / Return / ESC 鍵盤都觸發了 InputField 的丟失焦點, 強行對網頁當前的焦點執行 blur 操作, 禁止沒有IME的輸入繼續輸入網頁.
2. 當鼠標還在網頁UI區域時, 如果丟失了焦點就檢測對應ID的 input 是否也丟失了焦點, 如果沒有就重新焦點到 InputField.
這樣解決了焦點問題之后, 輸入 中文 / 日文 這些需要IME支持的語言就能正確輸入了, 當然如果看了源代碼知道怎樣直接觸發IME的話, 可以把 InputField 省略掉. Unity 怎樣Focus目標的代碼:
public void FocusInputField() { if(input) { if (EventSystem.current.currentSelectedGameObject != input.gameObject) { EventSystem.current.SetSelectedGameObject(input.gameObject, null); } input.OnPointerClick(new PointerEventData(EventSystem.current)); } }
IME 跟隨在之前的帖子里 : https://www.cnblogs.com/tiancaiwrk/p/12603955.html
完成這些之后, 還有一步, 就是動態創建 InputField 以及讓它跟隨 input 元素的位置, 因為 IME 會跟隨 InputField, 可是 InputField 怎樣跟隨網頁 input 元素呢? 這里當然地需要獲取 input 元素的位置信息了, html 提供了相關代碼 :
// 獲取頁面大小 var width = document.body.clientWidth; var height = document.body.clientHeight; // 獲取元素位置以及大小 var tag = document.getElementById(id); var rect = tag.getBoundingClientRect();
頁面大小可以理解為整個 html 渲染區域的大小, 它跟UGUI的大小有對應關系, 比如下圖顯示頁面大小1000x1000, 在UGUI上它渲染的大小就是UI的Rect Size:
這需要關閉自動修改尺寸才能實現:
如果是這種情況, 那么計算 input 元素的位置就需要經過二次轉換了, 首先UGUI中屏幕位置左下角為(0,0), 而 html 中左上角才是(0,0):
1. 需要先獲取頁面大小, 然后獲取 input 在頁面中的位置
2. 獲取渲染UI的大小, 並獲取UI左上角的坐標位置
3. 計算 input 在頁面中的歸一化位置 [0,1], 然后相對UI左上角位置獲取偏移量, 並轉換到當前UI Pivot的相對偏移量
4. UI位置加上偏移量就是 input 元素的位置了, 可以在Update中進行跟隨操作 (這是網頁渲染到UI面板, 面板可移動的情況)
5. 如果頁面內的元素也是可以移動的, 比如頁面中有Scroll可以滑動input, 就需要隨時重新計算偏移量了
以上面的觸發Focus的位置作為入口, 創建InputField, 並計算跟隨偏移量 :
const string GetElementRect_JS_Name = "GetElementRect"; const string GetElementRect_JS = @" function GetElementRect(id) { var tag = document.getElementById(id); var rect = tag.getBoundingClientRect(); var region = {}; region['x'] = rect.left; region['y'] = rect.top; region['width'] = Math.abs(rect.right - rect.left); region['height'] = Math.abs(rect.top - rect.bottom); return region; }"; const string GetHtmlBodySize_JS_Name = "GetHtmlBodySize"; const string GetHtmlBodySize_JS = @" function GetHtmlBodySize() { var region = {}; region['x'] = document.body.clientWidth; region['y'] = document.body.clientHeight; return region; }"; // 在某處注冊代碼 WebBrowserFunctionRegister(browser, GetElementRect_JS); WebBrowserFunctionRegister(browser, GetHtmlBodySize_JS); private void OnFocus(Browser browser, string id, string text = null) { if(browser) { var dict = m_scanTargets.GetValue(browser); var browserInputField = dict.TryGetNullableValue(id); if(browserInputField == false) { // 創建代碼, 省略 } if(browserInputField) { InputFieldFollowWebInput(browserInputField); // 設置跟隨 browserInputField.gameObject.SetActive(true); browserInputField.FocusInputField(); // Focus 到InputField, 觸發IME } } } private void InputFieldFollowWebInput(BrowserInputField inputField) { if(inputField && inputField.browser) { inputField.transform.position = Input.mousePosition; WebBrowserFunctionCall(inputField.browser, GetElementRect_JS_Name, new ZenFulcrum.EmbeddedBrowser.JSONNode[1] { new ZenFulcrum.EmbeddedBrowser.JSONNode(inputField.InputElementID) }, (_rect) => { var inputRect = LitJson.JsonMapper.ToObject<Rect>(_rect.AsJSON); WebBrowserFunctionCall(inputField.browser, GetHtmlBodySize_JS_Name, null, (_size) => { var htmlSize = LitJson.JsonMapper.ToObject<Vector2>(_size.AsJSON); var tag = inputField.browser.GetComponent<Modules.BrowserRenderTarget>(); if(tag) { var rect = tag.browserGUINetData.transform as RectTransform; if(rect) { var uiSize = rect.rect.size; var anchorPos = rect.position + MathTools.Multiple(new Vector2(0, 1) - rect.pivot, uiSize).UpGrade(MathTools.VecAxis.Z); var x = (inputRect.x / htmlSize.x) * uiSize.x; var y = (inputRect.y / htmlSize.y) * uiSize.y; var inputPos = anchorPos + new Vector3(x, -y, 0); var offset = MathTools.Multiple(inputPos - rect.position, rect.lossyScale); Tools.UIObjectFollow.instance.AddFollowInfo(inputField.transform as RectTransform, rect, offset); } } }); }); } }
(2020.07.09)
上面的邏輯仍舊是對頁面元素位置不變而計算的( UGUI可以移動位置, 因為輸入法可以跟隨 ), 說到頁面元素位置會變化的情況, 以最簡單的例子為例 : 當界面大小與網頁大小不同時, 有 Scroll 拖動條的情況.
先看看網頁代碼, 控制了元素大小使得頁面大小比較大:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Scroll</title> <style> #top { height: 500px; color: #FFF; background-color: #000000; } #bottom { height: 100px; color: rgb(0, 0, 0); background-color: #ffffff; } </style> </head> <body> <div> Input1 : <input id="field1"> <button id="top" onclick="GetInfo()">Button</button> <br> Input2 : <input id="bottom"> </div> <script type="text/javascript"> function GetInfo() { console.log("網頁大小 : " + document.body.clientWidth + " x " + document.body.clientHeight); } </script> </body> </html>
那么在Unity中運行時, 有UGUI大小, 以及Browser設定頁面大小, 以及實際網頁大小:
( UGUI 400 x 400 )
( Browser 300 x 500 )
網頁Log打印出 267 x 627, 所以在頁面上可以看到因為 Browser的大小 Height 500 < 627, 所以出現了拖動條, 而且映射到了UGUI 400 x 400 的RawImage上, 所以產生了縮放.
因為我制作的 InputField 預制體是以左上角對齊的, 所以只需要計算出網頁 input 的左上角坐標即可:
獲取 input 在html中坐標的時候, 它給的坐標是已經計算過Scroll之后的坐標, 所以直接使用即可:
看到它們的 top 位置因為 Scroll 拖動而改變了, 這樣就省了我們要去計算 Scroll 造成的偏移了.
下面的代碼就是怎樣計算 input 位置到 UGUI 位置的算法了:
private void InputFieldFollowWebInput(BrowserInputField inputField) { if(inputField && inputField.browser) { WebBrowserFunctionCall(inputField.browser, GetElementRect_JS_Name, new ZenFulcrum.EmbeddedBrowser.JSONNode[1] { new ZenFulcrum.EmbeddedBrowser.JSONNode(inputField.InputElementID) }, (_rect) => { var inputRect = LitJson.JsonMapper.ToObject<Rect>(_rect.AsJSON); InputFieldFollowWebInput(inputField, inputRect); }); } } private void InputFieldFollowWebInput(BrowserInputField inputField, Rect inputRect) { if(inputField && inputField.browser) { var tag = inputField.browser.GetComponent<Modules.BrowserRenderTarget>(); if(tag && tag.renderTarget) { var rect = tag.renderTarget.rectTransform; if(rect) { var uiSize = rect.rect.size; // UGUI size var anchorPos = rect.position + MathTools.Multiple(new Vector2(0, 1) - rect.pivot, uiSize).UpGrade(MathTools.VecAxis.Z); // ugui top left pos var browserSize = inputField.browser.Size; // uiSize equals to how large the browserSize is var x_normalized = (inputRect.x / browserSize.x); // normalized pos_x var y_normalized = (inputRect.y / browserSize.y); // normalized pos_y var x = (x_normalized) * uiSize.x; var y = (y_normalized) * uiSize.y; var inputPos = anchorPos + new Vector3(x, -y, 0); var offset = MathTools.Multiple(inputPos - rect.position, rect.lossyScale); var inputPosUI = rect.position + offset; inputField.transform.position = inputPosUI; } } } }
感覺比之前的算法反而更簡單了? 沒錯, 因為使用了統一歸一化算法, 本來 x_normalized , y_normalized 需要使用 html 網頁大小來進行計算的:
var x_normalized = (inputRect.x / htmlSize.x) * (htmlSize.x / browserSize.x); // normalized pos_x var y_normalized = (inputRect.y / htmlSize.y) * (htmlSize.y / browserSize.y); // normalized pos_y
不過剛好因為統一歸一化被消除了, 並且 html 的元素位置信息包含了 Scroll 之后的信息, 所以其他信息都不需要了, 只需要 input 元素的位置信息就夠了. 把計算要放在Update之中才行了, 因為要隨時計算坐標......
經過這些過程, 基本上坐標沒有問題了, 因為運行時 InputField 是完全透明的, 所以大小之類的都無所謂了, 只要去除 raycast 相關的讓人無法選中即可.
----------------------- 簡化了一些代碼 --------------------------

using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using UnityEngine.EventSystems; namespace UIModules.UITools { /// <summary> /// embered input to browser input, active the IME /// </summary> public class BrowserInputField : MonoBehaviour { [SerializeField] public ZenFulcrum.EmbeddedBrowser.Browser browser; [SerializeField] public InputField input; [Space(10.0f)] [Header("網頁 input id")] [SerializeField] public string InputElementID = "InputElementID"; public Core.Event<BrowserInputField> onEditEnd = new Core.Event<BrowserInputField>(); public bool inited { get; private set; } #region Mono Funcs private void Awake() { this.RequireComponent<CanvasGroup>().alpha = 0.0f; Init(); } #endregion #region Main Funcs public void Init() { if(input && (false == inited)) { inited = true; input.RequireComponent<IME_InputFollower>(); input.RequireComponent<InputFocusOutOfControl>(); input.onEndEdit.AddListener(OnEndEdit); } } public void FocusInputField() { if(input) { if (EventSystem.current.currentSelectedGameObject != input.gameObject) { EventSystem.current.SetSelectedGameObject(input.gameObject, null); } input.OnPointerClick(new PointerEventData(EventSystem.current)); } } #endregion #region Events private void OnEndEdit(string txt) { onEditEnd.Invoke(this); } #endregion } }

using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using UnityEngine.EventSystems; namespace UIModules.UITools { using ZenFulcrum.EmbeddedBrowser; /// <summary> /// AutoBrowserInputField can auto fit html input element /// </summary> public class AutoBrowserInputField : SingletonComponent<AutoBrowserInputField> { private Dictionary<Browser, Dictionary<string, BrowserInputField>> m_scanTargets = new Dictionary<Browser, Dictionary<string, BrowserInputField>>(); private Dictionary<Browser, HashSet<string>> m_focusTargets = new Dictionary<Browser, HashSet<string>>(); public UIModules.UITools.BrowserInputField inputTemplate { get { return UIManager.instance.Get<BrowserInputFieldPanel>("UI/CommonUI/BrowserInputFieldPanel").browserInputFieldTemplate; } } #region Overrides protected override void Initialize() { } protected override void UnInitialize() { } #endregion #region Mono Funcs private void Update() { CheckWebInput(); } #endregion #region Main Funcs public void AddAutoInputBrowser(Browser browser) { if(browser) { RemoveAutoInputBrowser(browser); RegisterBaseFunctions(browser); } } public Browser GetBrowserByHash(string hashCode) { foreach(var browser in m_scanTargets.Keys) { if(browser && string.Equals(browser.GetHashCode().ToString(), hashCode, System.StringComparison.Ordinal)) { return browser; } } return null; } public void RemoveAutoInputBrowser(Browser browser) { if(browser) { var dict = m_scanTargets.TryGetNullableValue(browser); if(dict != null) { foreach(var field in dict.Values) { if(field) { GameObject.Destroy(field.gameObject); } } m_scanTargets.Remove(browser); } m_focusTargets.Remove(browser); } } private void CheckWebInput() { foreach(var tags in m_focusTargets) { var browser = tags.Key; BrowserInputCheck(browser, tags.Value); } foreach(var tags in m_scanTargets.Values) { foreach(var browserInputField in tags.Values) { InputFieldFollowWebInput(browserInputField); } } } #endregion #region Events private void OnFocus(string msg, string src) { if(string.IsNullOrEmpty(msg)) { return; } Debug.Log("OnFocus msg : " + msg); var sp = msg.Split(':'); if(sp != null && sp.Length >= 3) { var id = sp[0]; var hashCode = sp[2]; switch(sp[1]) { case "onfocus": { OnFocus(GetBrowserByHash(hashCode), id); } break; } } } private void OnFocus(Browser browser, string id, string text = null) { if(browser) { var dict = m_scanTargets.GetValue(browser); var browserInputField = dict.TryGetNullableValue(id); if(browserInputField == false) { browserInputField = UIManager.CopyItem<BrowserInputField>(inputTemplate.transform as RectTransform); browserInputField.gameObject.name = id; dict[id] = browserInputField; browserInputField.browser = browser; browserInputField.InputElementID = id; browserInputField.Init(); browserInputField.onEditEnd.AddListener(OnEditEnd); } if(browserInputField) { if(browserInputField.input) { browserInputField.input.text = string.Empty; } InputFieldFollowWebInput(browserInputField); browserInputField.gameObject.SetActive(true); browserInputField.FocusInputField(); } } } private void OnEditEnd(BrowserInputField inputField) { if(inputField) { if(Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.KeypadEnter) || Input.GetKeyDown(KeyCode.Escape)) { WebBrowserFunctionCall(inputField.browser, CancelFocus_JS_Name, null); } else { var ui = MathTools.GetMouseOnUI(); if(ui) { var guiData = ui.GetComponent<RuntimeData.BrowserGUINetData>(); if(guiData && guiData.browser == inputField.browser) { Core.CoroutineRoot.instance.RunWaitFrames(2, () => { WebBrowserFunctionCall(inputField.browser, HasFocusFunc_JS_Name, new JSONNode[1] { new JSONNode(inputField.InputElementID) }, (_ret) => { Common.DataTable value = _ret.AsJSON; if((bool)value) { inputField.FocusInputField(); } }); }); } } } } } #endregion #region Help Funcs private void RegisterBaseFunctions(Browser browser) { m_scanTargets[browser] = new Dictionary<string, BrowserInputField>(); m_focusTargets[browser] = new HashSet<string>(); browser.onConsoleMessage -= OnFocus; browser.onConsoleMessage += OnFocus; WebBrowserFunctionRegister(browser, SetInputValue_JS); WebBrowserFunctionRegister(browser, HasFocusFunc_JS); WebBrowserFunctionRegister(browser, CancelFocus_JS); WebBrowserFunctionRegister(browser, InjectInputID_JS); string focusFunc_JS = GenerateAddEventFunc_JS_Code(ElementEventFunc.onfocus, ElementEventFunc.onfocus.ToString() + ":" + browser.GetHashCode().ToString()); WebBrowserFunctionRegister(browser, focusFunc_JS); WebBrowserFunctionRegister(browser, GetInputs_JS); WebBrowserFunctionRegister(browser, GetElementRect_JS); } private void BrowserInputCheck(Browser browser, HashSet<string> exists) { if(browser && browser.IsLoaded) { WebBrowserFunctionCall(browser, InjectInputID_JS_Name, null, (_) => { WebBrowserFunctionCall(browser, GetInputs_JS_Name, null, (_ret) => { var inputIDs = LitJson.JsonMapper.ToObject<List<string>>(_ret.AsJSON); if(inputIDs != null && inputIDs.Count > 0) { foreach(var inputID in inputIDs) { if(exists.Contains(inputID) == false) { exists.Add(inputID); WebBrowserFunctionCall(browser, AddEventFunc_JS_Name, new JSONNode[1] { new JSONNode(inputID) }); } } } }); }); } } private void InputFieldFollowWebInput(BrowserInputField inputField) { if(inputField && inputField.browser) { WebBrowserFunctionCall(inputField.browser, GetElementRect_JS_Name, new ZenFulcrum.EmbeddedBrowser.JSONNode[1] { new ZenFulcrum.EmbeddedBrowser.JSONNode(inputField.InputElementID) }, (_rect) => { var inputRect = LitJson.JsonMapper.ToObject<Rect>(_rect.AsJSON); InputFieldFollowWebInput(inputField, inputRect); }); } } private void InputFieldFollowWebInput(BrowserInputField inputField, Rect inputRect) { if(inputField && inputField.browser) { var tag = inputField.browser.GetComponent<Modules.BrowserRenderTarget>(); if(tag && tag.renderTarget) { var rect = tag.renderTarget.rectTransform; if(rect) { var uiSize = rect.rect.size; // UGUI size var anchorPos = rect.position + MathTools.Multiple(new Vector2(0, 1) - rect.pivot, uiSize).UpGrade(MathTools.VecAxis.Z); // ugui top left pos var browserSize = inputField.browser.Size; // uiSize equals to how large the browserSize is var x_normalized = (inputRect.x / browserSize.x); // normalized pos_x var y_normalized = (inputRect.y / browserSize.y); // normalized pos_y var x = (x_normalized) * uiSize.x; var y = (y_normalized) * uiSize.y; var inputPos = anchorPos + new Vector3(x, -y, 0); var offset = MathTools.Multiple(inputPos - rect.position, rect.lossyScale); var inputPosUI = rect.position + offset; inputField.transform.position = inputPosUI; } } } } public static void WebBrowserFunctionRegister(ZenFulcrum.EmbeddedBrowser.Browser browser, string function, System.Action<ZenFulcrum.EmbeddedBrowser.JSONNode> succ = null) { if(browser) { var register = browser.EvalJS(function); if(register != null) { register.Done((_value) => { if(succ != null) { succ.Invoke(_value); } }); } } } public static void WebBrowserFunctionCall(ZenFulcrum.EmbeddedBrowser.Browser browser, string functionname, ZenFulcrum.EmbeddedBrowser.JSONNode[] param, System.Action<ZenFulcrum.EmbeddedBrowser.JSONNode> result = null) { if(browser) { var retVal = param != null && param.Length > 0 ? browser.CallFunction(functionname, param) : browser.CallFunction(functionname); if(retVal != null) { retVal.Done((_ret) => { if(result != null) { result.Invoke(_ret); } }); } } } #endregion #region 代碼注入相關 #region JS Codes public const string GetInputs_JS_Name = "GetInputs"; public const string GetInputs_JS = @" function GetInputs() { var inputs, index; var array = new Array(); inputs = document.getElementsByTagName('input'); for (index = 0; index < inputs.length; ++index) { array.push(inputs[index].id); } return array; }"; public const string InjectInputID_JS_Name = "InjectInputID"; public const string InjectInputID_JS = @" var inputID = 1; function InjectInputID() { var inputs, index; inputs = document.getElementsByTagName('input'); for (index = 0; index < inputs.length; ++index) { var rawID = inputs[index].id; if(rawID == null || rawID == ''){ inputs[index].id = 'custom_input_id_' + (inputID++); } } }"; public const string SetInputValue_JS_Name = "SetInputValue"; public const string SetInputValue_JS = @" function SetInputValue(id, str) { var target = document.getElementById(id); if (target != null) { target.value = str; } }"; public const string HasFocusFunc_JS_Name = "GetHasFocus"; public const string HasFocusFunc_JS = @" function GetHasFocus(id) { var target = document.getElementById(id); return (target != null && target.id == document.activeElement.id); }"; public const string CancelFocus_JS_Name = "CancelFocus"; public const string CancelFocus_JS = @" function CancelFocus() { document.activeElement.blur(); }"; public const string AddEventFunc_JS_Name = @"AddEventFunc"; public const string eventFuncNameTemplate_JS = "EVENTFUNC"; public const string eventTemplateName_JS = "EVENTNAME"; public const string AddEventFuncTemplate_JS = @" function AddEventFunc(elementID) { var tagElement = document.getElementById(elementID); if (tagElement != null) { var oldFuncStr = (tagElement.EVENTFUNC + '').replace(/(\n)+|(\r\n)+/g, ''); var rawFunc = oldFuncStr.substring(oldFuncStr.indexOf('{') + 1, oldFuncStr.indexOf('}')); var newFunc = function() { eval(rawFunc); console.log(elementID + ':EVENTNAME'); } if((newFunc + '').replace(/(\n)+|(\r\n)+/g, '') == oldFuncStr){ return; } tagElement.EVENTFUNC = newFunc; } }"; const string GetElementRect_JS_Name = "GetElementRect"; const string GetElementRect_JS = @" function GetElementRect(id) { var tag = document.getElementById(id); var rect = tag.getBoundingClientRect(); var region = {}; region['x'] = rect.left; region['y'] = rect.top; region['width'] = Math.abs(rect.right - rect.left); region['height'] = Math.abs(rect.top - rect.bottom); return region; }"; #endregion public enum ElementEventFunc { onclick, onsubmit, onfocus } public static string GenerateAddEventFunc_JS_Code(ElementEventFunc eventFunc, string customEvent) { return GenerateAddEventFunc_JS_Code(eventFunc.ToString(), customEvent); } public static string GenerateAddEventFunc_JS_Code(string eventFunc, string customEvent) { return AddEventFuncTemplate_JS.Replace(eventFuncNameTemplate_JS, eventFunc).Replace(eventTemplateName_JS, customEvent); } #endregion } }

using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; namespace UIModules { public class BrowserInputFieldPanel : UIBase { [SerializeField] public UIModules.UITools.BrowserInputField browserInputFieldTemplate; } }
----------------------- 最簡單的代碼 --------------------------
上面這么多, 其實只是為了能讓輸入法的選擇框可以大致跟隨輸入位置而做的, 如果對輸入選擇框的位置不敏感, 其實只要在 BrowserInput.cs 這個類中的 HandleKeyInput 方法里去添加強行使用 IME 即可 :
private void HandleKeyInput() { var keyEvents = browser.UIHandler.KeyEvents; if(keyEvents.Count > 0) HandleKeyInput(keyEvents); if(extraEventsToInject.Count > 0) { Input.imeCompositionMode = IMECompositionMode.On; // force IME On HandleKeyInput(extraEventsToInject); extraEventsToInject.Clear(); } }
這又回到制作 IME_InputFollower 之前的問題了 Unity輸入法相關(IME)