I have the need to create a wavey looking text object in my WPF app, and I was actually assuming that there would be a "bend along a pa开发者_StackOverflow中文版th" type of options, but I dont see one at all in Blend.
I found a tutorial that suggest you need to convert you text to a path letter by letter then rotate it around, but that is totally horrible in my opinion, to much room for error and not enough flexibility.
I essentially want a sentence to have an animated wave effect, how can I achieve this?
Thanks all Mark
What you're looking for is effectively a non-linear transform. The Transform property on Visual can only do linear transforms. Fortunately WPF's 3D features come to your rescue. You can easily accomplish what you are looking for by creating a simple custom control that would be used like this:
<local:DisplayOnPath Path="{Binding ...}" Content="Text to display" />
Here is how to do it:
First create the "DisplayOnPath" custom control.
- Create it using Visual Studio's custom control template (making sure your assembly:ThemeInfo attribute is set correctly and all that)
- Add a dependency property "Path" of type
Geometry
(use wpfdp snippet) - Add a read-only dependency property "DisplayMesh" of type
Geometry3D
(use wpfdpro snippet) - Add a
PropertyChangedCallback
for Path to call a "ComputeDisplayMesh" method to convert the Path to a Geometry3D, 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 = ...
}
Next create the style and control template in Themes/Generic.xaml
(or a ResourceDictionary
included by it) as for any custom control. The template will have contents like this:
<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 />
...
What this does is display a 3D model that uses a DisplayMesh for location and uses your control's Content as a brush material.
Note that you may need to set other properties on the Viewport3DVisual and VisualBrush to get the layout to work the way you want and for the content visual to be stretched appropriately.
All that is left is the "ComputeDisplayMesh" function. This is a trivial mapping if you want the top of the content (the words you are displaying) to be perpendicular a certain distance out from the path. Of course, there are other algorithms you might choose instead, such as to create a parallel path and use percent distance along each.
In any case, the basic algorithm is the same:
- Convert to
PathGeometry
usingPathGeometry.CreateFromGeometry
- Select an appropriate number of rectangles in your mesh, 'n', using a heuristic of your choice. Maybe start with hard-coding n=50.
- Compute your
Positions
values for all the corners of the rectangles. There are n+1 corners on top and n+1 corners on the bottom. Each bottom corner can be found by callingPathGeometry.GetPointAtFractionOfLength
. This also returns a tangent, so it is easy to find the top corner as well. - Compute your
TriangleIndices
. This is trivial. Each rectangle will be two triangles, so there will be six indices per rectangle. - Compute your
TextureCoordinates
. This is even more trivial, because they will all be 0, 1, or i/n (where i is the rectangle index).
Note that if you are using a fixed value of n, the only thing you ever have to recompute when the path changes is the Posisions
array. Everything else is fixed.
Here is the 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(p.X, p.Y, 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
That's about it. Most of the code is written above. I leave it to you to fill in the rest.
By the way, note that by being creative with the 'Z' value, you can get some truly awesome effects.
Update
Mark implemented the code for this and encountered three problems. Here are the problems and the solutions for them:
I made a mistake in my TriangleIndices order for triangle #1. It is corrected above. I originally had those indices going upper left - lower left - upper right. By going around the triangle counterclockwise we actually saw the back of the triangle so nothing was painted. By simply changing the order of the indices we go around clockwise so the triangle is visible.
The binding on the GeometryModel3D was originally a
TemplateBinding
. This didn't work because TemplateBinding doesn't handle updates the same way. Changing it to a regular binding fixed the problem.The coordinate system for 3D is +Y is up, whereas for 2D +Y is down, so the path appeared upside-down. This can be solved by either negating Y in the code or by adding a
RenderTransform
on theViewPort3DVisual
, as you prefer. I personally prefer the RenderTransform because it makes the ComputeDisplayMesh code more readable.
Here is a snapshot of Mark's code animating a sentiment I think we all share:
(source: rayburnsresume.com)
You might want to check out Charles Petzold's MSDN article Render Text On A Path With WPF (archived version here).
I have found this article very useful and he also provides a sample where he uses animation.
I thought I would actually post the details of my progress so that we can get out of the comments (which dont format as nice :))
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(p.X, p.Y, 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();
}
}
At the moment as is, this does not render anything other than the path.
BUT if you get the mesh details of wave1
after the window has loaded, then replace the binding to be hard coded values, you get this: http://img199.yfrog.com/i/path1.png/
Which has 2 main problems as it is:
- The triangles are all pointy, so I think the rectangles are not being defined correctly
- Its reversed! But I think thats got something to do with the tangents
精彩评论