本文告訴大家在 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 onm ouseDown(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;
}
}
在 onm ouseDown 方法里面,需要呼叫 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 簡單聊聊如何使用 DrawGlyphRun 繪制文本
Can we public the DefaultTextStore.Current.DocumentManager property to create custom TextEditor with IME · Issue #6139 · dotnet/wpf
自己寫了一個輸入法, Windows下的五筆
我的Win32輸入法編程心得
檔案管理器 - Win32 apps Microsoft Docs
分段 - Win32 apps Microsoft Docs
輸入法編輯器 (IME) 要求 - Windows apps Microsoft Docs
CefSharp/WpfIMEKeyboardHandler.cs at bfa8ccf24c7694a80ec42b8f3d6d1683b144ec68 · cefsharp/CefSharp
ITfContextOwnerCompositionSink (msctf.h) - Win32 apps Microsoft Docs
WM_IME_SETCONTEXT message (Winuser.h) - Win32 apps Microsoft Docs
IME Level 3 app equivalent with Text services
ITfThreadMgr::AssociateFocus (msctf.h) - Win32 apps Microsoft Docs
ITfThreadMgr::SetFocus (msctf.h) - Win32 apps Microsoft Docs
ImmSetCompositionStringA function (imm.h) - Win32 apps Microsoft Docs
WM_IME_COMPOSITION message (Winuser.h) - Win32 apps Microsoft Docs
LOGFONTA (wingdi.h) - Win32 apps Microsoft Docs
ImmGetContext function (imm.h) - Win32 apps Microsoft Docs
Input Context - Win32 apps Microsoft Docs
About Input Method Manager - Win32 apps Microsoft Docs
Developing IME-Aware Multiple-thread Applications - Win32 apps Microsoft Docs
c++ - ImmGetContext returns zero always - Stack Overflow
[AHK]輸入法狀態提示,中文狀態提示“中”,英文狀態提示“EN”[轉] - 生命在等待中延續 - 博客園
博客園博客只做備份,博客發布就不再更新,如果想看最新博客,請到 https://blog.lindexi.com/

本作品采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可,歡迎轉載、使用、重新發布,但務必保留文章署名[林德熙](http://blog.csdn.net/lindexi_gd)(包含鏈接:http://blog.csdn.net/lindexi_gd ),不得用于商業目的,基于本文修改后的作品務必以相同的許可發布,如有任何疑問,請與我[聯系](mailto:[email protected]),
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/433257.html
標籤:WPF
