Building a Dynamic Milestone Journey Tracker with SkiaSharp in .NET MAUI

Ali RazaAli Raza
4 min read

πŸ“Œ Introduction

A visual progress tracker elevates user experience. In this post, we’ll create a milestone journey tracker in .NET MAUI that animates a curved path and displays milestone images dynamically along it, updating completed ones with a tick image.

🧰 Tools & Technologies

  • .NET MAUI

  • SkiaSharp

  • AbsoluteLayout

  • Custom Animation

  • Dynamic Image Placement

πŸ–ΌοΈ UI Layout in XAML

We define a Border with an AbsoluteLayout that hosts the animated path and milestone images.

<Grid RowDefinitions="*,*">
  <Border Padding="10" Stroke="#1A299494" StrokeThickness="1" StrokeShape="RoundRectangle 7">
    <Border.Background>
      <LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
        <GradientStop Color="#1A299494" Offset="0.0" />
        <GradientStop Color="#fbfcfc" Offset="1.0" />
      </LinearGradientBrush>
    </Border.Background>

    <AbsoluteLayout x:Name="RootLayout">
      <skia:SKCanvasView x:Name="RoadCanvas"
                         PaintSurface="OnPaintSurface"
                         AbsoluteLayout.LayoutBounds="0,0,1,1"
                         AbsoluteLayout.LayoutFlags="All" />
    </AbsoluteLayout>
  </Border>
</Grid>

🎯 Key Variables Used

Before diving into SkiaSharp drawing logic, here are the core variables that manage animation, path points, and milestones dynamically:

// Stores the calculated path points to align milestones along the curved path
private List<(float x, float y)> pathPoints = new();

// Represents each milestone's state and associated image
private List<Milestone> milestones = new();

// Stopwatch to control path animation timing
Stopwatch pathAnimationTimer = new();

// Progress of the animation (0 to 1)
float animationProgress = 0f;

// Duration of the curved path animation in milliseconds
const int AnimationDurationMs = 800;

These variables drive the animation logic and help dynamically position milestone images smoothly on the path.

🎨 Drawing the Curved Animated Path with Animation

Here's how we draw the curved path using sine waves and animate its drawing:

 private void OnPaintSurface(object sender, SKPaintSurfaceEventArgs e)
 {
     var canvas = e.Surface.Canvas;
     canvas.Clear();

     int width = e.Info.Width;
     int height = e.Info.Height;

     if (!pathAnimationTimer.IsRunning)
     {
         pathAnimationTimer.Start();
     }

     float elapsed = pathAnimationTimer.ElapsedMilliseconds;
     animationProgress = Math.Min(elapsed / AnimationDurationMs, 1f);

     using var pathPaint = new SKPaint
     {
         Style = SKPaintStyle.Stroke,
         StrokeWidth = 6,
         Color = new SKColor(15, 167, 152), // 
         IsAntialias = true,
         StrokeCap = SKStrokeCap.Round
     };

     var path = new SKPath();
     path.MoveTo(0, height / 2f);

     pathPoints.Clear(); // Prevent duplication or out-of-sync points

     for (float x = 0; x <= width * animationProgress; x += 10)
     {
         float y = (float)(height / 2 + Math.Sin(x * 0.01) * 50);
         path.LineTo(x, y);
         pathPoints.Add((x, y)); // Store for GetClosestPathPoint
     }

     canvas.DrawPath(path, pathPaint);

     if (animationProgress < 1)
     {
         ((SKCanvasView)sender).InvalidateSurface(); // Keep animating
     }
     else
     {
         PlaceMilestoneImages(width, height); // Only after path fully drawn
     }
 }

πŸ–ΌοΈ Displaying Milestone Images on the Path

You dynamically place images at equal distances along the curve using points from pathPoints.

 private async void PlaceMilestoneImages(int canvasWidth, int canvasHeight)
 {
     // Remove previous images
     for (int i = RootLayout.Children.Count - 1; i >= 0; i--)
     {
         if (RootLayout.Children[i] is Image)
             RootLayout.Children.RemoveAt(i);
     }
     float dipPadding = 25f; // Padding you have added around the canvas
     float density = (float)DeviceDisplay.MainDisplayInfo.Density;

     float pixelPadding = dipPadding * density;
     float drawableWidth = canvasWidth - 2 * pixelPadding;


     for (int i = 0; i < milestones.Count; i++)
     {
         float progress = (float)i / (milestones.Count - 1);
         float x = pixelPadding + drawableWidth * progress;

         var closest = GetClosestPathPoint(x);
         float y = closest.y;

         // Convert pixels to DIP
         double dipX = closest.x / density;
         double dipY = closest.y / density;

         var milestone = milestones[i];

         var image = new Image
         {
             Source = milestone.IsCompleted ? "dash_check" : milestone.ImageSource,
             WidthRequest = 35,
             HeightRequest = 35,
             VerticalOptions = LayoutOptions.Center,
             HorizontalOptions = LayoutOptions.Center,
             Opacity = 0,
             Scale = 0.5
         };

         AbsoluteLayout.SetLayoutBounds(image, new Rect(dipX - 16, dipY - 16, 32, 32));
         AbsoluteLayout.SetLayoutFlags(image, AbsoluteLayoutFlags.None);
         RootLayout.Children.Add(image);

         // Animate
         await Task.Delay(100); // Stagger for effect
         _ = image.FadeTo(1, 300, Easing.CubicIn);
         _ = image.ScaleTo(1, 300, Easing.BounceOut);
     }
     await Task.Delay(100); // Stagger for effect
     _ = borProgress.FadeTo(1, 300, Easing.CubicIn);
     _ = borProgress.ScaleTo(1, 300, Easing.BounceOut);
 }

πŸ” Helper Method: Get Closest Path Point

    private (float x, float y) GetClosestPathPoint(float targetX)
    {
        (float x, float y) closest = pathPoints[0];
        float minDist = Math.Abs(closest.x - targetX);

        foreach (var point in pathPoints)
        {
            float dist = Math.Abs(point.x - targetX);
            if (dist < minDist)
            {
                minDist = dist;
                closest = point;
            }
        }

        return closest;
    }

πŸ§ͺ Pro Tips

  • Cache milestone list if coming from API

  • Always remove previous images to avoid stacking

  • Add a ResetTracker() method to reanimate from start

  • Consider SVGs or Lottie for more complex visuals

🏁 Conclusion

You now have a visually rich, animated Milestone Tracker for any onboarding or progress-based feature in your .NET MAUI app.

0
Subscribe to my newsletter

Read articles from Ali Raza directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ali Raza
Ali Raza

πŸš€ Tech Lead | .NET MAUI Expert | Mobile App Developer I'm Ali Raza, a passionate Tech Lead with over 6 years of experience in mobile app development. I specialize in .NET MAUI/Xamarin and have led multiple high-impact projects, including enterprise apps, fintech solutions, and eSIM technology. πŸ”Ή What I Do: βœ” .NET MAUI & Xamarin – Building cross-platform apps with robust architectures βœ” eSIM & Fintech Apps – Leading innovations in digital connectivity & finance βœ” Performance Optimization – Creating high-quality, scalable mobile solutions βœ” Mentorship & Community Sharing – Helping developers master .NET MAUI πŸ“’ Sharing Weekly Insights on .NET MAUI/Xamarin to help developers level up! Follow me for deep dives into Native Interop, API Optimization, and Advanced UI/UX techniques. Let’s connect and build something amazing! πŸš€