Просмотр исходного кода

继续优化 ModernTooltipService

龙腾猫跃 4 месяцев назад
Родитель
Сommit
5979cbd87d
1 измененных файлов с 158 добавлено и 83 удалено
  1. 158 83
      Wpf/Controls/ModernTooltipService.cs

+ 158 - 83
Wpf/Controls/ModernTooltipService.cs

@@ -1,4 +1,4 @@
-// 此文件主体由 AI 生成,应作为独立模块,尽量减少与其他内容的耦合。
+// 此文件主体由 AI 生成,应作为独立模块,尽量减少与其他内容的耦合。
 // 使用 ModernTooltipService 接管后,TooltipService 仅有 IsEnabled、ShowOnDisabled、InitialShowDelay 仍然有效,其他属性不再生效。
 
 using System.Windows;
@@ -22,15 +22,6 @@ public static class ModernTooltipService {
     public static void SetFollowMouse(DependencyObject element, bool value) => element.SetValue(FollowMouseProperty, value);
     public static bool GetFollowMouse(DependencyObject element) => (bool) element.GetValue(FollowMouseProperty);
 
-    // 样式配置
-    private static readonly TimeSpan animationTime = TimeSpan.FromMilliseconds(70);
-    private static readonly int shadowRadius = 18;
-    private static readonly float shadowOpacity = 0.15f;
-    private static readonly Thickness contentMargin = new(12, 11, 12, 8);
-    private static readonly Brush backgroundBrush = new SolidColorBrush(Colors.White);
-    private static readonly Brush borderBrush = new SolidColorBrush(Color.FromRgb(0xD6, 0xD6, 0xD6));
-    private static readonly Brush foregroundBrush = new SolidColorBrush(Color.FromRgb(0x52, 0x52, 0x52));
-
     /// <summary>
     /// 全局启用现代 Tooltip。启用后会接管所有 <see cref="FrameworkElement.ToolTip"/>。
     /// </summary>
@@ -41,19 +32,18 @@ public static class ModernTooltipService {
         EventManager.RegisterClassHandler(typeof(FrameworkElement), UIElement.MouseEnterEvent, new MouseEventHandler(OnMouseEnter), true);
         EventManager.RegisterClassHandler(typeof(FrameworkElement), UIElement.MouseMoveEvent, new MouseEventHandler(OnMouseMove), true);
         EventManager.RegisterClassHandler(typeof(FrameworkElement), UIElement.MouseLeaveEvent, new MouseEventHandler(OnMouseLeave), true);
-
+        EventManager.RegisterClassHandler(typeof(FrameworkElement), UIElement.PreviewMouseUpEvent, new MouseButtonEventHandler(OnPreviewMouseUp), true);
+        EventManager.RegisterClassHandler(typeof(FrameworkElement), ToolTipService.ToolTipOpeningEvent, new ToolTipEventHandler(OnToolTipOpening), true);
         EventManager.RegisterClassHandler(typeof(FrameworkElement), FrameworkElement.UnloadedEvent, new RoutedEventHandler(static (sender, _) => {
             if (sender is FrameworkElement owner && ReferenceEquals(owner, currentOwner)) Close(false);
         }), true);
 
-        EventManager.RegisterClassHandler(typeof(FrameworkElement), ToolTipService.ToolTipOpeningEvent, new ToolTipEventHandler(OnToolTipOpening), true);
-
         // Loaded 负责常规路径;PreviewMouseDown 兜底处理 Init 晚于控件 Loaded 的情况。
         EventManager.RegisterClassHandler(typeof(ComboBox), FrameworkElement.LoadedEvent, new RoutedEventHandler(static (sender, _) => HookComboBoxDropDown(sender as ComboBox)), true);
         EventManager.RegisterClassHandler(typeof(ComboBox), UIElement.PreviewMouseDownEvent, new MouseButtonEventHandler(static (sender, _) => HookComboBoxDropDown(sender as ComboBox)), true);
     }
 
-    #region 附加属性与运行状态
+    #region 字段与属性
 
     // 附加属性
     public static readonly DependencyProperty UseModernTooltipProperty = DependencyProperty.RegisterAttached(
@@ -63,8 +53,16 @@ public static class ModernTooltipService {
     private static readonly DependencyProperty IsComboBoxDropDownHookedProperty = DependencyProperty.RegisterAttached(
         "IsComboBoxDropDownHooked", typeof(bool), typeof(ModernTooltipService), new PropertyMetadata(false));
 
-    private static readonly DispatcherTimer openTimer = new();
+    // 常量
+    private const double closedScale = 0.97;
+    private const int shadowRadius = 18, contentMaxWidth = 676;
+    private static readonly Thickness contentMargin = new(12, 11, 12, 8);
+    private static readonly Brush backgroundBrush = new SolidColorBrush(Colors.White);
+    private static readonly Brush borderBrush = new SolidColorBrush(Color.FromRgb(0xD6, 0xD6, 0xD6));
+    private static readonly Brush foregroundBrush = new SolidColorBrush(Color.FromRgb(0x52, 0x52, 0x52));
 
+    // 状态字段
+    private static readonly DispatcherTimer openTimer = new();
     private static bool initialized;
     private static int animationToken;
     private static Point lastMousePoint;
@@ -76,6 +74,10 @@ public static class ModernTooltipService {
     private static object? borrowedContent;
 
     static ModernTooltipService() {
+        backgroundBrush.Freeze();
+        borderBrush.Freeze();
+        foregroundBrush.Freeze();
+
         openTimer.Tick += (_, _) => {
             openTimer.Stop();
             if (currentOwner is not null) Show(currentOwner, lastMousePoint);
@@ -84,35 +86,38 @@ public static class ModernTooltipService {
 
     #endregion
 
-    #region 全局鼠标事件
+    #region 事件入口
 
     private static void OnMouseEnter(object sender, MouseEventArgs e) {
         if (sender is not FrameworkElement reference) return;
-
         reference.Dispatcher.BeginInvoke(new Action(() => RefreshCurrentOwner(reference)), DispatcherPriority.Input);
     }
 
     private static void OnMouseMove(object sender, MouseEventArgs e) {
         if (sender is not FrameworkElement reference) return;
 
+        if (IsAnyMouseButtonPressed()) {
+            if (currentOwner is FrameworkElement pressedOwner) {
+                lastMousePoint = Mouse.GetPosition(pressedOwner);
+                if (GetFollowMouse(pressedOwner) && popup is { IsOpen: true } && IsPointInside(pressedOwner, lastMousePoint)) {
+                    UpdatePosition(pressedOwner, lastMousePoint);
+                }
+            }
+            return;
+        }
+
         RefreshCurrentOwner(reference);
         if (currentOwner is not FrameworkElement owner) return;
 
         lastMousePoint = Mouse.GetPosition(owner);
-        if (e.LeftButton == MouseButtonState.Pressed && !IsPointInside(owner, lastMousePoint)) {
-            Close(true);
-            return;
-        }
-
         if (GetFollowMouse(owner) && popup is { IsOpen: true }) UpdatePosition(owner, lastMousePoint);
     }
 
     private static void OnMouseLeave(object sender, MouseEventArgs e) {
         if (sender is not FrameworkElement owner || !ReferenceEquals(owner, currentOwner)) return;
 
-        // 避免 Tooltip 在文本区域长按左键时自动收回
-        var point = e.GetPosition(owner);
-        if (e.LeftButton == MouseButtonState.Pressed && IsPointInside(owner, point)) return;
+        // 长按、拖选时鼠标会短暂离开元素边界;此时收回 Tooltip 会造成明显闪烁。
+        if (IsAnyMouseButtonPressed()) return;
 
         var nextOwner = FindCurrentTooltipOwner(owner);
         if (nextOwner is not null && !ReferenceEquals(nextOwner, owner)) {
@@ -123,73 +128,137 @@ public static class ModernTooltipService {
         Close(true);
     }
 
-    private static bool IsPointInside(FrameworkElement owner, Point point) {
-        return point.X >= 0 && point.Y >= 0 && point.X <= owner.ActualWidth && point.Y <= owner.ActualHeight;
-    }
-
-    #endregion
+    private static void OnPreviewMouseUp(object sender, MouseButtonEventArgs e) {
+        if (sender is not FrameworkElement reference) return;
 
-    #region Tooltip 生命周期
+        reference.Dispatcher.BeginInvoke(new Action(() => {
+            if (currentOwner is null) return;
 
-    private static bool CanShowModernTooltip(FrameworkElement owner) {
-        return GetUseModernTooltip(owner) && ToolTipService.GetIsEnabled(owner) && (owner.IsEnabled || ToolTipService.GetShowOnDisabled(owner));
+            var owner = FindCurrentTooltipOwner(reference);
+            if (owner is null) {
+                Close(true);
+            } else {
+                BeginShow(owner, Mouse.GetPosition(owner));
+            }
+        }), DispatcherPriority.Input);
     }
 
     private static void OnToolTipOpening(object sender, ToolTipEventArgs e) {
         if (sender is not FrameworkElement owner || !CanShowModernTooltip(owner) || !TryGetToolTip(owner, out _, out _)) return;
 
         e.Handled = true;
+        if (ShouldSuppressTooltip(owner)) {
+            Close(false);
+            return;
+        }
+
+        // 启用状态控件走 MouseEnter 延迟逻辑;禁用控件只能从原生 Opening 事件接管。
         if (owner.IsEnabled) return;
 
         if (!ReferenceEquals(currentOwner, owner)) HideImmediately();
-
         currentOwner = owner;
         openTimer.Stop();
-
         lastMousePoint = Mouse.GetPosition(owner);
         Show(owner, lastMousePoint);
     }
 
-    private static bool TryGetToolTip(FrameworkElement owner, out object? content, out ToolTip? sourceToolTip) {
-        var raw = owner.ToolTip;
-        sourceToolTip = raw as ToolTip;
-        content = sourceToolTip?.Content ?? raw;
-        return content is not null && (content is not string text || text.Length > 0);
-    }
+    #endregion
+
+    #region Tooltip 目标解析
 
     private static void RefreshCurrentOwner(FrameworkElement reference) {
         var owner = FindCurrentTooltipOwner(reference);
-        if (owner is null) return;
+        if (ShouldSuppressTooltip(owner)) {
+            // 鼠标捕获仍在当前拥有者分支内时,保留现有气泡,避免拖选或按压时闪烁。
+            if (currentOwner is not null &&
+                Mouse.Captured is DependencyObject captured &&
+                IsSameTreeBranch(captured, currentOwner) &&
+                IsPointInside(currentOwner, Mouse.GetPosition(currentOwner))) {
+                return;
+            }
 
-        BeginShow(owner, Mouse.GetPosition(owner));
+            Close(false);
+            return;
+        }
+
+        if (owner is not null) BeginShow(owner, Mouse.GetPosition(owner));
     }
 
     private static FrameworkElement? FindCurrentTooltipOwner(FrameworkElement reference) {
-        var owner = FindTooltipOwnerInAncestors(Mouse.DirectlyOver as DependencyObject);
+        static FrameworkElement? FindOwnerInAncestors(DependencyObject? start) {
+            for (var current = start; current is not null; current = GetParent(current)) {
+                if (current is FrameworkElement owner && CanShowModernTooltip(owner) && TryGetToolTip(owner, out _, out _)) {
+                    return owner;
+                }
+            }
+
+            return null;
+        }
+
+        var owner = FindOwnerInAncestors(Mouse.DirectlyOver as DependencyObject);
         if (owner is not null) return owner;
 
         return reference.InputHitTest(Mouse.GetPosition(reference)) is DependencyObject hit
-            ? FindTooltipOwnerInAncestors(hit)
+            ? FindOwnerInAncestors(hit)
             : null;
     }
 
-    private static FrameworkElement? FindTooltipOwnerInAncestors(DependencyObject? start) {
-        for (var current = start; current is not null;) {
-            if (current is FrameworkElement owner && CanShowModernTooltip(owner) && TryGetToolTip(owner, out _, out _)) {
-                return owner;
-            }
+    private static bool CanShowModernTooltip(FrameworkElement owner) =>
+        GetUseModernTooltip(owner) && ToolTipService.GetIsEnabled(owner) && (owner.IsEnabled || ToolTipService.GetShowOnDisabled(owner));
+
+    private static bool TryGetToolTip(FrameworkElement owner, out object? content, out ToolTip? sourceToolTip) {
+        var raw = owner.ToolTip;
+        sourceToolTip = raw as ToolTip;
+        content = sourceToolTip?.Content ?? raw;
+        return content is not null && (content is not string text || text.Length > 0);
+    }
+
+    private static bool ShouldSuppressTooltip(FrameworkElement? owner) {
+        if (IsAnyMouseButtonPressed() || Mouse.Captured is null) return false;
+        if (Mouse.Captured is not DependencyObject captured) return true;
+        if (owner is null) return true;
+
+        return !IsSameTreeBranch(captured, owner);
+    }
+
+    private static bool IsAnyMouseButtonPressed() =>
+        Mouse.LeftButton == MouseButtonState.Pressed || Mouse.RightButton == MouseButtonState.Pressed ||
+        Mouse.MiddleButton == MouseButtonState.Pressed || Mouse.XButton1 == MouseButtonState.Pressed ||
+        Mouse.XButton2 == MouseButtonState.Pressed;
 
-            DependencyObject? parent = null;
-            if (current is Visual or Visual3D) parent = VisualTreeHelper.GetParent(current);
+    private static bool IsPointInside(FrameworkElement owner, Point point) =>
+        point.X >= 0 && point.Y >= 0 && point.X <= owner.ActualWidth && point.Y <= owner.ActualHeight;
 
-            current = parent ?? LogicalTreeHelper.GetParent(current);
+    private static bool IsSameTreeBranch(DependencyObject first, DependencyObject second) {
+        if (ReferenceEquals(first, second)) return true;
+
+        for (var current = GetParent(second); current is not null; current = GetParent(current)) {
+            if (ReferenceEquals(current, first)) return true;
+        }
+
+        for (var current = GetParent(first); current is not null; current = GetParent(current)) {
+            if (ReferenceEquals(current, second)) return true;
         }
 
-        return null;
+        return false;
     }
 
+    private static DependencyObject? GetParent(DependencyObject current) {
+        if (current is Visual or Visual3D && VisualTreeHelper.GetParent(current) is { } visualParent) return visualParent;
+
+        return LogicalTreeHelper.GetParent(current);
+    }
+
+    #endregion
+
+    #region 显示生命周期
+
     private static void BeginShow(FrameworkElement owner, Point point) {
         if (!CanShowModernTooltip(owner) || !TryGetToolTip(owner, out _, out _)) return;
+        if (ShouldSuppressTooltip(owner)) {
+            Close(false);
+            return;
+        }
 
         if (owner is ComboBox comboBox) HookComboBoxDropDown(comboBox);
         if (ReferenceEquals(currentOwner, owner)) {
@@ -210,29 +279,32 @@ public static class ModernTooltipService {
         var delay = Math.Max(0, ToolTipService.GetInitialShowDelay(owner));
         if (delay == 0) {
             Show(owner, lastMousePoint);
-        } else {
-            openTimer.Interval = TimeSpan.FromMilliseconds(delay);
-            openTimer.Start();
+            return;
         }
+
+        openTimer.Interval = TimeSpan.FromMilliseconds(delay);
+        openTimer.Start();
     }
 
     private static void Show(FrameworkElement owner, Point point) {
         if (!ReferenceEquals(owner, currentOwner) || !CanShowModernTooltip(owner) || !TryGetToolTip(owner, out var content, out var sourceToolTip)) return;
 
         if (popup is null) {
-            popupScale = new ScaleTransform(0.97, 0.97);
-            popupCard = new Border { 
-                Background = backgroundBrush, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), 
-                MaxWidth = 700, SnapsToDevicePixels = true, UseLayoutRounding = true, 
-                RenderTransform = popupScale, RenderTransformOrigin = new Point(0, 0), 
-                Effect = new DropShadowEffect { Opacity = shadowOpacity, BlurRadius = shadowRadius, ShadowDepth = 0, Color = Colors.Black } };
-
-            var root = new Grid { Margin = new Thickness(shadowRadius + 1), SnapsToDevicePixels = true, UseLayoutRounding = true };
+            popupScale = new ScaleTransform(closedScale, closedScale);
+            popupCard = new Border {
+                Background = backgroundBrush, BorderBrush = borderBrush, BorderThickness = new(1),
+                CornerRadius = new(8), MaxWidth = 700, SnapsToDevicePixels = true, UseLayoutRounding = true,
+                RenderTransform = popupScale, RenderTransformOrigin = new(0, 0),
+                Effect = new DropShadowEffect { Opacity = 0.15, BlurRadius = shadowRadius, ShadowDepth = 0, Color = Colors.Black }
+            };
+
+            var root = new Grid { Margin = new(shadowRadius + 1), SnapsToDevicePixels = true, UseLayoutRounding = true };
             root.Children.Add(popupCard);
 
-            popup = new Popup { 
-                AllowsTransparency = true, IsHitTestVisible = false, StaysOpen = true, PopupAnimation = PopupAnimation.None, 
-                Placement = PlacementMode.Relative, Child = root };
+            popup = new Popup {
+                AllowsTransparency = true, IsHitTestVisible = false, StaysOpen = true, PopupAnimation = PopupAnimation.None,
+                Placement = PlacementMode.Relative, Child = root
+            };
         }
 
         popup!.PlacementTarget = owner;
@@ -240,28 +312,31 @@ public static class ModernTooltipService {
         popupCard.FlowDirection = owner.FlowDirection;
         UpdatePosition(owner, point);
 
+        // 先还原上一次借出的 Visual,再挂载新的内容,避免同一元素拥有两个逻辑父级。
         popupCard.Child = null;
         RestoreBorrowedContent();
 
-        // 如果原 ToolTip.Content 是 Visual/FrameworkElement,必须先从原 ToolTip 借出,
-        // 否则同一个元素会同时拥有两个逻辑父级,导致 WPF 抛异常。
         if (sourceToolTip is not null && content is DependencyObject contentObject && ReferenceEquals(LogicalTreeHelper.GetParent(contentObject), sourceToolTip)) {
             borrowedToolTip = sourceToolTip;
             borrowedContent = content;
             sourceToolTip.Content = null;
         }
 
-        popupCard.Child = content is string stringContent && sourceToolTip?.ContentTemplate is null && sourceToolTip?.ContentTemplateSelector is null
-            ? new TextBlock { 
-                Text = stringContent, TextWrapping = TextWrapping.Wrap, Foreground = foregroundBrush, Margin = contentMargin, 
-                FontSize = 12.5, LineHeight = 17, MaxWidth = 676 }
-            : new ContentPresenter { 
+        var hasTemplate = sourceToolTip?.ContentTemplate is not null || sourceToolTip?.ContentTemplateSelector is not null;
+        popupCard.Child = content is string stringContent && !hasTemplate
+            ? new TextBlock {
+                Text = stringContent, TextWrapping = TextWrapping.Wrap, Foreground = foregroundBrush,
+                Margin = contentMargin, FontSize = 12.5, LineHeight = 17, MaxWidth = contentMaxWidth
+            }
+            : new ContentPresenter {
                 Content = content, ContentTemplate = sourceToolTip?.ContentTemplate, ContentTemplateSelector = sourceToolTip?.ContentTemplateSelector,
-                ContentStringFormat = sourceToolTip?.ContentStringFormat, Margin = contentMargin, MaxWidth = 676 };
+                ContentStringFormat = sourceToolTip?.ContentStringFormat, Margin = contentMargin, MaxWidth = contentMaxWidth
+            };
 
         animationToken++;
         popupCard.Opacity = 0;
-        popupScale!.ScaleX = 0.97; popupScale.ScaleY = 0.97;
+        popupScale!.ScaleX = closedScale;
+        popupScale.ScaleY = closedScale;
         popup.IsOpen = true;
         Animate(1, 1, null);
     }
@@ -270,14 +345,13 @@ public static class ModernTooltipService {
         openTimer.Stop();
         currentOwner = null;
 
-        if (popup is null || popupCard is null) return;
-        if (!popup.IsOpen || !animated) {
+        if (popup is not { IsOpen: true } || popupCard is null || !animated) {
             HideImmediately();
             return;
         }
 
         var token = ++animationToken;
-        Animate(0, 0.97, (_, _) => {
+        Animate(0, closedScale, (_, _) => {
             if (token != animationToken) return;
             HideImmediately();
         });
@@ -311,7 +385,7 @@ public static class ModernTooltipService {
 
     #endregion
 
-    #region Popup 创建、定位与动画
+    #region Popup 渲染与动画
 
     private static void UpdatePosition(FrameworkElement owner, Point point) {
         popup!.PlacementTarget = owner;
@@ -320,12 +394,13 @@ public static class ModernTooltipService {
     }
 
     private static void Animate(double opacity, double scale, EventHandler? completed) {
-        var opacityAnimation = new DoubleAnimation(opacity, new Duration(animationTime)) {
+        var duration = new Duration(TimeSpan.FromMilliseconds(70));
+        var opacityAnimation = new DoubleAnimation(opacity, duration) {
             EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
         };
         if (completed is not null) opacityAnimation.Completed += completed;
 
-        var scaleAnimation = new DoubleAnimation(scale, new Duration(animationTime)) {
+        var scaleAnimation = new DoubleAnimation(scale, duration) {
             EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
         };