第二十二章:动画(二十)

实现贝塞尔动画
一些图形系统实现动画,该动画沿着贝塞尔曲线移动视觉对象,甚至(可选地)旋转视觉对象,使其保持与曲线相切。
Bezier曲线以法国工程师兼数学家PierreBézier的名字命名,他在雷诺工作期间开发了用于汽车车身交互式计算机辅助设计的曲线。 曲线是一种由起点和终点以及两个控制点定义的样条曲线。 曲线通过起点和终点,但通常不是两个控制点。 相反,控制点的功能类似于“磁铁”,可以将曲线拉向它们。
在其二维形式中,贝塞尔曲线在数学上表示为一对参数三次方程。 这是Xamarin.FormsBook.Toolkit库中的BezierSpline结构:

namespace Xamarin.FormsBook.Toolkit
{
    public struct BezierSpline
    {
        public BezierSpline(Point point0, Point point1, Point point2, Point point3)
            : this()
        {
            Point0 = point0;
            Point1 = point1;
            Point2 = point2;
            Point3 = point3;
        }
        public Point Point0 { private set; get; }
        public Point Point1 { private set; get; }
        public Point Point2 { private set; get; }
        public Point Point3 { private set; get; }
        public Point GetPointAtFractionLength(double t, out Point tangent)
        {
            // Calculate point on curve.
            double x = (1 - t) * (1 - t) * (1 - t) * Point0.X +
                        3 * t * (1 - t) * (1 - t) * Point1.X +
                        3 * t * t * (1 - t) * Point2.X +
                        t * t * t * Point3.X;
            double y = (1 - t) * (1 - t) * (1 - t) * Point0.Y +
                        3 * t * (1 - t) * (1 - t) * Point1.Y +
                        3 * t * t * (1 - t) * Point2.Y +
                        t * t * t * Point3.Y;
            Point point = new Point(x, y);
            // Calculate tangent to curve.
            x = 3 * (1 - t) * (1 - t) * (Point1.X - Point0.X) +
                6 * t * (1 - t) * (Point2.X - Point1.X) +
                3 * t * t * (Point3.X - Point2.X);
            y = 3 * (1 - t) * (1 - t) * (Point1.Y - Point0.Y) +
                6 * t * (1 - t) * (Point2.Y - Point1.Y) +
                3 * t * t * (Point3.Y - Point2.Y);
            tangent = new Point(x, y);
            return point;
        }
    }
}

点0和点3点是起点和终点,而点1和点2是两个控制点。
GetPointAtFractionLength方法返回曲线上与t值相对应的点,范围从0到1.此方法中x和y的第一次计算涉及Bezier曲线的标准参数方程。当t为0时,曲线上的点为Point0,当t为1时,曲线上的点为Point3。
GetPointAtFractionLength还基于曲线的一阶导数对x和y进行第二次计算,因此这些值表示该点处曲线的正切。通常,我们将切线视为接触曲线但不与其相交的直线,因此将切线表示为另一点可能看起来很奇怪。但这不是一个重点。它是从点(0,0)到点(x,y)的方向上的向量。通过使用反正切函数(也称为rctangent)可以将该向量转换为旋转角度,并且最方便地为.NET程序员提供Math.Atan2,它具有两个参数y和x,并且返回弧度的角度。您需要转换为度数来设置Rotation属性。
Xamarin.FormsBook.Toolkit库中的BezierPathTo方法通过调用Layout方法移动目标可视元素,这意味着BezierPathTo类似于LayoutTo。该方法还可以通过设置其Rotation属性来选择性地旋转元素。 BezierPathTo不是将作业分成两个子动画,而是在单个动画的回调方法中完成所有操作。
假设贝塞尔曲线的起点是动画所针对的视觉元素的中心。 BezierPathTo方法需要两个控制点和一个终点。从贝塞尔曲线生成的所有点也被假定为引用视觉元素的中心,因此必须将点调整为元素宽度和高度的一半:

namespace Xamarin.FormsBook.Toolkit
{
    public static class MoreViewExtensions
    {
        __
        public static Task<bool> BezierPathTo(this VisualElement view, 
                                                 Point pt1, Point pt2, Point pt3, 
                                                 uint length = 250, 
                                                 BezierTangent bezierTangent = BezierTangent.None,
                                                 Easing easing = null)
        {
            easing = easing ?? Easing.Linear;
            TaskCompletionSource<bool> taskCompletionSource = new TaskCompletionSource<bool>();
            WeakReference<VisualElement> weakViewRef = new WeakReference<VisualElement>(view);
            Rectangle bounds = view.Bounds;
            BezierSpline bezierSpline = new BezierSpline(bounds.Center, pt1, pt2, pt3);
            Action<double> callback = t =>
                {
                    VisualElement viewRef;
                    if (weakViewRef.TryGetTarget(out viewRef))
                    {
                        Point tangent;
                        Point point = bezierSpline.GetPointAtFractionLength(t, out tangent);
                        double x = point.X - bounds.Width / 2;
                        double y = point.Y - bounds.Height / 2;
                        viewRef.Layout(new Rectangle(new Point(x, y), bounds.Size));
                        if (bezierTangent != BezierTangent.None)
                        {
                            viewRef.Rotation = 180 * Math.Atan2(tangent.Y, tangent.X) / Math.PI;
                            if (bezierTangent == BezierTangent.Reversed)
                            {
                                viewRef.Rotation += 180;
                            }
                        }
                    }
                };
            Animation animation = new Animation(callback, 0, 1, easing);
            animation.Commit(view, "BezierPathTo", 16, length, 
                finished: (value, cancelled) => taskCompletionSource.SetResult(cancelled));
            return taskCompletionSource.Task;
        }
        public static void CancelBezierPathTo(VisualElement view)
        {
            view.AbortAnimation("BezierPathTo");
        }
        __
    }
}

然而,应用旋转角度仍然有点棘手。 如果定义贝塞尔曲线的点使得曲线在屏幕上从左向右大致,则切线是也从左到右的矢量,并且动画元素的旋转应该保持其方向。 但是如果贝塞尔曲线的点从右到左,那么切线也是从右到左,并且数学要求元素应该翻转180度。
为了控制目标元素的方向,定义了一个微小的枚举:

namespace Xamarin.FormsBook.Toolkit
{
    public enum BezierTangent
    {
        None,
        Normal,
        Reversed
    }
}

BezierPathTo动画使用它来控制切线角度应用于Rotation属性的方式。
BezierLoop程序演示了BezierPathTo的使用。 按钮位于AbsoluteLayout的左上角:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="BezierLoop.BezierLoopPage">
    <ContentPage.Padding>
        <OnPlatform x:TypeArguments="Thickness"
                    iOS="0, 20, 0, 0" />
    </ContentPage.Padding>
    <AbsoluteLayout>
        <Button Text="Click for Loop"
                Clicked="OnButtonClicked" />
    </AbsoluteLayout>
</ContentPage>

Button的Clicked处理程序首先计算Bezier曲线的起点和终点以及两个控制点。 起点是Button最初所在的左上角。 终点是右上角。 两个控制点分别是右下角和左下角。 这种类型的配置实际上在Bezier曲线中创建了一个循环:

public partial class BezierLoopPage : ContentPage
{
    public BezierLoopPage()
    {
        InitializeComponent();
    }
    async void OnButtonClicked(object sender, EventArgs args)
    {
        Button button = (Button)sender;
        Layout parent = (Layout)button.Parent;
        // Center of Button in upper-left corner.
        Point point0 = new Point(button.Width / 2, button.Height / 2);
        // Lower-right corner of page.
        Point point1 = new Point(parent.Width, parent.Height);
        // Lower-left corner of page.
        Point point2 = new Point(0, parent.Height);
        // Center of Button in upper-right corner.
        Point point3 = new Point(parent.Width - button.Width / 2, button.Height / 2);
        // Initial angle of Bezier curve (vector from Point0 to Point1).
        double angle = 180 / Math.PI * Math.Atan2(point1.Y - point0.Y, 
                                                point1.X - point0.X);
        await button.RotateTo(angle, 1000, Easing.SinIn);
        await button.BezierPathTo(point1, point2, point3, 5000, 
                                    BezierTangent.Normal, Easing.SinOut);
        await button.BezierPathTo(point2, point1, point0, 5000, 
                                    BezierTangent.Reversed, Easing.SinIn);
        await button.RotateTo(0, 1000, Easing.SinOut);
    }
}

Bezier曲线最初的切线是从point0到point1的直线。 这是方法计算的角度变量,因此它可以首先使用RotateTo旋转Button以避免在BezierPathTo动画开始时跳转。 第一个BezierPathTo将Button从左上角移动到右上角,在屏幕底部附近有一个循环:
第二十二章:动画(二十)
然后第二个BezierPathTo将行程反转回左上角。 (这是BezierTangent枚举发挥作用的地方。没有它,当第二个BezierPathTo开始时,Button会突然翻转。)最终的RotateTo将其恢复到原始方向。