WPF How you can create a beautiful wave of letters

I need to create a wave-like text object in my WPF application, and I actually assumed that there would be a β€œbend along path” type, but I did not see it at all in Blend.

I found a tutorial that says you need to convert the text to the letter of the path letter by letter, and then rotate it around, but in my opinion it is terribly terrible that there is a lot of room for errors and not enough flexibility.

I really want the proposal to have an animated ripple effect, how can I achieve this?

Thanks to all Mark

+7
text layout animation wpf
source share
3 answers

You might want to check out the Charles Petzold Render Text MSDN article on the way with WPF ( archived version here ).

wavy text

I found this article very useful, and it also offers a sample in which it uses animation.

+8
source share

What you are looking for is essentially a non-linear transformation. The Transform property in Visual can only perform linear transformations. Fortunately, WPF 3D features come to your rescue. You can easily accomplish what you are looking for by creating a simple user control that will be used as follows:

<local:DisplayOnPath Path="{Binding ...}" Content="Text to display" /> 

Here's how to do it:

First, create a custom DisplayOnPath control.

  1. Create it using a custom Visual Studio control template (make sure your assembly: ThemeInfo attribute is set correctly and all that)
  2. Add a "Path" dependency property of type Geometry (use the wpfdp snippet)
  3. Add the read-only dependency property of DisplayMesh of type Geometry3D (use the wpfdpro snippet)
  4. Add a PropertyChangedCallback for Path to call the ComputeDisplayMesh method to convert Path to Geometry3D, and then set DisplayMesh from it.

It will look something like this:

 public class DisplayOnPath : ContentControl { static DisplayOnPath() { DefaultStyleKeyProperty.OverrideMetadata ... } public Geometry Path { get { return (Geometry)GetValue(PathProperty) ... public static DependencyProperty PathProperty = ... new UIElementMetadata { PropertyChangedCallback = (obj, e) => { var displayOnPath = obj as DisplayOnPath; displayOnPath.DisplayMesh = ComputeDisplayMesh(displayOnPath.Path); })); public Geometry3D DisplayMesh { get { ... } private set { ... } } private static DependencyPropertyKey DisplayMeshPropertyKey = ... public static DependencyProperty DisplayMeshProperty = ... } 

Then create a style template and Themes/Generic.xaml controls in Themes/Generic.xaml (or the ResourceDictionary included in it), as for any custom Themes/Generic.xaml controls. The template will have the following contents:

 <Style TargetType="{x:Type local:DisplayOnPath}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type local:DisplayOnPath}"> <Viewport3DVisual ...> <ModelVisual3D> <ModelVisual3D.Content> <GeometryModel3D Geometry="{Binding DisplayMesh, RelativeSource={RelativeSource TemplatedParent}}"> <GeometryModel3D.Material> <DiffuseMaterial> <DiffuseMaterial.Brush> <VisualBrush ...> <VisualBrush.Visual> <ContentPresenter /> ... 

To do this, a 3D model is displayed that uses DisplayMesh to determine the location and uses your Content control as a brush material.

Please note that you may need to set other properties in Viewport3DVisual and VisualBrush in order for the layout to work the way you want and to visually stretch the content accordingly.

All that remains is the "ComputeDisplayMesh" function. This is a trivial display if you want the top of the content (the displayed words) to be perpendicular to a certain distance from the path. Of course, there are other algorithms that you can choose instead, for example, create a parallel path and use the percentage distance along each.

In any case, the basic algorithm is the same:

  1. Convert to PathGeometry using PathGeometry.CreateFromGeometry
  2. Select the appropriate number of rectangles in your 'n' grid using the heuristic of your choice. Maybe start with hard coding n = 50.
  3. Calculate your Positions for all corners of the rectangles. There are n + 1 angles at the top and n + 1 angles at the bottom. Each bottom corner can be found by calling PathGeometry.GetPointAtFractionOfLength . This also returns the tangent, so it's easy to find the top corner.
  4. Calculate your TriangleIndices . This is trivial. Each rectangle will consist of two triangles, so there will be six indices in each rectangle.
  5. Calculate your TextureCoordinates . This is even more trivial because they will all be 0, 1 or i / n (where I am the index of the rectangle).

Note that if you use a fixed value of n, the only thing you will ever have to recount when changing the path is the Posisions array. Everything else is fixed.

Here is what the main part of this method looks like:

 var pathGeometry = PathGeometry.CreateFromGeometry(path); int n=50; // Compute points in 2D var positions = new List<Point>(); for(int i=0; i<=n; i++) { Point point, tangent; pathGeometry.GetPointAtFractionOfLength((double)i/n, out point, out tangent); var perpendicular = new Vector(tangent.Y, -tangent.X); perpendicular.Normalize(); positions.Add(point + perpendicular * height); // Top corner positions.Add(point); // Bottom corner } // Convert to 3D by adding 0 'Z' value mesh.Positions = new Point3DCollection(from p in positions select new Point3D(pX, pY, 0)); // Now compute the triangle indices, same way for(int i=0; i<n; i++) { // First triangle mesh.TriangleIndices.Add(i*2+0); // Upper left mesh.TriangleIndices.Add(i*2+2); // Upper right mesh.TriangleIndices.Add(i*2+1); // Lower left // Second triangle mesh.TriangleIndices.Add(i*2+1); // Lower left mesh.TriangleIndices.Add(i*2+2); // Upper right mesh.TriangleIndices.Add(i*2+3); // Lower right } // Add code here to create the TextureCoordinates 

This is about it. Most of the code is written above. I leave it to you to fill in everything else.

By the way, please note that if you take a creative approach with a value of "Z", you can get really amazing effects.

Refresh

Mark implemented the code for this and ran into three problems. Here are the problems and solutions for them:

  1. I made a mistake in my TriangleIndices order for triangle # 1. This is fixed above. Initially, I had these indicators, going to the top left - bottom left - top right. Going around the triangle counterclockwise, we actually saw the back of the triangle, so nothing was drawn. Just changing the order of the indices, we rotate clockwise so that the triangle is visible.

  2. The binding to GeometryModel3D was originally a TemplateBinding binding. This did not work because TemplateBinding does not handle updates in the same way. Changing it to regular binding solved the problem.

  3. The coordinate system for 3D is + Y up, while for 2D + Y it is down, so the path turned upside down. This can be solved either by negating Y in the code, or by adding a RenderTransform to ViewPort3DVisual , as you prefer. I personally prefer RenderTransform because it makes the ComputeDisplayMesh code more readable.

Here's a snapshot of Mark's code animating a mood that I think we all share:

Snapshot of animating text "StackOverflowIsFun"
(source: rayburnsresume.com )

+36
source share

I thought I would actually post details of my progress so that we could exit the comments (which are not formatted as pleasant :))

Here is my main window:

 <Window.Resources> <Style TargetType="{x:Type local:DisplayOnPath}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type local:DisplayOnPath}"> <Viewport3D> <Viewport3D.Camera> <PerspectiveCamera FieldOfView="60" FarPlaneDistance="1000" NearPlaneDistance="10" Position="0,0,300" LookDirection="0,0,-1" UpDirection="0,1,0"/> </Viewport3D.Camera> <ModelVisual3D> <ModelVisual3D.Content> <Model3DGroup> <AmbientLight Color="#ffffff" /> <GeometryModel3D Geometry="{TemplateBinding DisplayMesh}"> <GeometryModel3D.Material> <DiffuseMaterial> <DiffuseMaterial.Brush> <SolidColorBrush Color="Red" /> </DiffuseMaterial.Brush> </DiffuseMaterial> </GeometryModel3D.Material> </GeometryModel3D> </Model3DGroup> </ModelVisual3D.Content> </ModelVisual3D> </Viewport3D> </ControlTemplate> </Setter.Value> </Setter> </Style> <Storyboard x:Key="movepath"> <PointAnimationUsingKeyFrames BeginTime="00:00:00" Storyboard.TargetName="p1" Storyboard.TargetProperty="(Path.Data).(PathGeometry.Figures)[0].(PathFigure.Segments)[4].(LineSegment.Point)"> <SplinePointKeyFrame KeyTime="00:00:01" Value="181.5,81.5"/> </PointAnimationUsingKeyFrames> <PointAnimationUsingKeyFrames BeginTime="00:00:00" Storyboard.TargetName="p1" Storyboard.TargetProperty="(Path.Data).(PathGeometry.Figures)[0].(PathFigure.Segments)[3].(LineSegment.Point)"> <SplinePointKeyFrame KeyTime="00:00:01" Value="141.5,69.5"/> </PointAnimationUsingKeyFrames> <PointAnimationUsingKeyFrames BeginTime="00:00:00" Storyboard.TargetName="p1" Storyboard.TargetProperty="(Path.Data).(PathGeometry.Figures)[0].(PathFigure.Segments)[1].(LineSegment.Point)"> <SplinePointKeyFrame KeyTime="00:00:01" Value="62.5,49.5"/> </PointAnimationUsingKeyFrames> </Storyboard> </Window.Resources> <Window.Triggers> <EventTrigger RoutedEvent="FrameworkElement.Loaded"> <BeginStoryboard Storyboard="{StaticResource movepath}"/> </EventTrigger> </Window.Triggers> <Grid x:Name="grid1"> <Path x:Name="p1" Stroke="Black" Margin="238.5,156.5,331.5,0" VerticalAlignment="Top" Height="82"> <Path.Data> <PathGeometry> <PathFigure StartPoint="0.5,0.5"> <LineSegment Point="44.5,15.5"/> <LineSegment Point="73.5,30.5"/> <LineSegment Point="91.5,56.5"/> <LineSegment Point="139.5,53.5"/> <LineSegment Point="161,80"/> </PathFigure> </PathGeometry> </Path.Data> </Path> <local:DisplayOnPath x:Name="wave1" Path="{Binding Data, ElementName=p1, Mode=Default}" /> </Grid> 

Then I have the actual user control:

 public partial class DisplayOnPath : UserControl { public MeshGeometry3D DisplayMesh { get { return (MeshGeometry3D)GetValue(DisplayMeshProperty); } set { SetValue(DisplayMeshProperty, value); } } public Geometry Path { get { return (Geometry)GetValue(PathProperty); } set { SetValue(PathProperty, value); } } public static readonly DependencyProperty DisplayMeshProperty = DependencyProperty.Register("DisplayMesh", typeof(MeshGeometry3D), typeof(DisplayOnPath), new FrameworkPropertyMetadata(new MeshGeometry3D(), FrameworkPropertyMetadataOptions.AffectsRender)); public static readonly DependencyProperty PathProperty = DependencyProperty.Register("Path", typeof(Geometry), typeof(DisplayOnPath), new PropertyMetadata() { PropertyChangedCallback = (obj, e) => { var displayOnPath = obj as DisplayOnPath; displayOnPath.DisplayMesh = ComputeDisplayMesh(displayOnPath.Path); } } ); private static MeshGeometry3D ComputeDisplayMesh(Geometry path) { var mesh = new MeshGeometry3D(); var pathGeometry = PathGeometry.CreateFromGeometry(path); int n = 50; int height = 10; // Compute points in 2D var positions = new List<Point>(); for (int i = 0; i <= n; i++) { Point point, tangent; pathGeometry.GetPointAtFractionLength((double)i / n, out point, out tangent); var perpendicular = new Vector(tangent.Y, -tangent.X); perpendicular.Normalize(); positions.Add(point + perpendicular * height); // Top corner positions.Add(point); // Bottom corner } // Convert to 3D by adding 0 'Z' value mesh.Positions = new Point3DCollection(from p in positions select new Point3D(pX, pY, 0)); // Now compute the triangle indices, same way for (int i = 0; i < n; i++) { // First triangle mesh.TriangleIndices.Add(i * 2 + 0); // Upper left mesh.TriangleIndices.Add(i * 2 + 1); // Lower left mesh.TriangleIndices.Add(i * 2 + 2); // Upper right // Second triangle mesh.TriangleIndices.Add(i * 2 + 1); // Lower left mesh.TriangleIndices.Add(i * 2 + 2); // Upper right mesh.TriangleIndices.Add(i * 2 + 3); // Lower right } for (int i = 0; i <= n; i++) { for (int j = 0; j < 2; j++) { mesh.TextureCoordinates.Add(new Point((double) i/n, j)); } } //Console.WriteLine("Positions=\"" + mesh.Positions + "\"\nTriangleIndices=\"" + mesh.TriangleIndices + // "\"\nTextureCoordinates=\"" + mesh.TextureCoordinates + "\""); return mesh; } static DisplayOnPath() { DefaultStyleKeyProperty.OverrideMetadata(typeof(DisplayOnPath), new FrameworkPropertyMetadata(typeof(DisplayOnPath))); } public DisplayOnPath() { InitializeComponent(); } } 

Currently, as it is, it does not display anything but the path.

BUT, if you get the details of the wave1 grid after the window has loaded, replace the binding with hard-coded values, you get the following: http://img199.yfrog.com/i/path1.png/

There are two main problems:

  • The triangles are all pointed, so I think the rectangles are not defined correctly
  • Canceled! But I think it has something to do with tangents
0
source share

All Articles