本文告诉大家在 WPF 写一个自定义的文本框,如何实现让输入法跟随光标
本文非小白向,本文适合想开发自定义的文本框,从底层开始开发的文本库的伙伴。在开始之前,期望了解了文本库开发的基础知识
本文实现的效果如下
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.Zewww.easck.comro) { // 如果依然获取不到,那么使用当前激活的窗口,在准备输入的时候 // 当前的窗口大部分都是对的 // 进入这里,是尽可能恢复输入法,拿到的 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 initgit remote add origin https://gitee.com/lindexi/lindexi_gd.gitgit pull origin b3a1fffece8284d0b84407aa13d949de6a2f1536
以上使用的是 gitee 的源,如果 gitee 不能访问,请替换为 github 的源
git remote remove origingit remote add origin https://github.com/lindexi/lindexi_gd.git
获取代码之后,打开 LightTextEditorPlus.sln 文件








