ModernTooltipService.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. // 此文件主体由 AI 生成,应作为独立模块,尽量减少与其他内容的耦合。
  2. using System.Windows;
  3. using System.Windows.Controls;
  4. using System.Windows.Controls.Primitives;
  5. using System.Windows.Input;
  6. using System.Windows.Media;
  7. using System.Windows.Media.Animation;
  8. using System.Windows.Media.Effects;
  9. using System.Windows.Threading;
  10. namespace MeloongCore.Wpf;
  11. public static class ModernTooltipService {
  12. // 控制是否在控件上启用现代化 Tooltip;默认为 true。
  13. public static void SetIsEnabled(DependencyObject element, bool value) => element.SetValue(IsEnabledProperty, value);
  14. public static bool GetIsEnabled(DependencyObject element) => (bool) element.GetValue(IsEnabledProperty);
  15. // 控制 Tooltip 是否跟随鼠标移动;默认为 true。
  16. public static void SetFollowMouse(DependencyObject element, bool value) => element.SetValue(FollowMouseProperty, value);
  17. public static bool GetFollowMouse(DependencyObject element) => (bool) element.GetValue(FollowMouseProperty);
  18. // 样式配置
  19. private static readonly TimeSpan animationTime = TimeSpan.FromMilliseconds(70);
  20. private static readonly int shadowRadius = 18;
  21. private static readonly float shadowOpacity = 0.15f;
  22. private static readonly Thickness contentMargin = new(12, 11, 12, 8.5);
  23. private static readonly Brush backgroundBrush = new SolidColorBrush(Colors.White);
  24. private static readonly Brush borderBrush = new SolidColorBrush(Color.FromRgb(0xD6, 0xD6, 0xD6));
  25. private static readonly Brush foregroundBrush = new SolidColorBrush(Color.FromRgb(0x52, 0x52, 0x52));
  26. /// <summary>
  27. /// 全局启用现代 Tooltip。启用后会接管所有 <see cref="FrameworkElement.ToolTip"/>。
  28. /// </summary>
  29. internal static void Init() {
  30. if (initialized) return;
  31. initialized = true;
  32. EventManager.RegisterClassHandler(typeof(FrameworkElement), UIElement.MouseEnterEvent, new MouseEventHandler(OnMouseEnter), true);
  33. EventManager.RegisterClassHandler(typeof(FrameworkElement), UIElement.MouseMoveEvent, new MouseEventHandler(OnMouseMove), true);
  34. EventManager.RegisterClassHandler(typeof(FrameworkElement), UIElement.MouseLeaveEvent, new MouseEventHandler(OnMouseLeave), true);
  35. EventManager.RegisterClassHandler(typeof(FrameworkElement), FrameworkElement.UnloadedEvent, new RoutedEventHandler(static (sender, _) => {
  36. if (sender is FrameworkElement owner && ReferenceEquals(owner, currentOwner)) Close(false);
  37. }), true);
  38. EventManager.RegisterClassHandler(typeof(FrameworkElement), ToolTipService.ToolTipOpeningEvent, new ToolTipEventHandler(static (sender, e) => {
  39. if (sender is FrameworkElement owner && GetIsEnabled(owner) && TryGetToolTip(owner, out _, out _)) e.Handled = true;
  40. }), true);
  41. // Loaded 负责常规路径;PreviewMouseDown 兜底处理 Init 晚于控件 Loaded 的情况。
  42. EventManager.RegisterClassHandler(typeof(ComboBox), FrameworkElement.LoadedEvent, new RoutedEventHandler(static (sender, _) => HookComboBoxDropDown(sender as ComboBox)), true);
  43. EventManager.RegisterClassHandler(typeof(ComboBox), UIElement.PreviewMouseDownEvent, new MouseButtonEventHandler(static (sender, _) => HookComboBoxDropDown(sender as ComboBox)), true);
  44. }
  45. #region 附加属性与运行状态
  46. // 附加属性
  47. public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
  48. "IsEnabled", typeof(bool), typeof(ModernTooltipService), new PropertyMetadata(true));
  49. public static readonly DependencyProperty FollowMouseProperty = DependencyProperty.RegisterAttached(
  50. "FollowMouse", typeof(bool), typeof(ModernTooltipService), new PropertyMetadata(true));
  51. private static readonly DependencyProperty IsComboBoxDropDownHookedProperty = DependencyProperty.RegisterAttached(
  52. "IsComboBoxDropDownHooked", typeof(bool), typeof(ModernTooltipService), new PropertyMetadata(false));
  53. private static readonly DispatcherTimer openTimer = new();
  54. private static readonly DispatcherTimer leaveWatchTimer = new() { Interval = TimeSpan.FromMilliseconds(50) };
  55. private static bool initialized;
  56. private static int animationToken;
  57. private static Point lastMousePoint;
  58. private static Popup? popup;
  59. private static Border? popupCard;
  60. private static ScaleTransform? popupScale;
  61. private static FrameworkElement? currentOwner;
  62. private static ToolTip? borrowedToolTip;
  63. private static object? borrowedContent;
  64. static ModernTooltipService() {
  65. openTimer.Tick += (_, _) => {
  66. openTimer.Stop();
  67. if (currentOwner is not null) Show(currentOwner, lastMousePoint);
  68. };
  69. leaveWatchTimer.Tick += (_, _) => {
  70. if (currentOwner is null) {
  71. leaveWatchTimer.Stop();
  72. return;
  73. }
  74. if (TryGetCursorPoint(currentOwner, out var point)) {
  75. lastMousePoint = point;
  76. if (GetFollowMouse(currentOwner) && popup is { IsOpen: true }) UpdatePosition(currentOwner, point);
  77. if (currentOwner.IsMouseOver) leaveWatchTimer.Stop();
  78. return;
  79. }
  80. Close(true);
  81. };
  82. }
  83. #endregion
  84. #region 全局鼠标事件
  85. private static void OnMouseEnter(object sender, MouseEventArgs e) {
  86. if (sender is not FrameworkElement owner || !GetIsEnabled(owner) || !TryGetToolTip(owner, out _, out _)) return;
  87. if (ReferenceEquals(currentOwner, owner) && popup is { IsOpen: true }) {
  88. leaveWatchTimer.Stop();
  89. return;
  90. }
  91. if (owner is ComboBox comboBox) HookComboBoxDropDown(comboBox);
  92. if (!ReferenceEquals(currentOwner, owner)) HideImmediately();
  93. currentOwner = owner;
  94. lastMousePoint = e.GetPosition(owner);
  95. openTimer.Stop();
  96. leaveWatchTimer.Stop();
  97. var delay = Math.Max(0, ToolTipService.GetInitialShowDelay(owner));
  98. if (delay == 0) {
  99. Show(owner, lastMousePoint);
  100. } else {
  101. openTimer.Interval = TimeSpan.FromMilliseconds(delay);
  102. openTimer.Start();
  103. }
  104. }
  105. private static void OnMouseMove(object sender, MouseEventArgs e) {
  106. if (sender is not FrameworkElement owner || !ReferenceEquals(owner, currentOwner)) return;
  107. lastMousePoint = e.GetPosition(owner);
  108. if (GetFollowMouse(owner) && popup is { IsOpen: true }) UpdatePosition(owner, lastMousePoint);
  109. }
  110. private static void OnMouseLeave(object sender, MouseEventArgs e) {
  111. if (sender is not FrameworkElement owner || !ReferenceEquals(owner, currentOwner)) return;
  112. // TextBox 点击时,内部元素可能短暂捕获鼠标并触发一次假的 MouseLeave。
  113. // 若确认鼠标还在控件内,则短暂轮询;避免 WPF 不再补发真正 MouseLeave 时 Tooltip 残留。
  114. owner.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => {
  115. if (!ReferenceEquals(owner, currentOwner)) return;
  116. if (TryGetCursorPoint(owner, out _)) {
  117. leaveWatchTimer.Start();
  118. return;
  119. }
  120. Close(true);
  121. }));
  122. }
  123. // 确认鼠标位置
  124. private static bool TryGetCursorPoint(FrameworkElement owner, out Point point) {
  125. point = default;
  126. if (!owner.IsLoaded || !owner.IsVisible || !GetCursorPos(out var cursor)) return false;
  127. point = owner.PointFromScreen(new Point(cursor.X, cursor.Y));
  128. return point.X >= 0 && point.Y >= 0 && point.X <= owner.ActualWidth && point.Y <= owner.ActualHeight;
  129. }
  130. [DllImport("user32.dll")] private static extern bool GetCursorPos(out CursorPoint point);
  131. [StructLayout(LayoutKind.Sequential)]
  132. private struct CursorPoint {
  133. public int X;
  134. public int Y;
  135. }
  136. #endregion
  137. #region Tooltip 生命周期
  138. private static bool TryGetToolTip(FrameworkElement owner, out object? content, out ToolTip? sourceToolTip) {
  139. var raw = owner.ToolTip;
  140. sourceToolTip = raw as ToolTip;
  141. content = sourceToolTip?.Content ?? raw;
  142. return content is not null && (content is not string text || text.Length > 0);
  143. }
  144. private static void Show(FrameworkElement owner, Point point) {
  145. if (!ReferenceEquals(owner, currentOwner) || !TryGetToolTip(owner, out var content, out var sourceToolTip)) return;
  146. EnsurePopup();
  147. popup!.PlacementTarget = owner;
  148. popupCard!.DataContext = sourceToolTip?.DataContext ?? owner.DataContext;
  149. popupCard.FlowDirection = owner.FlowDirection;
  150. UpdatePosition(owner, point);
  151. popupCard.Child = null;
  152. RestoreBorrowedContent();
  153. // 如果原 ToolTip.Content 是 Visual/FrameworkElement,必须先从原 ToolTip 借出,
  154. // 否则同一个元素会同时拥有两个逻辑父级,导致 WPF 抛异常。
  155. if (sourceToolTip is not null && content is DependencyObject contentObject && ReferenceEquals(LogicalTreeHelper.GetParent(contentObject), sourceToolTip)) {
  156. borrowedToolTip = sourceToolTip;
  157. borrowedContent = content;
  158. sourceToolTip.Content = null;
  159. }
  160. popupCard.Child = content is string stringContent && sourceToolTip?.ContentTemplate is null && sourceToolTip?.ContentTemplateSelector is null
  161. ? new TextBlock {
  162. Text = stringContent, TextWrapping = TextWrapping.Wrap, Foreground = foregroundBrush, Margin = contentMargin,
  163. FontSize = 12.5, LineHeight = 17, MaxWidth = 676 }
  164. : new ContentPresenter {
  165. Content = content, ContentTemplate = sourceToolTip?.ContentTemplate, ContentTemplateSelector = sourceToolTip?.ContentTemplateSelector,
  166. ContentStringFormat = sourceToolTip?.ContentStringFormat, Margin = contentMargin, MaxWidth = 676 };
  167. animationToken++;
  168. popupCard.Opacity = 0;
  169. popupScale!.ScaleX = 0.97; popupScale.ScaleY = 0.97;
  170. popup.IsOpen = true;
  171. Animate(1, 1, null);
  172. }
  173. private static void Close(bool animated) {
  174. openTimer.Stop();
  175. leaveWatchTimer.Stop();
  176. currentOwner = null;
  177. if (popup is null || popupCard is null) return;
  178. if (!popup.IsOpen || !animated) {
  179. HideImmediately();
  180. return;
  181. }
  182. var token = ++animationToken;
  183. Animate(0, 0.97, (_, _) => {
  184. if (token != animationToken) return;
  185. HideImmediately();
  186. });
  187. }
  188. private static void HideImmediately() {
  189. openTimer.Stop();
  190. leaveWatchTimer.Stop();
  191. animationToken++;
  192. if (popup is not null) popup.IsOpen = false;
  193. if (popupCard is not null) {
  194. popupCard.BeginAnimation(UIElement.OpacityProperty, null);
  195. popupCard.Child = null;
  196. }
  197. if (popupScale is not null) {
  198. popupScale.BeginAnimation(ScaleTransform.ScaleXProperty, null);
  199. popupScale.BeginAnimation(ScaleTransform.ScaleYProperty, null);
  200. }
  201. RestoreBorrowedContent();
  202. }
  203. private static void RestoreBorrowedContent() {
  204. if (borrowedToolTip is null) return;
  205. borrowedToolTip.Content = borrowedContent;
  206. borrowedToolTip = null;
  207. borrowedContent = null;
  208. }
  209. #endregion
  210. #region Popup 创建、定位与动画
  211. private static void EnsurePopup() {
  212. if (popup is not null) return;
  213. popupScale = new ScaleTransform(0.97, 0.97);
  214. popupCard = new Border {
  215. Background = backgroundBrush, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8),
  216. MaxWidth = 700, SnapsToDevicePixels = true, UseLayoutRounding = true,
  217. RenderTransform = popupScale, RenderTransformOrigin = new Point(0, 0),
  218. Effect = new DropShadowEffect { Opacity = shadowOpacity, BlurRadius = shadowRadius, ShadowDepth = 0, Color = Colors.Black } };
  219. var root = new Grid { Margin = new Thickness(shadowRadius + 1), SnapsToDevicePixels = true, UseLayoutRounding = true };
  220. root.Children.Add(popupCard);
  221. popup = new Popup {
  222. AllowsTransparency = true, IsHitTestVisible = false, StaysOpen = true, PopupAnimation = PopupAnimation.None,
  223. Placement = PlacementMode.Relative, Child = root };
  224. }
  225. private static void UpdatePosition(FrameworkElement owner, Point point) {
  226. popup!.PlacementTarget = owner;
  227. popup.HorizontalOffset = Math.Round(point.X + 15);
  228. popup.VerticalOffset = Math.Round(point.Y + 25);
  229. }
  230. private static void Animate(double opacity, double scale, EventHandler? completed) {
  231. var opacityAnimation = new DoubleAnimation(opacity, new Duration(animationTime)) {
  232. EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
  233. };
  234. if (completed is not null) opacityAnimation.Completed += completed;
  235. var scaleAnimation = new DoubleAnimation(scale, new Duration(animationTime)) {
  236. EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
  237. };
  238. popupCard!.BeginAnimation(UIElement.OpacityProperty, opacityAnimation, HandoffBehavior.SnapshotAndReplace);
  239. popupScale!.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace);
  240. popupScale.BeginAnimation(ScaleTransform.ScaleYProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace);
  241. }
  242. #endregion
  243. // WPF 原生 Tooltip 会在 ComboBox 下拉弹出时关闭;这里保持同样行为。
  244. private static void HookComboBoxDropDown(ComboBox? comboBox) {
  245. if (comboBox is null || (bool) comboBox.GetValue(IsComboBoxDropDownHookedProperty)) return;
  246. comboBox.SetValue(IsComboBoxDropDownHookedProperty, true);
  247. comboBox.DropDownOpened += static (_, _) => { if (currentOwner is not null) Close(true); };
  248. }
  249. }