Tips for the Do It All DBA
April 14th, 2009
In my previous blog on path animation, a path element was given a “spinning wheel” effect by transforming the StrokeDashArray property. This time around, let’s animate some visual along a path element. I will reuse the spinning wheel project and it will serve as the target to move around with. The path element will be made up of a bunch of bezier curve segments. The final project should look like a planet with a spinning ring looping thru 3 irregular ellipses like this.
You can see the online demo here.
Some Math
Although the object may seem to move along a curve, it is actually performing a lot of small incremental linear TranslateTransform animations. To calculate all the points between a start and end point along a curve, you need to perform what mathematisians call interpolation or “curve fitting.” I came across a great article on doing exactly this for ActionScript and implemented the same calculation method for my project.
void CalculateNextBezierSegmentPoint() { posx = Math.Pow(counter, 3) * (anchor2.X + 3 * (control1.X - control2.X) - anchor1.X) + 3 * Math.Pow(counter, 2) * (anchor1.X - 2 * control1.X + control2.X) + 3 * counter * (control1.X - anchor1.X) + anchor1.X; posy = Math.Pow(counter, 3) * (anchor2.Y + 3 * (control1.Y - control2.Y) - anchor1.Y) + 3 * Math.Pow(counter, 2) * (anchor1.Y - 2 * control1.Y + control2.Y) + 3 * counter * (control1.Y - anchor1.Y) + anchor1.Y; }
The variables “anchor1″ and “anchor2″ are the start and end points. The variables “control1″ and “control2″ are the control points of the cubic bezier curve. This resembles the path markup syntax in Silverlight for defining a cubic bezier segment: “C control1, control2, anchor2″. The count variable is a fraction (ie, 0.01, 0.2, etc) and is very important not only in the calculation of the incremental data points but will also serve as the checkpoint as to when to stop calculating once we have reached the end point (anchor2) as we’ll see shortly.
Parsing Path Markup
The 3 irregular ellipses in the above image is made of 6 bezier curve segments as defined below.
const string path_markup = "M 85,135 C 80 ,0 235,0 285,135 C 345,350 750,450 545,175 C365,0 705,0 715,125 C735,250 570,250 515,135 C 425,0 125,0 285,160 C 500,400 110,450 85,135";
There are various ways you can implement a method to parse a path markup. My approach was to use regular expressions and some Linq expressions to arrive at a sequence of data points. For this project, I will only look for a start point “M” and cubic bezier segments “C”. But you can easily change the regular expression to return more matches like “Q” for a quadratic bezier segment or “A” for an ellipse. (Just remember to impement the logic to handle those type of path segments.)
I will perform 2 regular expression searches. The first is for a path segment markup: “C 80,0 235,0 285,135″. If I find matches for a path segment, I perform a second regular expression search for groups of data points within the path segment: “80,0″, “235,0″; “285,135″. Once I have a sequence of data points, I will queue them up.
void QueuePathSegments(string path_markup) { // only parsing for cubic bezier segments for now... string get_control_points_expression = @"\s*(-?\d*(\.\d*)?\s*,\s*-?\d*(\.\d*)?\s*)"; string get_curve_commands_expression = string.Format(@"([MmCc]{0}{{1,3}})", get_control_points_expression); var curve_commands_matches = from Match curve_commands in Regex.Matches(path_markup, get_curve_commands_expression) select curve_commands; foreach (Match curve_command in curve_commands_matches) { var points = from control_point in (from Match control_points in Regex.Matches(curve_command.Value, get_control_points_expression) select control_points) select control_point.Value.Replace(" ", ""); // Start Point "M" if (curve_command.Value.StartsWith("m", StringComparison.InvariantCultureIgnoreCase)) { string[] coordinates = points.FirstOrDefault().Split(','); BezierSegment bezier_segment = new BezierSegment(); bezier_segment.Point3 = new Point() { X = Double.Parse(coordinates[0].Trim()), Y = Double.Parse(coordinates[1].Trim()) }; path_segments.Enqueue(bezier_segment); } // Cubic Bezier Point "C" if (curve_command.Value.StartsWith("c", StringComparison.InvariantCultureIgnoreCase)) { int point_counter = 0; BezierSegment bezier_segment = new BezierSegment(); points.ToList().ForEach(p => { string[] coordinates = p.Split(','); switch (point_counter) { case 0: bezier_segment.Point1 = new Point() { X = Double.Parse(coordinates[0].Trim()), Y = Double.Parse(coordinates[1].Trim()) }; break; case 1: bezier_segment.Point2 = new Point() { X = Double.Parse(coordinates[0].Trim()), Y = Double.Parse(coordinates[1].Trim()) }; break; case 2: bezier_segment.Point3 = new Point() { X = Double.Parse(coordinates[0].Trim()), Y = Double.Parse(coordinates[1].Trim()) }; break; } point_counter++; }); path_segments.Enqueue(bezier_segment); } } }
Processing the Queue
Once the path segments are queued up, I’ll just create a routine to loop thru the queue and perform an animation for each path segment. For each path segment in the queue, the second anchor point becomes the first anchor point of the path segment. When one path segment has been processed, I’ll signal an event to fetch another path segemnt to process.
The CalculateNextBezierSegmentPoint() function that does the interpolation will now be passed as a variable to an “Animate” method.
void Page_Loaded(object sender, RoutedEventArgs e) { this.one_animation_completed_event += new EventHandler(delegate { LookForSegmentToAnimate(); }); } void LookForSegmentToAnimate() { if (path_segments.Count > 0) { // start one animation StartOneAnimation(); } else { // signal all animations are done if (all_animations_completed_events != null) all_animations_completed_events(this, new EventArgs()); } } void StartOneAnimation() { // dequeue and set global control points variables PathSegment path_segment = path_segments.Dequeue(); if (path_segment is BezierSegment) { var bezier_segemnt = path_segment as BezierSegment; control1 = bezier_segemnt.Point1; control2 = bezier_segemnt.Point2; anchor2 = bezier_segemnt.Point3; Action calculate_path_segment_point = CalculateNextBezierSegmentPoint; Animate(calculate_path_segment_point); } }
Performing the Animation
As for the “Animate” method, I need to create a routine that creates a bunch (say, about 100) of TranslateTransform animations that move the object in small steps from one anchor point to the next. When the Animate method has processed a pre-determined number of TranslateTransform animations (in this case, 100), it will signal an event that all animations for a given path segment has finished and that event will be handled by the LookForSegmentToAnimate() as described above.
// This UIElement will move along the path MovablePanel movable_panel = new MovablePanel(); // Variables used as discrete set of points to define a bezier curve for interpolation Point anchor1 = new Point(0, 0); Point control1 = new Point(0, 0); Point control2 = new Point(0,0); Point anchor2 = new Point(0,0); // Variables used for interpolation (curve fitting) calculation const int step_size = 100; Double counter = 0.0; Double posx, posy; void Animate(Action calculate_path_segment_point) { calculate_path_segment_point(); movable_panel.TranslateToPoint = new Point(posx, posy); storyboard = new Storyboard(); storyboard.Completed += delegate { //storyboard = null; storyboard.Stop(); storyboard.Children.Clear(); counter += (1.0 / step_size); if (counter > 1) { // the last point of an animation becomes the first point of the next animation anchor1 = new Point(posx, posy); // need to reset counter for next animation block counter = 0; // signal archor2 point has been reached and animation is done if (one_animation_completed_event != null) one_animation_completed_event(this, new EventArgs()); } else { Animate(calculate_path_segment_point); } }; BuildTranslateTransformStoryboard(storyboard, movable_panel, movable_panel.OffsetPoint, step_size); storyboard.Begin(); }
You may notice that I am actually moving something called a “movable_panel”. It derives from ItemsControl so you can add any UIElement to the container and all objects within the container will appear to move along the path. The “TranslateToPoint” property is a DependencyProperty which calculates a relative Point property from a given container like a Grid or ItemsControl which is explained more in detail in my blog on a custom hit test.
Finally, here is the complete code.
MovablePanel.cs
public class MovablePanel : ItemsControl { public MovablePanel() { TransformGroup transformgroup = new TransformGroup(); transformgroup.Children.Add(new TranslateTransform()); this.RenderTransform = transformgroup; } public Point StartPosition { get; set; } // sets the initial position of object; will be (0,0) by default if not set public Point OffsetPoint { get; set; } // this property is updated by the dependency property TranslateToPoint; translate animation will use this value #region "Dependency Objects" public static readonly DependencyProperty TranslateToPointProperty = DependencyProperty.Register("TranslateToPoint", typeof(Point), typeof(MovablePanel), new PropertyMetadata(new PropertyChangedCallback(OnTranslateToPointPropertyChanged))); public Point TranslateToPoint { get { return (Point)GetValue(TranslateToPointProperty); } set { SetValue(TranslateToPointProperty, value); } } static void OnTranslateToPointPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var ui_element = d as MovablePanel; var newPoint = (Point)e.NewValue; var offsetX = newPoint.X - ui_element.StartPosition.X; var offsetY = newPoint.Y - ui_element.StartPosition.Y; ui_element.OffsetPoint = new Point(offsetX, offsetY); } #endregion }
Page.xaml.cs
Variables
// This UIElement will move along the path MovablePanel movable_panel = new MovablePanel(); // You can substitute SpinAnimation with any UIElement derived object SpinAnimation animation_to_move = new SpinAnimation(); // Variables used as discrete set of points to define a bezier curve for interpolation Point anchor1 = new Point(0, 0); Point control1 = new Point(0, 0); Point control2 = new Point(0, 0); Point anchor2 = new Point(0, 0); // Variables used for interpolation (curve fitting) calculation const int step_size = 100; Double counter = 0.0; Double posx, posy; // Variables used in project to animate object along a path const string path_markup = "M 85,135 C 80 ,0 235,0 285,135 C 345,350 750,450 545,175 C365,0 705,0 715,125 C735,250 570,250 515,135 C 425,0 125,0 285,160 C 500,400 110,450 85,135"; Storyboard storyboard; Queue<PathSegment> path_segments = new Queue<PathSegment>(); public event EventHandler all_animations_completed_events; public event EventHandler one_animation_completed_event;
EventHandlers
public Page() { InitializeComponent(); this.Loaded += new RoutedEventHandler(Page_Loaded); } void Page_Loaded(object sender, RoutedEventArgs e) { // Add any UIElement to the moving panel and add the moving panel to the layout this.LayoutRoot.Children.Add(movable_panel); movable_panel.Items.Add(animation_to_move); this.one_animation_completed_event += new EventHandler(delegate { LookForSegmentToAnimate(); }); this.all_animations_completed_events += new EventHandler(OnAllAnimationsCompletedAlongPath); //Parse some path markup syntax and queue it up for animating QueuePathSegments(path_markup); //Start the animating along the path defined above StartOneAnimation(); } void OnAllAnimationsCompletedAlongPath(object sender, EventArgs e) { // queue up the bezier segments again, and take out first data point "M" QueuePathSegments(path_markup); path_segments.Dequeue(); StartOneAnimation(); }
Parsing Path Markup
void QueuePathSegments(string path_markup) { // only parsing for cubic bezier segments for now... string get_control_points_expression = @"\s*(-?\d*(\.\d*)?\s*,\s*-?\d*(\.\d*)?\s*)"; string get_curve_commands_expression = string.Format(@"([MmCc]{0}{{1,3}})", get_control_points_expression); var curve_commands_matches = from Match curve_commands in Regex.Matches(path_markup, get_curve_commands_expression) select curve_commands; foreach (Match curve_command in curve_commands_matches) { var points = from control_point in (from Match control_points in Regex.Matches(curve_command.Value, get_control_points_expression) select control_points) select control_point.Value.Replace(" ", ""); // Start Point "M" if (curve_command.Value.StartsWith("m", StringComparison.InvariantCultureIgnoreCase)) { string[] coordinates = points.FirstOrDefault().Split(','); BezierSegment bezier_segment = new BezierSegment(); bezier_segment.Point3 = new Point() { X = Double.Parse(coordinates[0].Trim()), Y = Double.Parse(coordinates[1].Trim()) }; path_segments.Enqueue(bezier_segment); } // Cubic Bezier Point "C" if (curve_command.Value.StartsWith("c", StringComparison.InvariantCultureIgnoreCase)) { int point_counter = 0; BezierSegment bezier_segment = new BezierSegment(); points.ToList().ForEach(p => { string[] coordinates = p.Split(','); switch (point_counter) { case 0: bezier_segment.Point1 = new Point() { X = Double.Parse(coordinates[0].Trim()), Y = Double.Parse(coordinates[1].Trim()) }; break; case 1: bezier_segment.Point2 = new Point() { X = Double.Parse(coordinates[0].Trim()), Y = Double.Parse(coordinates[1].Trim()) }; break; case 2: bezier_segment.Point3 = new Point() { X = Double.Parse(coordinates[0].Trim()), Y = Double.Parse(coordinates[1].Trim()) }; break; } point_counter++; }); path_segments.Enqueue(bezier_segment); } } } void CalculateNextBezierSegmentPoint() { posx = Math.Pow(counter, 3) * (anchor2.X + 3 * (control1.X - control2.X) - anchor1.X) + 3 * Math.Pow(counter, 2) * (anchor1.X - 2 * control1.X + control2.X) + 3 * counter * (control1.X - anchor1.X) + anchor1.X; posy = Math.Pow(counter, 3) * (anchor2.Y + 3 * (control1.Y - control2.Y) - anchor1.Y) + 3 * Math.Pow(counter, 2) * (anchor1.Y - 2 * control1.Y + control2.Y) + 3 * counter * (control1.Y - anchor1.Y) + anchor1.Y; }
Animation
#region "Animation Block" Action<Storyboard, UIElement, Point, int> BuildTranslateTransformStoryboard = (storyboard, item, offset, step_size) => { var translationAnimationX = new DoubleAnimation() { SpeedRatio = 0.8, Duration = new Duration(TimeSpan.FromSeconds(1.0 / step_size)), To = offset.X, AutoReverse = false }; storyboard.Children.Add(translationAnimationX); Storyboard.SetTargetProperty(translationAnimationX, new PropertyPath("(UIElement.RenderTransform).(TransformGroup.Children)[0].(TranslateTransform.X)")); Storyboard.SetTarget(storyboard, item); var translationAnimationY = new DoubleAnimation() { SpeedRatio = 0.8, Duration = new Duration(TimeSpan.FromSeconds(1.0 / step_size)), To = offset.Y, AutoReverse = false }; storyboard.Children.Add(translationAnimationY); Storyboard.SetTargetProperty(translationAnimationY, new PropertyPath("(UIElement.RenderTransform).(TransformGroup.Children)[0].(TranslateTransform.Y)")); Storyboard.SetTarget(storyboard, item); }; void Animate(Action calculate_path_segment_point) { calculate_path_segment_point(); movable_panel.TranslateToPoint = new Point(posx, posy); storyboard = new Storyboard(); storyboard.Completed += delegate { storyboard.Stop(); storyboard.Children.Clear(); counter += (1.0 / step_size); if (counter > 1) { // the last point of an animation becomes the first point of the next animation anchor1 = new Point(posx, posy); // need to reset counter for next animation block counter = 0; // signal archor2 point has been reached and animation is done if (one_animation_completed_event != null) one_animation_completed_event(this, new EventArgs()); } else { Animate(calculate_path_segment_point); } }; BuildTranslateTransformStoryboard(storyboard, movable_panel, movable_panel.OffsetPoint, step_size); storyboard.Begin(); } #endregion
Processing the Queue
#region "Queue animation block" void LookForSegmentToAnimate() { if (path_segments.Count > 0) { // start one animation StartOneAnimation(); } else { // signal all animations are done if (all_animations_completed_events != null) all_animations_completed_events(this, new EventArgs()); } } void StartOneAnimation() { // dequeue and set global control points variables PathSegment path_segment = path_segments.Dequeue(); if (path_segment is BezierSegment) { var bezier_segemnt = path_segment as BezierSegment; control1 = bezier_segemnt.Point1; control2 = bezier_segemnt.Point2; anchor2 = bezier_segemnt.Point3; Action calculate_path_segment_point = CalculateNextBezierSegmentPoint; Animate(calculate_path_segment_point); } } #endregion }
SpinAnimation.xaml
<UserControl x:Class="PathAnimation.SpinAnimation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <Grid x:Name="LayoutRoot" Background="Transparent" Height="100" Width="100" HorizontalAlignment="Left" VerticalAlignment="Top"> <Grid.ColumnDefinitions> <ColumnDefinition Width="5"/> <ColumnDefinition Width="90"/> <ColumnDefinition Width="5"/> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition Height="5"/> <RowDefinition Height="90"/> <RowDefinition Height="5"/> </Grid.RowDefinitions> <Grid Grid.Column="1" Grid.Row="1"> <Ellipse Height="50" Width="50" > <Ellipse.Fill> <RadialGradientBrush GradientOrigin="0.75,0.75"> <GradientStop Offset="0.25" Color="White"/> <GradientStop x:Name="FinalOffset" Offset="1.0" Color="DarkGoldenrod"/> </RadialGradientBrush> </Ellipse.Fill> </Ellipse> <Path x:Name="pathMarkup" StrokeDashArray="53,0.75" StrokeDashOffset="0" StrokeEndLineCap="Flat" StrokeStartLineCap="Flat" StrokeThickness="5" HorizontalAlignment="Center" VerticalAlignment="Center" Stretch="UniformToFill" Data="M 0,80 A 40,40 0 0 0 80,0 A 40,40 0 0 0 0,80"> <Path.Stroke> <LinearGradientBrush StartPoint="0.5,0" EndPoint="0.5,1"> <GradientStop Color="DarkGoldenrod" Offset="0" /> <GradientStop Color="White" Offset="1"/> </LinearGradientBrush> </Path.Stroke> <Path.RenderTransform> <MatrixTransform> <MatrixTransform.Matrix> <Matrix M11="1" M12="0.5" M21="0" M22="0.5" OffsetX="0" OffsetY="0"/> </MatrixTransform.Matrix> </MatrixTransform> </Path.RenderTransform> </Path> <TextBlock Text="Loading" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="Bisque"/> </Grid> </Grid> </UserControl>
Page.xaml
<UserControl x:Class="PathAnimation.Page" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:PathAnimation;assembly=PathAnimation" Width="1000" Height="600"> <Grid x:Name="LayoutRoot" Background="Black" HorizontalAlignment="Center" VerticalAlignment="Center" Width="900" Height="500"> <Path Stretch="None" StrokeDashOffset="2000" StrokeEndLineCap="Triangle" StrokeStartLineCap="Round" StrokeThickness="5" HorizontalAlignment="Left" Margin="10,20,0,0" x:Name="path" VerticalAlignment="Top" Data="M 85,135 C 80,0 235,0 285,135 C 345,350 750,450 545,175 C365,0 705,0 715,125 C735,250 570,250 515,135 C 425,0 125,0 285,160 C 500,400 110,450 85,135" Visibility="Visible" > <Path.Stroke> <LinearGradientBrush StartPoint="0.5,0" EndPoint="0.5,1"> <GradientStop Color="DarkGoldenrod" Offset="0" /> <GradientStop Color="White" Offset="1"/> </LinearGradientBrush> </Path.Stroke> </Path> </Grid> </UserControl>
Posted in Silverlight 2 (5) | Comments (0)