第二十二章:动画(十三)-云栖社区-阿里云

动画Bounds属性也许ViewExtensions类中最好奇的扩展方法是LayoutTo。参数是一个Rectangle值,第一个问题可能是:此方法的动画属性是什么? VisualElement定义的Rectangle类型的唯一属性是Bounds属性。

动画Bounds属性
也许ViewExtensions类中最好奇的扩展方法是LayoutTo。参数是一个Rectangle值,第一个问题可能是:此方法的动画属性是什么? VisualElement定义的Rectangle类型的唯一属性是Bounds属性。此属性指示元素相对于其父元素的位置及其大小,但该属性为get-only。
LayoutTo动画确实为Bounds属性设置了动画,但它通过调用Layout方法间接地动画。 Layout方法不是应用程序通常调用的方法。顾名思义,它通常在布局系统中用于相对于父母定位和调整孩子的大小。您可能有机会调用Layout的唯一时间是编写一个派生自的自定义布局类,如第26章“自定义布局”中所示。
您可能不希望将StackTo动画用于StackLayout或Grid的子项,因为动画会覆盖父项设置的位置和大小。一旦您将手机侧向翻转,页面就会经历另一个布局传递,导致StackLayout或Grid根据正常的布局过程移动并调整子项的大小,这将覆盖您的动画。
你对AbsoluteLayout的孩子有同样的问题。在LayoutTo动画完成后,如果您将手机侧向转动,AbsoluteLayout将根据子项的LayoutBounds附加可绑定属性移动并调整子项的大小。但是使用AbsoluteLayout你也可以解决这个问题:在LayoutTo动画结束之后,程序可以将子的LayoutBounds附加的可绑定属性设置为动画中指定的同一个矩形,也许使用动画设置的Bounds属性的最终设置。
但请记住,Layout方法和LayoutTo动画不知道AbsoluteLayout中的比例定位和大小调整功能。 如果使用比例定位和尺寸调整,则可能需要在比例和绝对坐标和尺寸之间进行转换。 Bounds属性始终以绝对坐标报告位置和大小。
BouncingBox程序使用LayoutTo有条不紊地在方框内部反弹BoxView。 BoxView从顶部边缘的中心开始,然后以弧形移动到右边缘的中心,然后移动到底部边缘的中心,左边缘的中心,然后返回到顶部,从那里 旅程还在继续。 当BoxView击中每个边缘时,它会逼真地压缩,然后像橡皮球一样展开:
2019_03_12_095434
代码隐藏文件使用AbsoluteLayout.SetLayoutBounds将BoxView定位在四个边缘中的每一个上,LayoutTo用于压缩和解压缩边缘,RotateTo将BoxView以弧形移动到下一个边缘。
XAML文件创建Frame,AbsoluteLayout和BoxView:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="BouncingBox.BouncingBoxPage">
    <ContentPage.Padding>
        <OnPlatform x:TypeArguments="Thickness"
                    iOS="0, 20, 0, 0" />
    </ContentPage.Padding>
    <ContentView SizeChanged="OnContentViewSizeChanged">
        <Frame x:Name="frame"
               OutlineColor="Accent"
               BackgroundColor="White"
               Padding="0"
               HorizontalOptions="Center"
               VerticalOptions="Center">
            <AbsoluteLayout SizeChanged="OnAbsoluteLayoutSizeChanged">
                <BoxView x:Name="boxView"
                         Color="Accent"
                         IsVisible="False" />
            </AbsoluteLayout>
        </Frame>
    </ContentView>
</ContentPage>

在代码隐藏文件中,ContentView的SizeChanged处理程序将Frame的大小调整为方形,而AbsoluteLayout的SizeChanged处理程序保存其动画计算的大小,并在大小看似合法时启动动画。 (如果没有此检查,动画开始得太早,并且它使用无效的AbsoluteLayout大小。)

public partial class BouncingBoxPage : ContentPage
{
    static readonly uint arcDuration = 1000;
    static readonly uint bounceDuration = 250;
    static readonly double boxSize = 50;
    double layoutSize;
    bool animationGoing;
    public BouncingBoxPage()
    {
        InitializeComponent();
    }
    void OnContentViewSizeChanged(object sender, EventArgs args)
    {
        ContentView contentView = (ContentView)sender;
        double size = Math.Min(contentView.Width, contentView.Height);
        frame.WidthRequest = size;
        frame.HeightRequest = size;
    }
    void OnAbsoluteLayoutSizeChanged(object sender, EventArgs args)
    {
        AbsoluteLayout absoluteLayout = (AbsoluteLayout)sender;
        layoutSize = Math.Min(absoluteLayout.Width, absoluteLayout.Height);
        // Only start the animation with a valid size.
        if (!animationGoing && layoutSize > 100)
        {
            animationGoing = true;
            AnimationLoop();
        }
    }
    __
}

AnimationLoop方法很冗长,但这仅仅是因为它为四个边中的每一边以及这些边之间的过渡使用单独的逻辑。 对于每一方,第一步是使用AbsoluteLayout.SetLayoutBounds定位BoxView。 然后BoxView以弧形旋转到下一侧。 这需要设置AnchorX和AnchorY属性,以便动画的中心靠近Frame的角落,但以BoxView大小为单位表示。
然后两个调用LayoutTo来激活BoxView的压缩,因为它击中了Frame的内侧,以及BoxView随后反弹的后续扩展:

public partial class BouncingBoxPage : ContentPage
{
    __
    async void AnimationLoop()
    {
        while (true)
        {
            // Initial position at top.
            AbsoluteLayout.SetLayoutBounds(boxView,
                new Rectangle((layoutSize - boxSize) / 2, 0, boxSize, boxSize));
            // Arc from top to right.
            boxView.AnchorX = layoutSize / 2 / boxSize;
            boxView.AnchorY = 0.5;
            await boxView.RotateTo(-90, arcDuration);
            // Bounce on right.
            Rectangle rectNormal = new Rectangle(layoutSize - boxSize,
                                                 (layoutSize - boxSize) / 2,
                                                 boxSize, boxSize);
            Rectangle rectSquashed = new Rectangle(rectNormal.X + boxSize / 2,
                                                   rectNormal.Y - boxSize / 2,
                                                   boxSize / 2, 2 * boxSize);
            boxView.BatchBegin();
            boxView.Rotation = 0;
            boxView.AnchorX = 0.5;
            boxView.AnchorY = 0.5;
            AbsoluteLayout.SetLayoutBounds(boxView, rectNormal);
            boxView.BatchCommit();
            await boxView.LayoutTo(rectSquashed, bounceDuration, Easing.SinOut);
            await boxView.LayoutTo(rectNormal, bounceDuration, Easing.SinIn);
            // Arc from right to bottom.
            boxView.AnchorX = 0.5;
            boxView.AnchorY = layoutSize / 2 / boxSize;
            await boxView.RotateTo(-90, arcDuration);
            // Bounce at bottom.
            rectNormal = new Rectangle((layoutSize - boxSize) / 2,
                                       layoutSize - boxSize,
                                       boxSize, boxSize);
            rectSquashed = new Rectangle(rectNormal.X - boxSize / 2,
                                         rectNormal.Y + boxSize / 2,
                                         2 * boxSize, boxSize / 2);
            boxView.BatchBegin();
            boxView.Rotation = 0;
            boxView.AnchorX = 0.5;
            boxView.AnchorY = 0.5;
            AbsoluteLayout.SetLayoutBounds(boxView, rectNormal);
            boxView.BatchCommit();
            await boxView.LayoutTo(rectSquashed, bounceDuration, Easing.SinOut);
            await boxView.LayoutTo(rectNormal, bounceDuration, Easing.SinIn);
            // Arc from bottom to left.
            boxView.AnchorX = 1 - layoutSize / 2 / boxSize;
            boxView.AnchorY = 0.5;
            await boxView.RotateTo(-90, arcDuration);
            // Bounce at left.
            rectNormal = new Rectangle(0, (layoutSize - boxSize) / 2,
                                       boxSize, boxSize);
            rectSquashed = new Rectangle(rectNormal.X,
                                         rectNormal.Y - boxSize / 2,
                                         boxSize / 2, 2 * boxSize);
            boxView.BatchBegin();
            boxView.Rotation = 0;
            boxView.AnchorX = 0.5;
            boxView.AnchorY = 0.5;
            AbsoluteLayout.SetLayoutBounds(boxView, rectNormal);
            boxView.BatchCommit();
            await boxView.LayoutTo(rectSquashed, bounceDuration, Easing.SinOut);
            await boxView.LayoutTo(rectNormal, bounceDuration, Easing.SinIn);
            // Arc from left to top.
            boxView.AnchorX = 0.5;
            boxView.AnchorY = 1 - layoutSize / 2 / boxSize;
            await boxView.RotateTo(-90, arcDuration);
            // Bounce on top.
            rectNormal = new Rectangle((layoutSize - boxSize) / 2, 0,
                                       boxSize, boxSize);
            rectSquashed = new Rectangle(rectNormal.X - boxSize / 2, 0,
                                         2 * boxSize, boxSize / 2);
            boxView.BatchBegin();
            boxView.Rotation = 0;
            boxView.AnchorX = 0.5;
            boxView.AnchorY = 0.5;
            AbsoluteLayout.SetLayoutBounds(boxView, rectNormal);
boxView.BatchCommit();
            await boxView.LayoutTo(rectSquashed, bounceDuration, Easing.SinOut);
            await boxView.LayoutTo(rectNormal, bounceDuration, Easing.SinIn);
        }
    }
}

SinOut和SinIn缓动函数为压缩提供了一点现实性,在它结束时减速,并且在它开始后扩展速度加快。
请注意对BatchBegin和BatchCommit的调用,它们围绕着BoxView在其中一个边缘定位的许多属性设置。添加这些是因为iPhone模拟器上似乎有一点闪烁,好像没有同时设置属性。然而,即使有这些电话,闪烁仍然存在。
LayoutTo动画也用于为Xamarin.Forms编写的第一批游戏之一。它是着名的15-Puzzle的一个版本,由15个瓷砖和一个四乘四网格中的一个空方块组成。瓷砖可以移动,但只能通过将瓷砖移动到空白点。
在早期的Apple Macintosh上,这个拼图被命名为Puzzle。在第一个Windows软件开发工具包中,它是唯一使用Microsoft Pascal的示例程序,它的名称为Muzzle(用于“Microsoft puzzle”)。 Xamarin.Forms的版本因此被称为Xuzzle。
Xuzzle的原始版本在这里:
https://developer.xamarin.com/samples/xamarin-forms/Xuzzle/
本章介绍的简化版本不包括奖励您成功完成拼图的动画。 然而,这个新版本的瓷砖不是显示字母或数字,而是显示心爱的Xamarin徽标的15/16,称为Xamagon,因此这个新版本称为XamagonXuzzle。 这是启动屏幕:
2019_03_12_110027
按“随机化”按钮时,图块会移动:
2019_03_12_110056
您的工作是将磁贴切换回原始配置。 您可以通过点击空方块旁边的任何瓷砖来执行此操作。 该程序应用动画将抽头的瓷砖移动到空的
正方形,空方块现在替换您点击的图块。
您也可以通过一次点击移动多个图块。 例如,假设您点击Android屏幕第三行中最右侧的图块。 该行中的第二个图块向左移动,然后第三个和第四个图块也向左移动,再次将空方块替换为您点击的图块。
15个tile的位图是专门为这个程序创建的,XamagonXuzzle项目将它们包含在Portable Class Library的Images文件夹中,所有这些都包含Embedded Resource的Build Action。
每个磁贴都是一个ContentView,它只包含一个Image,其中有一些Padding应用于您在屏幕截图中看到的磁贴之间的间隙:

class XamagonXuzzleTile : ContentView
{
    public XamagonXuzzleTile (int row, int col, ImageSource imageSource)
    {
        Row = row;
        Col = col;
        Padding = new Thickness(1);
        Content = new Image
        {
           Source = imageSource
        };
    }
    public int Row { set; get; }
    public int Col { set; get; }
}

每个磁贴都有一个初始行和列,但Row和Col属性是公共的,因此程序可以在磁贴移动时更改它们。 同样提供给XamagonXuzzleTile类的构造函数的是一个ImageSource对象,它引用一个位图资源。
XAML文件实例化Button和一个用于tile的AbsoluteLayout:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="XamagonXuzzle.XamagonXuzzlePage">
    <ContentPage.Padding>
        <OnPlatform x:TypeArguments="Thickness"
                    iOS="0, 20, 0, 0" />
    </ContentPage.Padding>
    <ContentView SizeChanged="OnContentViewSizeChanged">
        <StackLayout x:Name="stackLayout">
            <Button Text="Randomize"
                    Clicked="OnRandomizeButtonClicked"
                    HorizontalOptions="CenterAndExpand"
                    VerticalOptions="CenterAndExpand" />
            <AbsoluteLayout x:Name="absoluteLayout"
                            BackgroundColor="Black" />
 
                <!-- Balance out layout with invisible button. -->
                <Button Text="Randomize"
                        Opacity="0"
                        HorizontalOptions="CenterAndExpand"
                        VerticalOptions="CenterAndExpand" />
        </StackLayout>
    </ContentView>
</ContentPage>

正如您将看到的,ContentView的SizeChanged处理程序更改了StackLayout的方向以适应纵向和横向模式。
代码隐藏文件的构造函数实例化所有15个tile,并根据15个位图之一为每个tile赋予一个ImageSource。

public partial class XamagonXuzzlePage : ContentPage
{
    // Number of tiles horizontally and vertically,
    // but if you change it, some code will break.
    static readonly int NUM = 4;
    // Array of tiles, and empty row & column.
    XamagonXuzzleTile[,] tiles = new XamagonXuzzleTile[NUM, NUM];
    int emptyRow = NUM - 1;
    int emptyCol = NUM - 1;
    double tileSize;
    bool isBusy;
    public XamagonXuzzlePage()
    {
        InitializeComponent();
        // Loop through the rows and columns.
        for (int row = 0; row < NUM; row++)
        {
            for (int col = 0; col < NUM; col++)
            {
                // But skip the last one!
                if (row == NUM - 1 && col == NUM - 1)
                    break;
                // Get the bitmap for each tile and instantiate it.
                ImageSource imageSource = 
                    ImageSource.FromResource("XamagonXuzzle.Images.Bitmap" + 
                                             row + col + ".png");
                XamagonXuzzleTile tile = new XamagonXuzzleTile(row, col, imageSource);
                // Add tap recognition.
                TapGestureRecognizer tapGestureRecognizer = new TapGestureRecognizer
                {
                    Command = new Command(OnTileTapped),
                    CommandParameter = tile
                };
                tile.GestureRecognizers.Add(tapGestureRecognizer);
                // Add it to the array and the AbsoluteLayout.
                tiles[row, col] = tile;
                absoluteLayout.Children.Add(tile);
            }
        }
    }
    __
}

ContentView的SizeChanged处理程序负责设置StackLayout的Orientation属性,调整AbsoluteLayout的大小,以及调整和定位AbsoluteLayout中的所有tile。 请注意,每个磁贴的位置都是根据该磁贴的Row和Col属性计算的:

public partial class XamagonXuzzlePage : ContentPage
{
    __
    void OnContentViewSizeChanged(object sender, EventArgs args)
    {
        ContentView contentView = (ContentView)sender;
        double width = contentView.Width;
        double height = contentView.Height;
        if (width <= 0 || height <= 0)
            return;
        // Orient StackLayout based on portrait/landscape mode.
        stackLayout.Orientation = (width < height) ? StackOrientation.Vertical :
                                                     StackOrientation.Horizontal;
        // Calculate tile size and position based on ContentView size.
        tileSize = Math.Min(width, height) / NUM;
        absoluteLayout.WidthRequest = NUM * tileSize;
        absoluteLayout.HeightRequest = NUM * tileSize;
        foreach (View view in absoluteLayout.Children)
        {
            XamagonXuzzleTile tile = (XamagonXuzzleTile)view;
            // Set tile bounds.
            AbsoluteLayout.SetLayoutBounds(tile, new Rectangle(tile.Col * tileSize,
                                                               tile.Row * tileSize,
                                                               tileSize,
                                                               tileSize));
        }
    }
    __
}

构造函数在每个tile上设置了TapGestureRecognizer,并由OnTileTapped方法处理。 单击可能会导致最多三个图块被移位。 该作业由ShiftIntoEmpty方法处理,该方法遍历所有移位的切片并为每个切片调用AnimateTile。 该方法定义了对LayoutTo调用的Rectangle值 - 这是整个程序中唯一的动画方法 - 然后针对新配置调整其他变量:

public partial class XamagonXuzzlePage : ContentPage
{
    __
    async void OnTileTapped(object parameter)
    {
        if (isBusy)
            return;
        isBusy = true;
        XamagonXuzzleTile tappedTile = (XamagonXuzzleTile)parameter;
        await ShiftIntoEmpty(tappedTile.Row, tappedTile.Col);
        isBusy = false;
    }
    async Task ShiftIntoEmpty(int tappedRow, int tappedCol, uint length = 100)
    {
        // Shift columns.
        if (tappedRow == emptyRow && tappedCol != emptyCol)
        {
            int inc = Math.Sign(tappedCol - emptyCol);
            int begCol = emptyCol + inc;
            int endCol = tappedCol + inc;
            for (int col = begCol; col != endCol; col += inc)
            {
                await AnimateTile(emptyRow, col, emptyRow, emptyCol, length);
            }
        }
        // Shift rows.
        else if (tappedCol == emptyCol && tappedRow != emptyRow)
        {
            int inc = Math.Sign(tappedRow - emptyRow);
            int begRow = emptyRow + inc;
            int endRow = tappedRow + inc;
            for (int row = begRow; row != endRow; row += inc)
            {
                await AnimateTile(row, emptyCol, emptyRow, emptyCol, length);
            }
        }
    }
    async Task AnimateTile(int row, int col, int newRow, int newCol, uint length)
    {
        // The tile to be animated.
        XamagonXuzzleTile animaTile = tiles[row, col];
        // The destination rectangle.
        Rectangle rect = new Rectangle(emptyCol * tileSize,
                                        emptyRow * tileSize,
                                        tileSize,
                                        tileSize);
        // Animate it!
        await animaTile.LayoutTo(rect, length);
        // Set layout bounds to same Rectangle.
        AbsoluteLayout.SetLayoutBounds(animaTile, rect);
        // Set several variables and properties for new layout.
        tiles[newRow, newCol] = animaTile;
        animaTile.Row = newRow;
        animaTile.Col = newCol;
        tiles[row, col] = null;
        emptyRow = row;
        emptyCol = col;
    }
    __
}

AnimateTile方法使用await进行LayoutTo调用。如果它没有使用await - 如果它让LayoutTo动画在后台运行时继续其他工作 - 那么程序将不知道LayoutTo动画何时结束。这意味着如果ShiftIntoEmpty正在移动两个或三个瓦片,那么这些动画将同时发生而不是顺序发生。
由于AnimateTile使用await,因此该方法必须具有async修饰符。但是,如果方法返回void,则在LayoutTo动画开始时AnimateTile方法将返回,并且ShiftIntoEmpty方法再次不知道动画何时完成。因此,AnimateTile返回一个Task对象。当LayoutTo动画开始时,AnimateTile方法仍然会返回,但它返回一个Task对象,该对象可以在AnimateTile方法完成时发出信号。这意味着ShiftIntoEmpty可以使用await调用AnimateTile并按顺序移动切片。
ShiftIntoEmpty使用await,因此它也必须使用async修饰符定义,但它可能会重新变为void。如果是这样,那么ShiftIntoEmpty将在它第一次调用AnimateTile时返回,这意味着OnTileTapped方法不知道整个动画何时完成。但是OnTileTapped需要防止在已经处理动画的过程中对其进行挖掘和动画处理,这需要ShiftIntoEmpty返回Task。这意味着OnTileTapped可以使用等待ShiftIntoEmpty,这意味着OnTileTapped还必须包含async修饰符。
OnTileTapped处理程序是从Button本身调用的,因此它不能返回Task。它必须返回void,就像定义方法一样。但你可以看到await和async的使用似乎如何影响方法调用链。
一旦存在用于处理抽头的代码,实现Randomize按钮变得相当简单。
它只是以更快的动画速度多次调用ShiftIntoEmpty:

public partial class XamagonXuzzlePage : ContentPage
{
    __
    async void OnRandomizeButtonClicked(object sender, EventArgs args)
    {
        Button button = (Button)sender;
        button.IsEnabled = false;
        Random rand = new Random();
        isBusy = true;
        // Simulate some fast crazy taps.
        for (int i = 0; i < 100; i++)
        {
            await ShiftIntoEmpty(rand.Next(NUM), emptyCol, 25);
            await ShiftIntoEmpty(emptyRow, rand.Next(NUM), 25);
        }
        button.IsEnabled = true;
        isBusy = false;
    }
}

同样,使用带有ShiftIntoEmpty调用的await允许调用按顺序执行(这令人兴奋地观察)并允许OnRandomizeButtonClicked处理程序知道何时完成所有操作,以便它可以重新启用Button并允许在tile上执行点击。