WPF在自定義文本框中實現輸入法跟隨光標
本文告訴大傢在 WPF 寫一個自定義的文本框,如何實現讓輸入法跟隨光標
本文非小白向,本文適合想開發自定義的文本框,從底層開始開發的文本庫的夥伴。在開始之前,期望瞭解瞭文本庫開發的基礎知識
本文實現的效果如下
實現
本文的方法參考瞭 WPF 官方倉庫的邏輯,可以在WPF倉庫的wpf\src\Microsoft.DotNet.Wpf\src\PresentationFramework\System\Windows\Documents\ImmComposition.cs
文件看到官方是如何讓TextBox控件獲取輸入法焦點,和在輸入光標變更時,修改輸入法的輸入框坐標
先瞭解一下輸入法的相關知識。在 Windows 編程開發裡,輸入法框架有三套,其中用的最多的是第二套。第二套是采用 IMM 進行對接的。所謂 IMM 就是 Input Method Manager 也就是 輸入法管理器
相關的另一個縮寫詞 IME 則是 Input Method Editor 或者是 Input Method Engine 的縮寫,含義是輸入法編輯器或輸入法引擎
應用程序可以通過 IMM 對接輸入法。所用的 win32 的 API 重點是如下幾個
- ImmGetContext 獲取輸入法上下文,用於後續所有的其他函數調用
- ImmAssociateContext 關聯輸入法和對應的窗口,讓輸入法瞭解在哪個窗口輸入
- ImmSetCompositionWindow 用來設置輸入法的窗口的坐標,也是本文最重要的函數
本文接下來將告訴大傢如何一步步實現封裝對 IME 輸入法調用,在本文最後將會給出所有的源代碼
這部分對輸入法的邏輯可以封裝為一個類,這樣上層就可以不關註細節邏輯。如例子代碼,放在 IMESupporter 類型裡
為瞭方便文本框的接入,咱再定義一個接口,用於設置文本框需要實現一些方法,用來提供參數給 IMESupporter 使用才能進行接入
/// <summary> /// 表示控件支持被輸入法 /// </summary> interface IIMETextEditor { /// <summary> /// 獲取當前使用的字體名 /// </summary> /// <returns></returns> string GetFontFamilyName(); /// <summary> /// 獲取字號大小,單位和 WPF 的 FontSize 相同 /// </summary> /// <returns></returns> int GetFontSize(); /// <summary> /// 獲取輸入框的左上角的點,用於設置輸入法的左上角。此點相對於 <see cref="IIMETextEditor"/> 所在元素坐標。對大部分控件來說,都應該是 0,0 點 /// </summary> /// <returns></returns> Point GetTextEditorLeftTop(); /// <summary> /// 獲取光標的輸入左上角的點。此點相對於 <see cref="IIMETextEditor"/> 所在元素坐標 /// </summary> /// <returns></returns> Point GetCaretLeftTop(); }
對於如微軟拼音等輸入法,是支持設置輸入法的文本大小和字體。因此就需要文本框提供 GetFontFamilyName 和 GetFontSize 方法
而 GetCaretLeftTop 自然就是用來讓輸入法跟隨的。為瞭讓文本框可以做更多的定制,也需要 GetTextEditorLeftTop 方法,這個方法的返回值對大部分自定義的文本框控件來說,都應該是 0,0 點
在 IMESupporter 類型構造函數,期望傳入文本框控件,如此可以解決初始化值和監聽的鍋
internal class IMESupporter<T> where T : UIElement, IIMETextEditor { // ReSharper disable InconsistentNaming public IMESupporter(T editor) { Editor = editor; // 忽略代碼 } }
為瞭同時約束傳入的文本框控件繼承 UIElement 和 IIMETextEditor 接口,用瞭泛形
在文本框控件 Editor 獲取焦點的時候,將需要喚起輸入法進行輸入。在 Editor 失去焦點的時候,就應該告訴輸入法當前不進行輸入
public IMESupporter(T editor) { Editor = editor; Editor.GotKeyboardFocus += Editor_GotKeyboardFocus; Editor.LostKeyboardFocus += Editor_LostKeyboardFocus; } private T Editor { get; }
根據 WPF 的約定,對自定義的支持輸入法的控件,需要設置 IsInputMethodSuspendedProperty 附加屬性,如下面代碼
InputMethod.SetIsInputMethodSuspended(editor, true);
在 Editor_GotKeyboardFocus
需要實現的邏輯是調起輸入法和設置初始的輸入框的坐標。如上文,開始之前,需要先拿到輸入法上下文。在拿到輸入法上下文之前,可以先獲取默認的 IME 類窗口句柄。先獲取默認的 IME 類窗口句柄是為瞭在多進程嵌入窗口時,讓微軟拼音輸入法的輸入框跟隨輸入光標而不是在左上角
_defaultImeWnd = IMENative.ImmGetDefaultIMEWnd(IntPtr.Zero);
以上的 _defaultImeWnd
是一個字段,在 IMESupporter 裡定義如下字段和屬性
private T Editor { get; } private IntPtr _defaultImeWnd; private IntPtr _currentContext; private IntPtr _previousContext; private HwndSource? _hwndSource; private bool _isUpdatingCompositionWindow;
這裡有一個細節是 ImmGetDefaultIMEWnd 也許會返回 0x00 空值。什麼時候會返回空值?如打開一個 Win32Dialog 窗口,如 OpenFileDialog 或 SaveFileDialog 等,之後關閉,那麼此時也許 ImmGetDefaultIMEWnd 將會返回空值
拿到空值,需要重新綁定輸入法,告訴輸入法當前的窗口獲取輸入焦點,可以使用如下代碼,通過修改附加屬性的值,通過附加屬性變更調用到 WPF 框架的邏輯,從而修復此問題
if (_defaultImeWnd == IntPtr.Zero) { // 如果拿到瞭空的默認 IME 窗口瞭,那麼此時也許是作為嵌套窗口放入到另一個進程的窗口 // 拿不到就需要刷新一下。否則微軟拼音輸入法將在屏幕的左上角上 RefreshInputMethodEditors(); // 忽略代碼 } /// <summary> /// 刷新 IME 的 ITfThreadMgr 狀態,用於修復打開 Win32Dialog 之後關閉,輸入法無法輸入中文問題 /// </summary> /// 原因是在打開 Win32Dialog 之後,將會讓 ITfThreadMgr 失去焦點。因此需要使用本方法刷新,通過 InputMethod 的 IsInputMethodEnabledProperty 屬性調用到 InputMethod 的 EnableOrDisableInputMethod 方法,在這裡面調用到 TextServicesContext.DispatcherCurrent.SetFocusOnDefaultTextStore 方法,從而調用到 SetFocusOnDim(DefaultTextStore.Current.DocumentManager) 的代碼,將 DefaultTextStore.Current.DocumentManager 設置為 ITfThreadMgr 的焦點,重新綁定 IME 輸入法 /// 但是即使如此,依然拿不到 <see cref="_defaultImeWnd"/> 的初始值。依然需要重新打開和關閉 WPF 窗口才能拿到 /// [Can we public the `DefaultTextStore.Current.DocumentManager` property to create custom TextEditor with IME · Issue #6139 · dotnet/wpf](https://github.com/dotnet/wpf/issues/6139 ) private void RefreshInputMethodEditors() { if (InputMethod.GetIsInputMethodEnabled(Editor)) { InputMethod.SetIsInputMethodEnabled(Editor, false); } if (InputMethod.GetIsInputMethodSuspended(Editor)) { InputMethod.SetIsInputMethodSuspended(Editor, false); } InputMethod.SetIsInputMethodEnabled(Editor, true); InputMethod.SetIsInputMethodSuspended(Editor, true); }
除瞭給 ImmGetDefaultIMEWnd 傳入 IntPtr.Zero 可以獲取之外,還可以傳入當前的 Editor 所在的 HwndSource
進行獲取,這裡的 HwndSource 就相當於或者說大多數時候是等於 Editor 所在的窗口
_hwndSource = (HwndSource) (PresentationSource.FromVisual(Editor) ?? throw new ArgumentNullException(nameof(Editor))); if (_defaultImeWnd == IntPtr.Zero) { // 如果拿到瞭空的默認 IME 窗口瞭,那麼此時也許是作為嵌套窗口放入到另一個進程的窗口 // 拿不到就需要刷新一下。否則微軟拼音輸入法將在屏幕的左上角上 RefreshInputMethodEditors(); // 嘗試通過 _hwndSource 也就是文本所在的窗口去獲取 _defaultImeWnd = IMENative.ImmGetDefaultIMEWnd(_hwndSource.Handle); // 忽略代碼 }
如果繼續獲取不到,那麼可以嘗試使用 GetForegroundWindow 獲取。使用 GetForegroundWindow 獲取到的也許不是正確的,但是能進入此分支,也好過沒有輸入法
_defaultImeWnd = IMENative.ImmGetDefaultIMEWnd(_hwndSource.Handle); if (_defaultImeWnd == IntPtr.Zero) { // 如果依然獲取不到,那麼使用當前激活的窗口,在準備輸入的時候 // 當前的窗口大部分都是對的 // 進入這裡,是盡可能恢復輸入法,拿到的 GetForegroundWindow 雖然預計是不對的 // 也好過沒有輸入法 _defaultImeWnd = IMENative.ImmGetDefaultIMEWnd(Win32.User32.GetForegroundWindow()); }
接下來通過 _defaultImeWnd
獲取輸入法上下文,如下面代碼
// 使用 DefaultIMEWnd 可以比較好解決微軟拼音的輸入法到屏幕左上角的問題 _currentContext = IMENative.ImmGetContext(_defaultImeWnd);
如果從 _defaultImeWnd
拿不到,則使用 _hwndSource.Handle
獲取
_currentContext = IMENative.ImmGetContext(_defaultImeWnd); if (_currentContext == IntPtr.Zero) { _currentContext = IMENative.ImmGetContext(_hwndSource.Handle); }
獲取上下文之後,將輸入法上下文和當前窗口關聯起來。對於隻實現第二套輸入法框架的輸入法,應用程序調用 ImmAssociateContext 關聯,即可調起此輸入法在關聯的窗口輸入
// 對 Win32 使用第二套輸入法框架的輸入法,可以采用 ImmAssociateContext 關聯 // 但是對實現 TSF 第三套輸入法框架的輸入法,在應用程序對接第三套輸入法框架 // 就需要調用 ITfThreadMgr 的 SetFocus 方法。剛好 WPF 對接瞭 _previousContext = IMENative.ImmAssociateContext(_hwndSource.Handle, _currentContext);
輸入法在輸入過程中,將會通過 Windows 消息和當前窗口進行通訊,如獲取輸入框所需的坐標和輸入文本等。因此咱需要加上 Hook 消息,用於告訴輸入法坐標。但不需要處理輸入的文本的邏輯,因為輸入文本的邏輯等在 WPF 已有處理
_previousContext = IMENative.ImmAssociateContext(_hwndSource.Handle, _currentContext); _hwndSource.AddHook(WndProc);
關於 WndProc 的函數邏輯,咱放在後面
在 WPF 框架裡,會對第三套輸入法有進行支持,於是就需要調用 ITfThreadMgr
這個 COM 組件進行關聯焦點,如下面代碼
// 盡管文檔說傳遞null是無效的,但這似乎有助於在與WPF共享的默認輸入上下文中激活IME輸入法 // 這裡需要瞭解的是,在 WPF 的邏輯,是需要傳入 DefaultTextStore.Current.DocumentManager 才符合預期 IMENative.ITfThreadMgr? threadMgr = IMENative.GetTextFrameworkThreadManager(); threadMgr?.SetFocus(IntPtr.Zero);
初始化的過程還需要給輸入法的輸入框一個初始化的坐標,可使用 Win32 的 ImmSetCompositionWindow 進行設置。在進行設置之前,需要獲取到文本框的輸入光標相對於窗口的坐標,用於給輸入法使用
下面代碼從文本框獲取文本框實現接口的獲取光標和輸入框左上角
var textEditorLeftTop = Editor.GetTextEditorLeftTop(); var caretLeftTop = Editor.GetCaretLeftTop();
接下來使用如下代碼將坐標轉換為相對於窗口的
var hIMC = _currentContext; HwndSource source = _hwndSource; var textEditorLeftTop = Editor.GetTextEditorLeftTop(); var caretLeftTop = Editor.GetCaretLeftTop(); var transformToAncestor = Editor.TransformToAncestor(source.RootVisual); var textEditorLeftTopForRootVisual = transformToAncestor.Transform(textEditorLeftTop); var caretLeftTopForRootVisual = transformToAncestor.Transform(caretLeftTop);
對 surface 設備來說,需要進行更多的處理
//解決surface上輸入法光標位置不正確 //現象是surface上光標的位置需要乘以2才能正確,普通電腦上沒有這個問題 //且此問題與DPI無關,目前用CaretWidth可以有效判斷 caretLeftTopForRootVisual = new Point(caretLeftTopForRootVisual.X / SystemParameters.CaretWidth, caretLeftTopForRootVisual.Y / SystemParameters.CaretWidth);
獲取到的坐標傳入到 ImmSetCompositionWindow 方法
//const int CFS_DEFAULT = 0x0000; //const int CFS_RECT = 0x0001; const int CFS_POINT = 0x0002; //const int CFS_FORCE_POSITION = 0x0020; //const int CFS_EXCLUDE = 0x0080; //const int CFS_CANDIDATEPOS = 0x0040; var form = new IMENative.CompositionForm(); form.dwStyle = CFS_POINT; form.ptCurrentPos.x = (int) Math.Max(caretLeftTopForRootVisual.X, textEditorLeftTopForRootVisual.X); form.ptCurrentPos.y = (int) Math.Max(caretLeftTopForRootVisual.Y, textEditorLeftTopForRootVisual.Y); //if (_isSoftwarePinYinOverWin7) //{ // form.ptCurrentPos.y += (int) characterBounds.Height; //} IMENative.ImmSetCompositionWindow(hIMC, ref form);
以上註釋的 _isSoftwarePinYinOverWin7
的邏輯是判斷在系統版本大於 Win7 的系統,如 Win10 系統上,使用微軟拼音輸入法,微軟拼音輸入法在幾個版本,需要修改 Y 坐標,加上輸入的行高才可以。但是在一些 Win10 版本,通過補丁又修瞭這個問題
以上就完成瞭輸入法的初始化邏輯
接下來就是需要處理 Windows 消息瞭,如在收到 WM_INPUTLANGCHANGE
消息時,需要重新獲取輸入法上下文
private IntPtr WndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { switch (msg) { // 忽略代碼 case IMENative.WM_INPUTLANGCHANGE: if (_hwndSource != null) { CreateContext(); } // 忽略代碼 break; } return IntPtr.Zero; }
以上獲取輸入法上下文 CreateContext 方法是獲取 _currentContext
的邏輯
在收到 WM_IME_COMPOSITION
消息,需要更新輸入法的輸入框的坐標
private IntPtr WndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { switch (msg) { // 忽略代碼 case IMENative.WM_IME_COMPOSITION: UpdateCompositionWindow(); break; // 忽略代碼 } return IntPtr.Zero; }
以上的 UpdateCompositionWindow 方法是調用 ImmSetCompositionWindow 方法設置坐標的方法
關於此 IMESupporter 類型的所有代碼,可以從下文獲取
接下來是對接 IMESupporter 和具體的文本框
先在自定義的文本框 TextEditor 控件上繼承 IIMETextEditor 接口。為瞭方便調試,咱先寫測試邏輯,獲取的輸入光標就是上次鼠標點擊的點以及固定的字體字號
public partial class TextEditor : FrameworkElement, IIMETextEditor { // 忽略代碼 protected override void OnRender(DrawingContext drawingContext) { drawingContext.DrawRectangle(Brushes.Black,null,new Rect(MouseDownPoint,new Size(3,30))); base.OnRender(drawingContext); } protected override HitTestResult HitTestCore(PointHitTestParameters hitTestParameters) { // 讓控件接收點擊 return new PointHitTestResult(this, hitTestParameters.HitPoint); } protected override void OnMouseDown(MouseButtonEventArgs e) { MouseDownPoint = e.GetPosition(this); Focus(); InvalidateVisual(); } private Point MouseDownPoint { get; set; } string IIMETextEditor.GetFontFamilyName() { return "微軟雅黑"; } int IIMETextEditor.GetFontSize() { return 30; } Point IIMETextEditor.GetTextEditorLeftTop() { // 相對於當前輸入框的坐標 return new Point(0, 0); } Point IIMETextEditor.GetCaretLeftTop() { return MouseDownPoint; } }
在 OnMouseDown 方法裡面,需要調用 Focus 獲取焦點,同時更新一下模擬的光標。模擬的光標是在 OnRender 方法裡面,使用畫出一個矩形模擬的,沒有做閃爍
為瞭讓控件能接收鍵盤消息,需要設置 FocusableProperty 屬性。為瞭接收 Tab 鍵,而不是被切到其他控件,需要設置 KeyboardNavigation 的 IsTabStopProperty 和 TabNavigationProperty 附加屬性。因為這是作用在所有的自定義文本框 TextEditor 控件上的,因此可以在 TextEditor 的靜態構造函數,進行更改默認值,代碼如下
static TextEditor() { // 用於接收 Tab 按鍵,而不是被切換焦點 KeyboardNavigation.IsTabStopProperty.OverrideMetadata(typeof(TextEditor), new FrameworkPropertyMetadata(true)); KeyboardNavigation.TabNavigationProperty.OverrideMetadata(typeof(TextEditor), new FrameworkPropertyMetadata(KeyboardNavigationMode.None)); // 用於獲取焦點邏輯 FocusableProperty.OverrideMetadata(typeof(TextEditor), new FrameworkPropertyMetadata(true)); }
完成 TextEditor 控件的配置,就可以對接 IMESupporter 類,對接方法是創建即可
public TextEditor() { // 忽略代碼 _imeSupporter = new IMESupporter<TextEditor>(this); } private readonly IMESupporter<TextEditor> _imeSupporter;
這樣就完成瞭文本框讓輸入法跟隨輸入的功能
代碼
本文所有代碼放在github 和 gitee 歡迎訪問
可以通過如下方式獲取本文的源代碼,先創建一個空文件夾,接著使用命令行 cd 命令進入此空文件夾,在命令行裡面輸入以下代碼,即可獲取到本文的代碼
git init git remote add origin https://gitee.com/lindexi/lindexi_gd.git git pull origin b3a1fffece8284d0b84407aa13d949de6a2f1536
以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源
git remote remove origin git remote add origin https://github.com/lindexi/lindexi_gd.git
獲取代碼之後,打開 LightTextEditorPlus.sln 文件
到此這篇關於WPF在自定義文本框中實現輸入法跟隨光標的文章就介紹到這瞭,更多相關WPF輸入法跟隨光標內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- C# wpf 通過HwndHost渲染視頻的實現方法
- C# 顯示、隱藏窗口對應的任務欄
- C#中IntPtr類型的具體使用
- .Net中的Junction Points(交接點)操作
- C#模擬實現鼠標自動點擊與消息發送功能