Simulating Pipeline Fluid Flow Direction in WPF - Path Animation

Simulating Pipeline Fluid Flow Direction in WPF - Path Animation

A major feature of WPF is its animation system, which enables many effects that are difficult to achieve in WinForms.

Last updated 1/15/2023 12:46 PM
ludewig
11 min read
Category
WPF
Topic
WPF UI Design
Tags
.NET C# WPF Winform

One of the major features of WPF is its animation system, which enables effects that are difficult to achieve in WinForm. I recently came across a great example online where a developer used WPF animations to move objects along a specific path in either forward or reverse direction, and I wanted to try it out myself.

1. Simple Path Animation

Let's start with the simplest path animation: a square and a line segment. The square moves from the start point to the end point of the line. The front-end page code is as follows:

<Grid>
  <Grid.RowDefinitions>
    <RowDefinition Height="80"></RowDefinition>
    <RowDefinition></RowDefinition>
  </Grid.RowDefinitions>
  <WrapPanel VerticalAlignment="Center" HorizontalAlignment="Center">
    <button x:Name="btnAnimo" Click="btnAnimo_Click" Margin="0,0,10,0">
      Start
    </button>
  </WrapPanel>
  <Grid Grid.Row="1">
    <canvas x:Name="cvsMain">
      <Path
        x:Name="path1"
        Data="M100,100 L300,100 400,200 500,200"
        Stroke="LightGreen"
        StrokeThickness="20"
        StrokeLineJoin="Round"
      ></Path>
    </canvas>
  </Grid>
</Grid>

The code-behind logic is as follows:

private void btnAnimo_Click(object sender, RoutedEventArgs e)
{
    AnimationByPath(cvsMain, path1,path1.StrokeThickness);
}

/// <summary>
/// Path animation
/// </summary>
/// <param name="cvs">Canvas</param>
/// <param name="path">Path</param>
/// <param name="target">Animation object</param>
/// <param name="duration">Duration in seconds</param>
private void AnimationByPath(Canvas cvs, Path path, double targetWidth, int duration = 5)
{
    #region Create animation object
    Rectangle target = new Rectangle();
    target.Width = targetWidth;
    target.Height = targetWidth;
    target.Fill = new SolidColorBrush(Colors.Orange);
    cvs.Children.Add(target);
    Canvas.SetLeft(target, -targetWidth / 2);
    Canvas.SetTop(target, -targetWidth / 2);
    target.RenderTransformOrigin = new Point(0.5, 0.5);
    #endregion

    MatrixTransform matrix = new MatrixTransform();
    TransformGroup groups = new TransformGroup();
    groups.Children.Add(matrix);
    target.RenderTransform = groups;
    string registname = "matrix" + Guid.NewGuid().ToString().Replace("-", "");
    this.RegisterName(registname, matrix);
    MatrixAnimationUsingPath matrixAnimation = new MatrixAnimationUsingPath();
    matrixAnimation.PathGeometry = PathGeometry.CreateFromGeometry(Geometry.Parse(path.Data.ToString()));
    matrixAnimation.Duration = new Duration(TimeSpan.FromSeconds(duration));
    matrixAnimation.DoesRotateWithTangent = true; // Rotate to follow path tangent
    matrixAnimation.RepeatBehavior = RepeatBehavior.Forever; // Loop
    Storyboard story = new Storyboard();
    story.Children.Add(matrixAnimation);
    Storyboard.SetTargetName(matrixAnimation, registname);
    Storyboard.SetTargetProperty(matrixAnimation, new PropertyPath(MatrixTransform.MatrixProperty));

    story.FillBehavior = FillBehavior.Stop;
    story.Begin(target, true);
}

The key point is to dynamically create a Rectangle square as the animation object, with its width and height set to match the path's thickness, and set the transform origin to the center (RenderTransformOrigin ="0.5,0.5") to ensure that the square rotates with the path as it moves. The final effect is as follows:

2. Reverse Path Animation

Based on the previous example, replacing the line segment with multiple continuous line segments or even adding arcs does not affect the result. The block will still move along the path. A path has a start and an end point. Normally, the animation object moves from start to end. Can we make the object move from the end to the start?

If we think differently, reversing the original path by swapping the start and end points gives us a path that looks identical but has reversed data. If the animation object moves along the reversed path, visually it moves from end to start.

The key to solving this problem is converting the path data.

private string ConvertPathData(string data)
{
    data = data.Replace("M", "");
    Regex regex = new Regex("[a-z]", RegexOptions.IgnoreCase);
    MatchCollection mc = regex.Matches(data);
    // item1: substring from previous position to current match start (match.Index = position of first character of captured substring in original string)
    // item2: current match symbol (L, C, Z, M)
    List<Tuple<string, string>> tmps = new List<Tuple<string, string>>();
    int index = 0;
    for (int i = 0; i < mc.Count; i++)
    {
        Match match = mc[i];
        if (match.Index != index)
        {
            string str = data.Substring(index, match.Index - index);
            tmps.Add(new Tuple<string, string>(str, match.Value));
        }
        index = match.Index + match.Length;
        if (i + 1 == mc.Count) // last
        {
            tmps.Add(new Tuple<string, string>(data.Substring(index), match.Value));
        }
    }
    List<string[]> arrys = new List<string[]>();
    Regex regexnum = new Regex(@"(\-?\d+\.?\d*)", RegexOptions.IgnoreCase);
    for (int i = 0; i < tmps.Count; i++)
    {
        MatchCollection childMcs = regexnum.Matches(tmps[i].Item1);
        if (childMcs.Count % 2 != 0)
        {
            continue;
        }
        int groups = childMcs.Count / 2;
        var strTmp = new string[groups];
        for (int j = 0; j < groups; j++)
        {
            string cdatas = childMcs[j * 2] + "," + childMcs[j * 2 + 1]; // Reassemble data
            strTmp[j] = cdatas;
        }
        arrys.Add(strTmp);
    }

    List<string> result = new List<string>();
    for (int i = arrys.Count - 1; i >= 0; i--)
    {
        string[] clist = arrys[i];
        for (int j = clist.Length - 1; j >= 0; j--)
        {
            if (j == clist.Length - 2 && i > 0) // For the second element, add L or C identifier
            {
                var pointWord = tmps[i - 1].Item2; // Get identifier
                result.Add(pointWord + clist[j]);
            }
            else
            {
                result.Add(clist[j]);
                if (clist.Length == 1 && i > 0) // Only one element, e.g., L44.679973,69.679973
                {
                    result.Add(tmps[i - 1].Item2);
                }
            }
        }
    }
    return "M" + string.Join(" ", result);
}

Additionally, the square used as the animation object can be replaced with any control. For illustration, we replace the square with an arrow, and to distinguish between forward and reverse animations, we set different colors for the paths. The modified code is as follows:

/// <summary>
/// Forward
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnAnimo_Click(object sender, RoutedEventArgs e)
{
    AnimationByPath(cvsMain, path1,path1.StrokeThickness,false,3);
}
/// <summary>
/// Reverse
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnReback_Click(object sender, RoutedEventArgs e)
{
    AnimationByPath(cvsMain, path1, path1.StrokeThickness, true, 3);
}

/// <summary>
/// Path animation
/// </summary>
/// <param name="cvs">Canvas</param>
/// <param name="path">Path</param>
/// <param name="targetWidth">Animation object width/height</param>
/// <param name="isInverse">Whether to reverse</param>
/// <param name="duration">Animation duration</param>
private void AnimationByPath(Canvas cvs, Path path, double targetWidth, bool isInverse = false, int duration = 5)
{
    Polygon target = new Polygon();
    target.Points = new PointCollection()
    {
        new Point(0,0),
        new Point(targetWidth/2,0),
        new Point(targetWidth,targetWidth/2),
        new Point(targetWidth/2,targetWidth),
        new Point(0,targetWidth),
        new Point(targetWidth/2,targetWidth/2)
    };

    if (isInverse) // Reverse
    {
        target.Fill = new SolidColorBrush(Colors.DeepSkyBlue);
    }
    else // Forward
    {
        target.Fill = new SolidColorBrush(Colors.Orange);
    }

    cvs.Children.Add(target);
    Canvas.SetLeft(target, -targetWidth / 2);
    Canvas.SetTop(target, -targetWidth / 2);
    target.RenderTransformOrigin = new Point(0.5, 0.5);

    MatrixTransform matrix = new MatrixTransform();
    TransformGroup groups = new TransformGroup();
    groups.Children.Add(matrix);
    target.RenderTransform = groups;
    string registname = "matrix" + Guid.NewGuid().ToString().Replace("-", "");
    this.RegisterName(registname, matrix);
    MatrixAnimationUsingPath matrixAnimation = new MatrixAnimationUsingPath();
    if (!isInverse) // Forward
    {
        matrixAnimation.PathGeometry = PathGeometry.CreateFromGeometry(Geometry.Parse(path.Data.ToString()));
    }
    else // Reverse
    {
        string data = ConvertPathData(path.Data.ToString());
        matrixAnimation.PathGeometry = PathGeometry.CreateFromGeometry(Geometry.Parse(data));
    }
    matrixAnimation.Duration = new Duration(TimeSpan.FromSeconds(duration));
    matrixAnimation.DoesRotateWithTangent = true; // Rotate
    matrixAnimation.RepeatBehavior = RepeatBehavior.Forever;
    Storyboard story = new Storyboard();
    story.Children.Add(matrixAnimation);
    Storyboard.SetTargetName(matrixAnimation, registname);
    Storyboard.SetTargetProperty(matrixAnimation, new PropertyPath(MatrixTransform.MatrixProperty));

    story.FillBehavior = FillBehavior.Stop;
    story.Begin(target, true);
}

The effect now becomes the following—quite interesting, isn't it?

3. Simulating Pipe Fluid Animation

With the foundation above, we can improve it to simulate water flowing in a pipe. There will be multiple pipes of different diameters. We also add a water pump: when the pump starts, water flows; when the pump reverses, water flows backward. Since we've already solved the core problem, we just need to add a keyframe animation to control the rotation of the animation object.

The front-end code is modified to:

<Grid>
  <Grid.RowDefinitions>
    <RowDefinition Height="80"></RowDefinition>
    <RowDefinition></RowDefinition>
  </Grid.RowDefinitions>
  <WrapPanel VerticalAlignment="Center" HorizontalAlignment="Center">
    <button x:Name="btnAnimo" Click="btnAnimo_Click" Margin="0,0,10,0">
      Forward
    </button>
    <button x:Name="btnReback" Click="btnReback_Click" Margin="0,0,10,0">
      Reverse
    </button>
  </WrapPanel>
  <Grid Grid.Row="1">
    <canvas x:Name="cvsMain">
      <Path
        x:Name="path1"
        Data="M100,100 L300,100 300,200 400,200"
        Stroke="LightGreen"
        StrokeThickness="20"
        StrokeLineJoin="Round"
      ></Path>
      <Path
        x:Name="path2"
        Data="M200,300 L350,300 350,200"
        Stroke="LightGreen"
        StrokeThickness="12"
        StrokeLineJoin="Round"
      ></Path>
      <Path
        x:Name="path3"
        Data="M450,223 L550,223 650,100 750,100 800,150"
        Stroke="LightGreen"
        StrokeThickness="16"
        StrokeLineJoin="Round"
      ></Path>
      <image
        Source="fan.png"
        Width="50"
        Height="50"
        Canvas.Left="400"
        Canvas.Top="185"
      ></image>
      <image
        x:Name="imgFan"
        Source="fan-inner.png"
        Width="24"
        Height="24"
        Canvas.Left="410"
        Canvas.Top="197"
        RenderTransformOrigin="0.5,0.5"
      ></image>
    </canvas>
  </Grid>
</Grid>

The code-behind is modified to:

/// <summary>
/// Forward rotation
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnAnimo_Click(object sender, RoutedEventArgs e)
{
    // Note: the third parameter in the original post used this.path[x].Width, but it should be this.path[x].StrokeThickness
    AnimationByPath(this.cvsMain, this.path1, this.path1.StrokeThickness, false, 3);
    AnimationByPath(this.cvsMain, this.path2, this.path2.StrokeThickness, false, 3);
    AnimationByPath(this.cvsMain, this.path3, this.path3.StrokeThickness, false, 3);

    StoryByOrient(this.imgFan, 0, 3);
}
/// <summary>
/// Reverse rotation
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnReback_Click(object sender, RoutedEventArgs e)
{
    // Note: the third parameter in the original post used this.path[x].Width, but it should be this.path[x].StrokeThickness
    AnimationByPath(this.cvsMain, this.path1, this.path1.StrokeThickness, true, 3);
    AnimationByPath(this.cvsMain, this.path2, this.path2.StrokeThickness, true, 3);
    AnimationByPath(this.cvsMain, this.path3, this.path3.StrokeThickness, true, 3);

    StoryByOrient(this.imgFan, 1, 3);
}

/// <summary>
/// Rotation animation
/// </summary>
/// <param name="img">Animation object</param>
/// <param name="orientation">Clockwise / Counterclockwise</param>
/// <param name="duration"></param>
private void StoryByOrient(Image img, int orientation, int duration = 5)
{
    Storyboard storyboard = new Storyboard(); // Create storyboard
    DoubleAnimation doubleAnimation = new DoubleAnimation(); // Instantiate a double animation
    RotateTransform rotate = new RotateTransform(); // Rotation transform instance
    img.RenderTransform = rotate; // Assign transform to image
    storyboard.RepeatBehavior = RepeatBehavior.Forever; // Loop forever
    storyboard.SpeedRatio = 2; // Playback speed
    // Rotate from 0 to 360 degrees
    doubleAnimation.From = 0;
    if (orientation == 0) // Clockwise
    {
        doubleAnimation.To = 360;
    }
    else // Counterclockwise
    {
        doubleAnimation.To = -360;
    }
    doubleAnimation.Duration = new Duration(TimeSpan.FromSeconds(duration)); // Duration 2 seconds
    Storyboard.SetTarget(doubleAnimation, img); // Set target object
    Storyboard.SetTargetProperty(doubleAnimation,
new PropertyPath("RenderTransform.Angle")); // Set dependency property
    storyboard.Children.Add(doubleAnimation); // Add animation to storyboard
    storyboard.Begin(img); // Start animation
}

Let's see the final effect:

It looks quite convincing.

Note: The third example is missing images. The site administrator extracted parts from the original GIF and adjusted parameters, making it run to produce the above effect: https://github.com/dotnet9/TerminalMACS.ManagerForWPF/tree/master/src/Demo/PathAnimationDemo

Programming is fun, never give up.

This article is reproduced.

Author: ludewig

Original title: WPF Essay (9) – Using Path Animation to Simulate Pipe Fluid Flow

Original link: https://blog.csdn.net/lordwish/article/details/85007867

Keep Exploring

Related Reading

More Articles
Same category / Same tag 9/13/2025

Migration Series from WPF to Avalonia: Why I Must Migrate My WPF Application to Avalonia

In the past few years, our host computer software has mainly been developed using WPF and WinForm . These technologies work well on the Windows platform and have accompanied us from small-scale trial production to the current stage of large-scale delivery. However, with business development and changes in customer requirements, the single Windows technology stack has gradually become a hurdle we must overcome.

Continue Reading