まめ - たんたんめん

備忘録 C# / WPF 多め

WPF StoryBoardを使ったアニメーションの拡張

お久しぶりです。久々にやる気になったので技術記事を書きます。 今日はWPFのアニメーションをカスタマイズしてちょっとリッチな表現ができる機能を紹介しようとおもいます。 入門記事やシンプルな組み込み型の記事についてはいろんなブログで紹介されていたりしますが踏み込んだ内容が少なかったので思い切って書いてみる事にしました。 今日扱うのはEasing関数というものです。

本記事で利用するライブラリについて

StoryBoardについて

    <Window.Resources>
        <Storyboard x:Key="Rotate">
            <DoubleAnimation Storyboard.TargetName="Fa5"
                             Storyboard.TargetProperty="Angle"
                             From="0"
                             To="360"
                             RepeatBehavior="Forever"
                             Duration="0:0:1"/>
        </Storyboard>    
    </Window.Resources>
    
    <fontAwesome5:GeometryIcon x:Name="Fa5"
               Foreground="LightBlue" 
               Background="Transparent" 
               VerticalAlignment="Center"
               Icon="Icon_Twitter_brands">

        <!--今回は Loadedにトリガーして再生する-->
        <b:Interaction.Triggers>
            <b:EventTrigger EventName="Loaded">
                <b:ControlStoryboardAction Storyboard="{StaticResource Rotate}"/>
            </b:EventTrigger>
        </b:Interaction.Triggers>

WPF ではアニメーションの仕組みとしてStoryBoardというものがありまして、ここにアニメーションを追加していくような感じです。 使い方に癖があるのですがEventにTriggerしたりして使うケースが多いと思います。 上の例で何をやっているのかというとアイコンを永遠にグルグル回しています。 ぱっとみこれで良さそうなのですが、FromとToを指定して自動的に線形補間されてしまうのでちょっと機械的な回転になってしまい、 角度を指定して20度づつ動かして~みたいなことはちょっとひと手間要ります。 なので今回はこれをどう実現するか考えてみます。

実装例 1

<Storyboard
    BeginTime="00:00:00"
    RepeatBehavior="Forever"
    Storyboard.TargetName="WaitCanvas" 
    Storyboard.TargetProperty="(Canvas.RenderTransform).(TransformGroup.Children)[0].(RotateTransform.Angle)">
    <DoubleAnimationUsingKeyFrames Duration="0:0:2">
        <DoubleKeyFrameCollection>
            <DiscreteDoubleKeyFrame KeyTime="0:0:0.000" Value="0" />
            <DiscreteDoubleKeyFrame KeyTime="0:0:0.125" Value="22.5" />
            <DiscreteDoubleKeyFrame KeyTime="0:0:0.250" Value="45" />
            <DiscreteDoubleKeyFrame KeyTime="0:0:0.375" Value="67.5" />
            <DiscreteDoubleKeyFrame KeyTime="0:0:0.500" Value="90" />
            <DiscreteDoubleKeyFrame KeyTime="0:0:0.625" Value="110.5" />
            <!-- ... -->
        </DoubleKeyFrameCollection>
    </DoubleAnimationUsingKeyFrames>
</Storyboard>

https://stackoverflow.com/questions/1544411/wpf-doubleanimation-animate-in-stepsより引用

何をやっているのかというと回転の値をTo,Fromで指定するのでは無く、何秒になったら値はどうするといったようにキーフレームでアニメーションさせています。 これでもよいのですがxamlの記述量も多いし、管理もしにくいので微妙ではありますが、単体利用とかならこれで十分でしょう。

実装例 2

次にEasingを使って拡張する方法を紹介します。まずはxamlから。

<b:ControlStoryboardAction.Storyboard>
    <Storyboard>
        <DoubleAnimation Storyboard.TargetName="TargetRight"
                                        Storyboard.TargetProperty="Angle"
                                        From="0"
                                        To="360"
                                        RepeatBehavior="Forever"
                                        Duration="0:0:1"
                                        EasingFunction="{markupEx:SnapEasing 8}"/>
    </Storyboard>
</b:ControlStoryboardAction.Storyboard>

ぱっとみ最初のxamlとほとんど違いが無いように見えますがEasingFunction="{markupEx:SnapEasing 8}"が指定されています。 これが何かを簡単に言うと線形補間に利用される正規化されたtの値を変形させることができます。概念コードですがこんな感じのイメージです。

t = easing(T);
val = (1 - t) * x + t * y;

Easingに値を設定するにはIEasingFunctionのinterfaceを実装する必要があります。(といっても関数一つだけ)

今回はこのようなクラスを用意しました。

public class SnapEasing : IEasingFunction
    {
        private readonly int _step;
        private readonly double[] _table;

        public SnapEasing(int step)
        {
            if(step <= 0)
                throw new ArgumentException(nameof(step));

            _step = step;
            _table = new double[step + 1];
            _table[0] = 0d;
            _table[step] = 1d;

            // Constructorで初期化時にstep毎テーブルを作成します。
            var unit = 1.0d / step;
            for(var i = 1; i < step; ++i)
                _table[i] = i * unit;
        }
        
        public double Ease(double t)
        {
            // テーブルの中から一番近い値を選択します。
            // note :この実装では_tableが増えたときにコストが高くなる
            return _table.OrderBy(x => Math.Abs(t - x)).First();
        }
    }

このクラスではstepをコンストラクタで受取り線形補間な t を変換をテーブルへの参照に変更しています。 例えば、step を 8にすると アニメーションに利用する8コマをコンストラクタで決定し、計算時に一番値の近いコマを持ってくるような形です。

次にxamlから参照をするためにマークアップ拡張を書きます。 マークアップ拡張は無くてもよいですが、個人的にxamlを薄くしたかったので用意しました。

    public class SnapEasingExtension : MarkupExtension
    {
        private readonly int _step;
        public SnapEasingExtension(int step)
        {
            _step = step;
        }
        
        public override object ProvideValue(IServiceProvider serviceProvider)
        {
            return new SnapEasing(_step);
        }
    }

この2つを用意することでxamlから簡単に参照ができるようになったのでEasingFunctionにマークアップ拡張で書きます。

    <Storyboard>
        <DoubleAnimation Storyboard.TargetName="TargetRight"
                                        Storyboard.TargetProperty="Angle"
                                        From="0"
                                        To="360"
                                        RepeatBehavior="Forever"
                                        Duration="0:0:1"
                                        EasingFunction="{markupEx:SnapEasing 8}"/>
    </Storyboard>

これで何コマに落とすかxamlから指定して落として再生できるようになりました。

おまけ

筆者は本来ののEasingともコンポジットで組み合わせれるようにもできます。

    public class SnapEasing : IEasingFunction
    {
        private readonly int _step;
        private readonly double[] _table;
        private readonly IEasingFunction? _easingFunction;

        public SnapEasing(int step , IEasingFunction? easingFunction)
        {
            if(step <= 0)
                throw new ArgumentException(nameof(step));

            _step = step;
            _easingFunction = easingFunction;
            _table = new double[step + 1];
            _table[0] = 0d;
            _table[step] = 1d;

            var unit = 1.0d / step;
            for(var i = 1; i < step; ++i)
                _table[i] = i * unit;
        }
        
        public double Ease(double t)
        {
            // easingが設定されればeasingする
            if(_easingFunction != null)
                t = _easingFunction.Ease(t);
            return _table.OrderBy(x => Math.Abs(t - x)).First();
        }
    }

結果

動画なのでそもそもコマ落ちしちゃってて違いがわかりづらいですが一応、 最初はgifで撮ったのですがめちゃくちゃわかりづらかったのでmp4で撮ってtwitterに載せています。

追記

アイキャッチhttps://github.com/p4j4dyxcry/YiSA.FontAwesome5.WPF/blob/main/resources/screenshots/2.gif?raw=true