How to get rid of the ToolTip when mouse gets out of the view?

Emon Haque 3,176 Reputation points
2021-06-03T03:05:13.623+00:00

So far everything is good other than the ToolTip of the LineChart:

101921-test.gif

so when I get out the LineChart view, in the middle, that ToolTip remains open most of the time! It's, probably, because of the collision detection logic in onPointerIsOver method of Circle class. To reproduce the problem add Pointer, Circle, LineTip, LineStream and LineChart classes in your project. Here is Pointer:

class Pointer : UIElement  
{  
    Pen pen;  
    Point start, end;  
    public static event Action<double> Moved;  
    public Pointer() {  
        pen = new Pen(Brushes.Blue, 1) { DashStyle = DashStyles.DashDotDot, DashCap = PenLineCap.Round };  
        start = new Point();  
        end = new Point();  
        IsHitTestVisible = false;  
    }  
    public void SetPointer(double x1, double y2, double labelWidth) {  
        start.X = end.X = x1;  
        start.Y = labelWidth;  
        end.Y = y2;  
        InvalidateVisual();  
        Moved(x1);  
    }  
    protected override void OnRender(DrawingContext drawingContext) => drawingContext.DrawLine(pen, start, end);  
}  

here is the Circle:

class Circle : FrameworkElement  
{  
    public int value;  
    int radius;  
    Path path;  
    EllipseGeometry circle;  
    RadialGradientBrush radialBrush;  
    GradientStop firstStop;  
    DoubleAnimation gradStopAnim;  
    DoubleAnimation scaleAnim;  
    double leftBound, rightBound;  
    LineTip tip;  
      
    public Circle(int value) {  
        this.value = value;  
        tip = new LineTip(value);  
        radius = 8;  
        firstStop = new GradientStop(Colors.CornflowerBlue, 0);  
        radialBrush = new RadialGradientBrush() {  
            GradientOrigin = new Point(0.5, 0.5),  
            GradientStops = {  
                firstStop,  
                new GradientStop(Colors.Coral, 1)  
            }  
        };  
        circle = new EllipseGeometry() { RadiusX = radius, RadiusY = radius };  
        path = new Path() {  
            Fill = radialBrush,  
            Data = circle  
        };  
        gradStopAnim = new DoubleAnimation() {  
            BeginTime = TimeSpan.FromSeconds(0.5),  
            Duration = TimeSpan.FromSeconds(1),  
            EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseInOut }  
        };  
        scaleAnim = new DoubleAnimation() {  
            Duration = TimeSpan.FromSeconds(1),  
            EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseInOut }  
        };  
        RenderTransform = new ScaleTransform();  
        Loaded += appear;  
        Pointer.Moved += onPointerIsOver;  
        Unloaded += unsubscribe;  
    }  

    void appear(object sender, RoutedEventArgs e) {  
        var anim = new DoubleAnimation() {  
            BeginTime = TimeSpan.FromSeconds(2),  
            Duration = TimeSpan.FromSeconds(1),  
            To = 0.5,  
            EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseInOut }  
        };  
        RenderTransform.BeginAnimation(ScaleTransform.ScaleXProperty, anim);  
        RenderTransform.BeginAnimation(ScaleTransform.ScaleYProperty, anim);  
    }       
    void onPointerIsOver(double x) {  
        if (x >= leftBound && x <= rightBound) {  
            tip.IsOpen = true;  
            if (gradStopAnim.To != 1) {  
                gradStopAnim.To = scaleAnim.To = 1;  
                animate();  
            }  
        }  
        else {  
            tip.IsOpen = false;  
            if (gradStopAnim.To == 1) {  
                gradStopAnim.To = 0.25;  
                scaleAnim.To = 0.5;  
                animate();  
            }  
        }  
    }  
    void unsubscribe(object sender, RoutedEventArgs e) {  
        Loaded -= appear;  
        Pointer.Moved -= onPointerIsOver;  
        Unloaded -= unsubscribe;  
    }  
    void animate() {  
        firstStop.BeginAnimation(GradientStop.OffsetProperty, gradStopAnim);  
        RenderTransform.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnim);  
        RenderTransform.BeginAnimation(ScaleTransform.ScaleYProperty, scaleAnim);  
    }  
    public void SetCenter(Point center) {  
        circle.Center = center;  
        leftBound = center.X - radius;  
        rightBound = center.X + radius;  
        var transform = RenderTransform as ScaleTransform;  
        transform.ScaleX = transform.ScaleY = 0;  
        transform.CenterX = center.X;  
        transform.CenterY = center.Y;  
        InvalidateVisual();  
    }         
    protected override void OnRender(DrawingContext dc) => dc.DrawGeometry(radialBrush, null, path.Data);  
}  

In the Circle I've an instance of LineTip, here it is:

class LineTip : ToolTip  
{  
    public LineTip(int value) {  
        BorderBrush = null;  
        Effect = null;  
        Background = null;  
        HasDropShadow = false;  
        var header = new TextBlock() {  
            FontSize = 16,  
            Foreground = Brushes.Gray,  
            Text = "Header"  
        };  
        var divider = new Separator() { Background = Brushes.LightGray };  
        var info = new Grid() {  
            Children = {  
                new TextBlock(){Text = "Value is: "},  
                new TextBlock(){  
                    Text = value.ToString("N0"),  
                    HorizontalAlignment = HorizontalAlignment.Right  
                }  
            }  
        };  
        Grid.SetRow(divider, 1);  
        Grid.SetRow(info, 2);  
        Content = new Border() {  
            MinWidth = 175,  
            Background = Brushes.White,  
            Effect = new DropShadowEffect() { BlurRadius = 5, ShadowDepth = 0 },  
            Padding = new Thickness(5),  
            CornerRadius = new CornerRadius(5),  
            Child = new Grid() {  
                RowDefinitions = {  
                    new RowDefinition(){ Height = GridLength.Auto },  
                    new RowDefinition(){ Height = GridLength.Auto },  
                    new RowDefinition()  
                },  
                Children = { header, divider, info }  
            }  
        };  
    }  
}  

Line and Polygon are generated by this LineStream:

public class LineStream : FrameworkElement  
{  
    List<int> values;  
    double spacing, height, lower, upper;  
    Pen pen;  
    StreamGeometry geometry, polygon;  
    Path polyPath;  
    LinearGradientBrush fillBrush;  
    DoubleAnimation gradientAnim;  

    public LineStream(List<int> values) {  
        this.values = values;  
        geometry = new StreamGeometry();  
        polygon = new StreamGeometry();  
        fillBrush = new LinearGradientBrush() {  
            StartPoint = new Point(0.5, 0),  
            EndPoint = new Point(0.5, 1),  
            GradientStops = {  
                new GradientStop(){Offset = 0, Color = Color.FromArgb(75, 0, 0, 100)},  
                new GradientStop() { Offset = 0, Color = Colors.Transparent }  
            }  
        };  
        polyPath = new Path() { Fill = fillBrush, Data = polygon };  
        gradientAnim = new DoubleAnimation(1, TimeSpan.FromSeconds(2)) {  
            EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseIn },  
            BeginTime = TimeSpan.FromSeconds(1)  
        };  
        pen = new Pen(Brushes.CornflowerBlue, 2);  
        Loaded += animateLine;  
    }  

    void animateLine(object sender, RoutedEventArgs e) {  
        var anim = new RectAnimation() {  
            From = new Rect(0, 0, 0, height),  
            Duration = TimeSpan.FromSeconds(2),  
            EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseInOut }  
        };  
        Clip.BeginAnimation(RectangleGeometry.RectProperty, anim);  
        fillBrush.GradientStops[0].BeginAnimation(GradientStop.OffsetProperty, gradientAnim);  
        fillBrush.GradientStops[1].BeginAnimation(GradientStop.OffsetProperty, gradientAnim);  
    }  
    public void SetParameters(double width, double height, double lower, double upper, double spacing) {  
        this.height = height;  
        this.lower = lower;  
        this.upper = upper;  
        this.spacing = spacing;  
        Clip = new RectangleGeometry(new Rect(0, 0, width, height));  
        geometry.Clear();  
        polygon.Clear();  
        InvalidateVisual();  
    }  
    protected override void OnRender(DrawingContext drawingContext) {  
        var posHeight = height / (lower + upper) * upper;  
        var negHeight = height - posHeight;              
        var poly = polygon.Open();  
        using (var geo = geometry.Open()) {  
            double x = 0;  
            double y = values[0] < 0 ? negHeight - Math.Abs(values[0]) / lower * negHeight : values[0] / upper * posHeight + negHeight;  
            Point point = new(x, y);  
            geo.BeginFigure(point, true, false);  
            poly.BeginFigure(new Point(x, negHeight), true, true);  
            poly.LineTo(point, true, true);  
            for (int i = 1; i < values.Count; i++) {  
                x += spacing;  
                y = values[i] < 0 ? negHeight - Math.Abs(values[i]) / lower * negHeight : values[i] / upper * posHeight + negHeight;  
                point = new Point(x, y);  
                geo.LineTo(point, true, true);  
                poly.LineTo(point, true, true);  
            }  
            poly.LineTo(new Point(x, negHeight), true, true);  
        }  
        poly.Close();  
        polyPath.Data = polygon;  
        drawingContext.DrawGeometry(null, pen, geometry);  
        drawingContext.DrawGeometry(fillBrush, null, polygon);  
    }  
}  

and here's the LineChart:

class LineChart : FrameworkElement  
{  
    public IEnumerable ItemSource {  
        get { return (IEnumerable)GetValue(ItemSourceProperty); }  
        set { SetValue(ItemSourceProperty, value); }  
    }  
    public static readonly DependencyProperty ItemSourceProperty =  
        DependencyProperty.Register("ItemSource", typeof(IEnumerable), typeof(LineChart), new PropertyMetadata(null, onSourceChanged));  

    static void onSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) {  
        var o = d as LineChart;  
        if (e.OldValue != null) o.Children.Clear();  
        var values = ((IEnumerable)e.NewValue).Cast<int>().ToList();  
        if (values.Count > 0) o.generateChart(values);  
        o.InvalidateArrange();  
    }  

    int numPoints;  
    Pointer pointer;  
    TextBlock infoBlock;  
    double minValue, maxValue;  
    Size labelDesired;  
    VisualCollection Children;  
    public LineChart() {  
        infoBlock = new TextBlock() {  
            Foreground = Brushes.Gray,  
            Tag = "Info",  
            FontSize = 14,  
            IsHitTestVisible = false,  
            LayoutTransform = new ScaleTransform() { ScaleY = -1 }  
        };  
        Children = new VisualCollection(this);  
        LayoutTransform = new ScaleTransform() { ScaleY = -1 };  
    }  

    void addLine() {  
        var line = new Line() {  
            StrokeThickness = 1,  
            Stroke = Brushes.LightBlue,  
            StrokeDashCap = PenLineCap.Flat,  
            StrokeDashArray = new DoubleCollection(new List<double> { 3, 3 }),  
            IsHitTestVisible = false  
        };  
        Children.Add(line);  
        line.Loaded += animateLine;  
    }  
    void animateLine(object sender, RoutedEventArgs e) {  
        var line = sender as Line;  
        var translateAnim = new DoubleAnimation() {  
            BeginTime = TimeSpan.FromSeconds(2),  
            To = 0,  
            Duration = TimeSpan.FromSeconds(2),  
            EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseInOut }  
        };  
        var scaleAnim = new DoubleAnimation() {  
            From = 0,  
            Duration = TimeSpan.FromSeconds(2),  
            EasingFunction = new CubicEase() { EasingMode = EasingMode.EaseInOut }  
        };  
        var translate = new TranslateTransform(0, ActualHeight / 2 - line.Y1);  
        var scale = new ScaleTransform(1, 1) { CenterX = ActualWidth / 2 };  
        line.RenderTransform = new TransformGroup() { Children = { translate, scale } };  
        translate.BeginAnimation(TranslateTransform.YProperty, translateAnim);  
        scale.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnim);  
    }  
    void addTick(double current) {  
        var tick = new TextBlock() {  
            Tag = "Tick",  
            Text = current.ToString("N0"),  
            HorizontalAlignment = HorizontalAlignment.Right,  
            RenderTransform = new TransformGroup() {  
                Children = {  
                        new ScaleTransform() { ScaleY = - 1 },  
                        new TranslateTransform()  
                    }  
            },  
            IsHitTestVisible = false  
        };  
        Children.Add(tick);  
    }  
    void addLabel(string value) {  
        var label = new TextBlock() {  
            Text = value,  
            IsHitTestVisible = false,  
            TextAlignment = TextAlignment.Right,  
            Padding = new Thickness(0, 0, 5, 0),  
            RenderTransform = new TransformGroup() {  
                Children = {  
                        new ScaleTransform() { ScaleY = -1 },  
                        new RotateTransform() { Angle = 90 }  
                    }  
            }  
        };  
        Children.Add(label);  
        label.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));  
        if (label.DesiredSize.Width > labelDesired.Width)  
            labelDesired = label.DesiredSize;  
    }  
    void generateChart(List<int> values) {  
        labelDesired = new Size(0, 0);  
        Children.Add(new Border() { Background = Brushes.Transparent });  

        var line = new LineStream(values);  
        Children.Add(line);  
        maxValue = 0;  
        foreach (var value in values) {  
            Children.Add(new Circle(value));  
            addLabel(value.ToString("N0"));  
            if (value > maxValue) maxValue = value;  
            if (value < minValue) minValue = value;  
        }  
        var min = minValue < 0 ? minValue : 0;  
        double step = 0d;  
        var current = min;  
        step = min < 0 ? ((maxValue) + Math.Abs(min)) / 9 : (maxValue) / 9;  
        for (int i = 0; i < 10; i++) {  
            addLine();  
            addTick(current);  
            current += step;  
        }  
        numPoints = values.Count;  
        pointer = new Pointer() { Visibility = Visibility.Hidden };  
        Children.Add(pointer);  
    }  

    protected override Size ArrangeOverride(Size finalSize) {  
        var testTick = new TextBlock() { Text = maxValue.ToString("N0") };  
        testTick.Measure(finalSize);  
        var tickWidth = testTick.DesiredSize.Width;  
        var labelHeight = labelDesired.Width;  
        var upperBound = maxValue;  
        var lowerBound = minValue < 0 ? Math.Abs(minValue) : 0;  

        double margin = 10;  
        var availableWidth = finalSize.Width - tickWidth - 2 * margin;  
        var availableHeight = (finalSize.Height - labelHeight - margin) / 10 * 9;  
        var posHeight = availableHeight / (lowerBound + upperBound) * upperBound;  
        var negHeight = availableHeight - posHeight;  
        var horizontalSpacing = (finalSize.Width - tickWidth - 2 * margin) / (numPoints - 1);  
        double verticalSpacing = (finalSize.Height - labelHeight - margin) / 10;  

        double lineY, tickY, labelX, circleX;  
        lineY = tickY = 0;  
        labelX = circleX = tickWidth + margin;  
        foreach (var item in Children) {  
            if (item is LineStream) {  
                var path = (LineStream)item;  
                path.SetParameters(availableWidth, availableHeight, lowerBound, upperBound, horizontalSpacing);  
                path.Arrange(new Rect(new Point(tickWidth + margin, labelHeight), path.DesiredSize));  
            }  
            else if (item is Pointer) {  
                var pointer = (Pointer)item;  
                pointer.SetPointer(0, finalSize.Height, labelHeight);  
                pointer.Arrange(new Rect(pointer.DesiredSize));  
            }  
            else if (item is Circle) {  
                var circle = (Circle)item;  
                double y = circle.value < 0 ? negHeight - Math.Abs(circle.value) / lowerBound * negHeight : circle.value / upperBound * posHeight + negHeight;  
                circle.SetCenter(new Point(circleX, y + labelHeight));  
                circle.Arrange(new Rect(circle.DesiredSize));  
                circleX += horizontalSpacing;  
            }  
            else if (item is Line) {  
                var line = (Line)item;  
                line.X2 = finalSize.Width - margin;  
                line.Y1 = line.Y2 = lineY + labelHeight;  
                line.Measure(finalSize);  
                line.Arrange(new Rect(line.DesiredSize));  
                lineY += verticalSpacing;  
            }  
            else if (item is TextBlock) {  
                var block = (TextBlock)item;  
                block.Measure(finalSize);  
                if (block.Tag != null) {  
                    if (string.Equals(block.Tag.ToString(), "Tick")) {  
                        block.Arrange(new Rect(new Point(0, tickY + block.DesiredSize.Height + labelHeight), block.DesiredSize));  
                        tickY += verticalSpacing;  
                    }  
                    else {  
                        var xPos = finalSize.Width - infoBlock.DesiredSize.Width - margin;  
                        var yPos = finalSize.Height - infoBlock.DesiredSize.Height;  
                        block.Arrange(new Rect(new Point(xPos, yPos), infoBlock.DesiredSize));  
                    }  
                }  
                else {  
                    block.Width = labelDesired.Width;  
                    block.Height = labelDesired.Height;  
                    block.Arrange(new Rect(new Point(labelX - labelDesired.Height / 2, 0), block.DesiredSize));  
                    labelX += horizontalSpacing;  
                }  
            }  
            else if (item is Border) {  
                var border = (Border)item;  
                border.Width = finalSize.Width - tickWidth;  
                border.Height = finalSize.Height - labelHeight;  
                border.Measure(finalSize);  
                border.Arrange(new Rect(new Point(tickWidth, labelHeight), border.DesiredSize));  
            }  
        }  
        return finalSize;  
    }  
    protected override Visual GetVisualChild(int index) => Children[index];  
    protected override int VisualChildrenCount => Children.Count;  
    protected override void OnMouseEnter(MouseEventArgs e) => pointer.Visibility = Visibility.Visible;  
    protected override void OnMouseMove(MouseEventArgs e) => pointer.SetPointer(e.GetPosition(this).X, ActualHeight, labelDesired.Width);  
    protected override void OnMouseLeave(MouseEventArgs e) => pointer.Visibility = Visibility.Hidden;  
}  

in the OnMouseMove of LineChart, I call SetPointer method of Pointer class and there it fires the Moved event. Circle, the subcriber, of that event calls onPointerIsOver when that is fired.

In your MainWindow.xaml, add this line:

<Window x:Class="Test.MainWindow" ...>  
    <local:LineChart ItemSource="{Binding ChartValues}" Margin="50"/>  
</Window>  

and in MainWindow.xaml.cs, have these:

public partial class MainWindow : Window  
{  
    public List<int> ChartValues { get; set; }  
    public MainWindow() {  
        InitializeComponent();  
        ChartValues = new List<int>();  
        Random rand = new();  
        for (int i = 0; i < 10; i++) {  
            ChartValues.Add(rand.Next(50, 501));  
        }  
        DataContext = this;  
    }  
}  

and you'll see something like this:

101942-test2.gif

Windows Presentation Foundation
Windows Presentation Foundation
A part of the .NET Framework that provides a unified programming model for building line-of-business desktop applications on Windows.
2,785 questions
0 comments No comments
{count} votes

1 answer

Sort by: Most helpful
  1. Emon Haque 3,176 Reputation points
    2021-06-07T06:27:13.22+00:00

    Not sure whether it's a good solution BUT it works

    102855-test.gif

    added one more condition to the onPointerIsOver of Circle:

    void onPointerIsOver(double x) {  
        if(x == -1) {  
            tip.IsOpen = false;  
            if (IsSelected) IsSelected = false;  
            gradStopAnim.To = 0.25;  
            scaleAnim.To = 0.75;  
            animate();  
            return;  
        }  
        if (x >= leftBound && x <= rightBound) {  
            tip.IsOpen = true;  
            if (IsSelected) return;  
            if (gradStopAnim.To != 1) {  
                gradStopAnim.To = scaleAnim.To = 1;  
                animate();  
            }  
        }  
        else {  
            tip.IsOpen = false;  
            if (IsSelected) return;  
            if (gradStopAnim.To == 1) {  
                gradStopAnim.To = 0.25;  
                scaleAnim.To = 0.75;  
                animate();  
            }  
        }              
    }  
    

    and set that condition in OnMouseLeave of LineChart:

    protected override void OnMouseLeave(MouseEventArgs e) {  
        pointer.Visibility = Visibility.Hidden;  
        pointer.SetPointer(-1, ActualHeight, labelDesired.Width);  
    }  
    
    0 comments No comments

Your answer

Answers can be marked as Accepted Answers by the question author, which helps users to know the answer solved the author's problem.