SoftKeyboardHost.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. using System;
  2. using System.Windows;
  3. using System.Windows.Controls;
  4. using System.Windows.Input;
  5. using System.Windows.Media;
  6. namespace ivf_tl_Operate.CustomUserControls
  7. {
  8. /// <summary>
  9. /// M4-04-2 · 内置软键盘弹出宿主 + 绑定附加属性。
  10. /// <para>
  11. /// 用法(XAML,仅声明、不改任何业务/校验逻辑):
  12. /// <code>
  13. /// &lt;PasswordBox cu:SoftKeyboardHost.Mode="Password" ... /&gt;
  14. /// &lt;TextBox cu:SoftKeyboardHost.Mode="Number" ... /&gt;
  15. /// </code>
  16. /// 输入框获焦/触控/点击时弹内置 <see cref="SoftKeyboard"/>,点「确定」「×」或点遮罩收起。
  17. /// 键入实时写回目标控件(TextBox.Text / PasswordBox.Password),其余 PasswordChanged/
  18. /// TextChanged/MouseUp 校验事件照常触发,故 tl13579 校验、参数校验语义完全不变。
  19. /// </para>
  20. /// <para>
  21. /// 弹出层挂在目标所属 Window 的最外层容器上:
  22. /// 若该 Window 暴露名为 "_mask" 的遮罩(MainWindow,与 M2-05 标定弹窗共用)则复用其遮罩背景;
  23. /// 否则(LoginWindow 等独立窗口)注入一层自带半透明遮罩。两种情况都不依赖具体页面。
  24. /// </para>
  25. /// </summary>
  26. public static class SoftKeyboardHost
  27. {
  28. /// <summary>当前已弹出的键盘宿主层(同一时刻至多一个)。</summary>
  29. private static KeyboardOverlay _current;
  30. #region 附加属性 Mode
  31. public static readonly DependencyProperty ModeProperty =
  32. DependencyProperty.RegisterAttached(
  33. "Mode", typeof(SoftKeyboardMode?), typeof(SoftKeyboardHost),
  34. new PropertyMetadata(null, OnModeChanged));
  35. public static void SetMode(DependencyObject element, SoftKeyboardMode? value) => element.SetValue(ModeProperty, value);
  36. public static SoftKeyboardMode? GetMode(DependencyObject element) => (SoftKeyboardMode?)element.GetValue(ModeProperty);
  37. private static void OnModeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  38. {
  39. if (!(d is Control c)) return;
  40. // 先摘除(防重复挂载),再按需挂上。
  41. c.GotKeyboardFocus -= Target_TriggerOpen;
  42. c.PreviewMouseLeftButtonDown -= Target_PreviewMouseDown;
  43. c.TouchDown -= Target_TouchDown;
  44. if (e.NewValue is SoftKeyboardMode)
  45. {
  46. c.GotKeyboardFocus += Target_TriggerOpen;
  47. c.PreviewMouseLeftButtonDown += Target_PreviewMouseDown;
  48. c.TouchDown += Target_TouchDown;
  49. }
  50. }
  51. #endregion
  52. #region 触发弹出
  53. private static void Target_PreviewMouseDown(object sender, MouseButtonEventArgs e)
  54. {
  55. // 已为当前目标弹出则不重复;否则弹出(不抢焦点行为,仅打开键盘)。
  56. if (sender is Control c) Open(c);
  57. }
  58. private static void Target_TouchDown(object sender, TouchEventArgs e)
  59. {
  60. if (sender is Control c) Open(c);
  61. }
  62. private static void Target_TriggerOpen(object sender, KeyboardFocusChangedEventArgs e)
  63. {
  64. if (sender is Control c) Open(c);
  65. }
  66. #endregion
  67. /// <summary>
  68. /// 为目标输入框弹出软键盘。重复目标的弹出请求会被忽略,避免抖动。
  69. /// </summary>
  70. public static void Open(Control target)
  71. {
  72. var mode = GetMode(target);
  73. if (mode == null) return;
  74. // 同一目标已开则忽略。
  75. if (_current != null && ReferenceEquals(_current.Target, target)) return;
  76. Close(false);
  77. var window = Window.GetWindow(target);
  78. if (window == null) return;
  79. var overlay = new KeyboardOverlay(window, target, mode.Value);
  80. overlay.Closed += () =>
  81. {
  82. if (ReferenceEquals(_current, overlay)) _current = null;
  83. };
  84. _current = overlay;
  85. overlay.Show();
  86. }
  87. /// <summary>收起当前键盘(若有)。</summary>
  88. public static void Close(bool submit)
  89. {
  90. _current?.Dispose(submit);
  91. _current = null;
  92. }
  93. }
  94. /// <summary>
  95. /// 单次键盘弹出的宿主层:遮罩 + 浮层 + SoftKeyboard,挂在目标 Window 的根 Panel 上。
  96. /// </summary>
  97. internal sealed class KeyboardOverlay
  98. {
  99. public Control Target { get; }
  100. public event Action Closed;
  101. private readonly Window _window;
  102. private readonly SoftKeyboardMode _mode;
  103. private readonly Panel _rootPanel; // 注入遮罩/浮层的容器(窗口根 Panel)
  104. private readonly UIElement _existingMask; // 复用的 _mask(MainWindow),可空
  105. private FrameworkElement _injectedMask; // 自注入遮罩(无 _mask 时)
  106. private Grid _layer; // 承载键盘的浮层
  107. private System.Windows.Controls.Primitives.Popup _popup; // G2-3:键盘改用顶层 Popup 托管(独立布局根,避免 Viewbox 内 MainGrid 不重测子树的坑)
  108. private SoftKeyboard _keyboard;
  109. private bool _disposed;
  110. public KeyboardOverlay(Window window, Control target, SoftKeyboardMode mode)
  111. {
  112. _window = window;
  113. Target = target;
  114. _mode = mode;
  115. _rootPanel = FindRootPanel(window);
  116. _existingMask = FindMask(window);
  117. }
  118. public void Show()
  119. {
  120. bool maskMode = _mode == SoftKeyboardMode.Password;
  121. // 窗口尺寸(Popup 顶层、按屏幕像素布局,不随 Viewbox 缩放)。
  122. double winW = _window != null && _window.ActualWidth > 0 ? _window.ActualWidth : 1920;
  123. double winH = _window != null && _window.ActualHeight > 0 ? _window.ActualHeight : 1080;
  124. double kbW = _mode == SoftKeyboardMode.Password ? System.Math.Min(960, winW * 0.62) : System.Math.Min(640, winW * 0.42);
  125. // 高度:Password 全键盘=标题栏+5 行,Number=标题栏+4 行;上限给足,避免竖屏大屏下被压矮、底部行(清除/空格/确定)显示不全。
  126. double kbH = _mode == SoftKeyboardMode.Password ? System.Math.Min(860, winH * 0.62) : System.Math.Min(680, winH * 0.55);
  127. _keyboard = new SoftKeyboard
  128. {
  129. HorizontalAlignment = HorizontalAlignment.Center,
  130. VerticalAlignment = VerticalAlignment.Center, // 居中:贴底会让最底行(确定)被屏幕底部裁掉,居中绝不被裁
  131. Margin = new Thickness(0),
  132. Width = kbW,
  133. Height = kbH
  134. };
  135. _keyboard.Reset(_mode, ReadTargetText(), maskMode);
  136. _keyboard.KeyInput += OnKeyInput;
  137. _keyboard.RequestClose += OnRequestClose;
  138. // 浮层:全窗口透明背景捕获点击(点空白处=取消)+ 键盘(底部居中)。
  139. _layer = new Grid { Width = winW, Height = winH };
  140. var bgCatcher = new Border { Background = Brushes.Transparent };
  141. bgCatcher.MouseDown += (s, e) => OnRequestClose(false);
  142. bgCatcher.TouchDown += (s, e) => OnRequestClose(false);
  143. _layer.Children.Add(bgCatcher);
  144. _layer.Children.Add(_keyboard);
  145. // 背景压暗:优先复用窗口现成 _mask(与 M2-05 标定弹窗一致);否则在根面板注入一层。
  146. if (_existingMask is UIElement mk)
  147. {
  148. mk.Visibility = Visibility.Visible;
  149. }
  150. else if (_rootPanel != null)
  151. {
  152. _injectedMask = new Grid { Background = new SolidColorBrush(Color.FromArgb(204, 0, 0, 0)) };
  153. _rootPanel.Children.Add(_injectedMask);
  154. Panel.SetZIndex(_injectedMask, int.MaxValue - 1);
  155. }
  156. // 键盘放进顶层 Popup —— 独立布局根,必被测量/渲染;规避"MainGrid 固定尺寸且在 Viewbox 内、
  157. // 动态新增子树布局失效不被父级重新测量"的坑(实测会导致键盘 0×0 不可见)。
  158. _popup = new System.Windows.Controls.Primitives.Popup
  159. {
  160. PlacementTarget = _window,
  161. Placement = System.Windows.Controls.Primitives.PlacementMode.Relative,
  162. HorizontalOffset = 0,
  163. VerticalOffset = 0,
  164. Width = winW,
  165. Height = winH,
  166. AllowsTransparency = true,
  167. StaysOpen = true,
  168. Child = _layer
  169. };
  170. _popup.IsOpen = true;
  171. }
  172. private void OnKeyInput(string text) => WriteTargetText(text);
  173. private void OnRequestClose(bool submit) => Dispose(submit);
  174. public void Dispose(bool submit)
  175. {
  176. if (_disposed) return;
  177. _disposed = true;
  178. if (_keyboard != null)
  179. {
  180. _keyboard.KeyInput -= OnKeyInput;
  181. _keyboard.RequestClose -= OnRequestClose;
  182. }
  183. if (_popup != null)
  184. {
  185. _popup.IsOpen = false;
  186. _popup.Child = null;
  187. _popup = null;
  188. }
  189. if (_injectedMask != null) _rootPanel?.Children.Remove(_injectedMask);
  190. // 复用的 _mask:仅当我们点亮过才隐藏(弹窗自身可能也用它——这里保守隐藏,
  191. // 因为同一时刻不会有标定弹窗与键盘并存:键盘弹出时页面处于输入态)。
  192. if (_existingMask is UIElement mk) mk.Visibility = Visibility.Hidden;
  193. Closed?.Invoke();
  194. }
  195. #region 目标读写(兼容 TextBox / PasswordBox)
  196. private string ReadTargetText()
  197. {
  198. switch (Target)
  199. {
  200. case PasswordBox pb: return pb.Password;
  201. case TextBox tb: return tb.Text;
  202. default: return string.Empty;
  203. }
  204. }
  205. private void WriteTargetText(string text)
  206. {
  207. switch (Target)
  208. {
  209. case PasswordBox pb:
  210. pb.Password = text; // 触发 PasswordChanged(背景/校验钩子照常)
  211. break;
  212. case TextBox tb:
  213. tb.Text = text; // 触发 TextChanged
  214. tb.CaretIndex = tb.Text.Length;
  215. break;
  216. }
  217. }
  218. #endregion
  219. #region 容器查找
  220. private static Panel FindRootPanel(Window w)
  221. {
  222. // 窗口内容若已是 Panel 直接用;若是 Viewbox/Border 等装饰则向下找首个 Panel;
  223. // 找不到则不弹(极端情况,保守)。
  224. DependencyObject node = w.Content as DependencyObject;
  225. return FindFirstPanel(node);
  226. }
  227. private static Panel FindFirstPanel(DependencyObject node)
  228. {
  229. if (node == null) return null;
  230. if (node is Panel p) return p;
  231. int count = VisualTreeHelperChildCountSafe(node);
  232. for (int i = 0; i < count; i++)
  233. {
  234. var child = System.Windows.Media.VisualTreeHelper.GetChild(node, i);
  235. var found = FindFirstPanel(child);
  236. if (found != null) return found;
  237. }
  238. // 逻辑树兜底(Visual 树未生成时)
  239. if (node is ContentControl cc && cc.Content is DependencyObject ccChild)
  240. return FindFirstPanel(ccChild);
  241. if (node is Decorator dec && dec.Child is DependencyObject decChild)
  242. return FindFirstPanel(decChild);
  243. return null;
  244. }
  245. private static int VisualTreeHelperChildCountSafe(DependencyObject node)
  246. {
  247. try { return System.Windows.Media.VisualTreeHelper.GetChildrenCount(node); }
  248. catch { return 0; }
  249. }
  250. /// <summary>查找窗口中名为 "_mask" 的遮罩元素(MainWindow 暴露,与标定弹窗共用)。</summary>
  251. private static UIElement FindMask(Window w)
  252. {
  253. return w.FindName("_mask") as UIElement;
  254. }
  255. #endregion
  256. }
  257. }