Pull to refresh

Используем «лямбды» для анимации WPF

Reading time 5 min
Views 2.4K
Создать графический примитив и анимировать его, например передвинув его с точки А в точку В с постоянной скоростью – дело нехитрое. Но что если нужно расположить несколько объектов в определенной последовательности и потом их нелинейно анимировать? Для этого ни в WPF ни в Silverlight нет встроенных функций. В этом очерке я покажу, как можно создавать объекты и анимацию динамически, используя лямбда-делегаты и функции высшего порядка.


Генерация


Допустим вам потребовалось создать (и анимировать) нечто подобное:



В принципе, можно обойтись и циклами, но если есть возможность сделать все опрятнее и понятнее, «в одну строчку», то почему бы не воспользоваться? Начнем с простого – набор кружков это очевидно коллекция, поэтому создадим класс который будет хранить ссылки на все объекты:

public class LambdaCollection<T> : Collection<T> where T : DependencyObject, new()
{
  public LambdaCollection(int count) { while (count --> 0) Add(new T()); }
  ⋮
}

Пока все тихо – мы просто определили коллекцию которая ограничена по типу содержимого (должно наследовать от DependencyObject и иметь дефолтный конструктор), и добавили конструктор который создает определенное количество нужных нам объектов. А вот теперь самое интересное – мы добавляем метод, который может инициализировать свойства объектов T с помощью лямбда-делегатов:

public class LambdaCollection<T> : Collection<T> where T : DependencyObject, new()
{
  ⋮
  public LambdaCollection<T> WithProperty<U>(DependencyProperty property, Func<int, U> generator)
  {
    for (int i = 0; i < Count; ++i)
      this[i].SetValue(property, generator(i));
    return this;
  }
}

Тут следует остановиться и посмотреть, что же происходит. Во-первых это fluent interface, потому как у него в конце написано return this. Сам же он берет два параметра. Первый параметр – это то свойство, которое мы хотим изменить во всех элементах коллекции. Это серьезно упрощает жизнь так как не надо писать везде циклы. Второй параметр – это ссылка на генератор значений – то есть на функцию, которая принимает индекс элемента в коллекции и выдает значение типа U. Причем сам тим может быть чем угодно, главное чтобы он подходил свойству.

Внимание: автоматического приведения типов тут нет, поэтому если тип свойства – double, нельзя генерировать значения типа int – получите исключение.

Как это использовать? Да очень просто. Например чтобы создать десять кругов увеличивающегося размера, мы пишем следующее:

var circles = new LambdaCollection<Ellipse>(10)
  .WithProperty(WidthProperty, i => 1.5 * (i+1))
  .WithProperty(HeightProperty, i => 1.5 * (i+1));

Такое выражение позволяет нам сделать диаметр зависимым от позиции элемента. В нашем случае он будет 1.5 пикселя для самого маленького элемента, и 15 для самого большого. Причем как видно из кода, можно варьировать ширину и высоту независимо.

Поскольку изменение X и Y координат нужно часто, можно написать полезный метод который упростит задачу:

public class LambdaCollection<T> : Collection<T> where T : DependencyObject, new()
{
  ⋮
  public LambdaCollection<T> WithXY<U>(Func<int, U> xGenerator, Func<int, U> yGenerator)
  {
    for (int i = 0; i < Count; ++i)
    {
      this[i].SetValue(Canvas.LeftProperty, xGenerator(i));
      this[i].SetValue(Canvas.TopProperty, yGenerator(i));
    }
    return this;
  }
}

А теперь возьмем все это вместе и создадим ту картинку, что мы показали в начале:

int count = 20;
var circles = new LambdaCollection<Ellipse>(count)
  .WithXY(i => 100.0 + (4.0 * i * Math.Sin(i / 4.0 * (Math.PI))),
          i => 100.0 + (4.0 * i * Math.Cos(i / 4.0 * (Math.PI))))
  .WithProperty(WidthProperty, i => 1.5 * i)
  .WithProperty(HeightProperty, i => 1.5 * i)
  .WithProperty(Shape.FillProperty, i => new SolidColorBrush(
    Color.FromArgb(255, 0, 0, (byte)(255 - (byte)(12.5 * i)))));
foreach (var circle in circles)
  MyCanvas.Children.Add(circle);

Вот и все – с помощью пары методов можно очень легко создавать разные «созвездия» элементов. Теперь посмотрим на анимацию.

Анимация


Линейная анимация типа DoubleAnimation – это скучно. Гораздо интересней когда мы сами контролируем значение элемента. Если взять для примера именно этот тип, то можно очень просто переопределить его чтобы анимированное значение контролировалось нашим генератором:

public class LambdaDoubleAnimation : DoubleAnimation
{
  public Func<double, double> ValueGenerator { get; set; }
  protected override double GetCurrentValueCore(double origin, double dst, AnimationClock clock)
  {
    return ValueGenerator(base.GetCurrentValueCore(origin, dst, clock));
  }
}

Теперь у нас есть класс, который за нас делает линейную интерполяцию, а мы в свою очередь можем получить преобразованное значение и что-то с ним сделать.

Поскольку мы работаем с коллекциями, нам опять же пригодится возможность создания набора таких анимационных объектов. Вот как раз такой класс:

public class LambdaDoubleAnimationCollection : Collection<LambdaDoubleAnimation>
{
  ⋮
  public LambdaDoubleAnimationCollection(int count, Func<int, double> from, Func<int, double> to,
    Func<int, Duration> duration, Func<int, Func<double, double>> valueGenerator)
  {
    for (int i = 0; i < count; ++i)
    {
      var lda = new LambdaDoubleAnimation
      {
        From = from(i), 
        To = to(i), 
        Duration = duration(i),
        ValueGenerator = valueGenerator(i)
      };
      Add(lda);
    }
  }
  public void BeginApplyAnimation(UIElement [] targets, DependencyProperty property)
  {
    for (int i = 0; i < Count; ++i)
      targets[i].BeginAnimation(property, Items[i]);
  }
}

Конструкторов несколько, выше показан только один из них. Параметры – это генераторы значений, то есть все параметры анимации тоже могут быть производными от позиции элемента в коллекции. Параметр valueGenerator ожидает функцию 2го порядка, или «функцию-генератор функций», то есть генератор, который зависит от индекса в коллекции и значение которого зависит от интерполированного double значения во время анимации. На языке C# это означает что сюда нужно передавать «двойную лямбду», например i => j => f(j).

Вот простой пример: разворачиваем нашу спираль в синусоиду:

var c = new LambdaDoubleAnimationCollection(
  circles.Count, 
  i => 10.0 * i, 
  i => new Duration(TimeSpan.FromSeconds(2)),
  i => j => 100.0 / j);
c.BeginApplyAnimation(circles.Cast<UIElement>().ToArray(), Canvas.LeftProperty);

Единственная неприятность тут – это отсутствие ковариантности. Именно из-за этого мы не можем передать круги в качестве параметра – приходится конвертировать в UIElement[]. А массив выбран именно потому, что хочется сразу узнать длину – хотя можно было бы и IEnumerable использовать.

Саму анимацию не показать, вот ее конечная фаза – вид спирали «с боку».



Расширения


Расширять наш небольшой фреймворк просто. Наши элементы анимируются параллельно, а нам нужно чтобы все было последовательно? Нет проблем – просто чуть-чуть меняем LambdaDoubleAnimationCollection:

public class LambdaDoubleAnimationCollection : Collection<LambdaDoubleAnimation>
{
  ⋮
  public void BeginApplyAnimation(UIElement [] targets, DependencyProperty property)
  {
    for (int i = 0; i < Count; ++i)
    {
      Items[i].BeginTime = new TimeSpan(0);
      targets[i].BeginAnimation(property, Items[i]);
    }
  }
  public void BeginSequentialAnimation(UIElement[] targets, DependencyProperty property)
  {
    TimeSpan acc = new TimeSpan(0);
    for (int i = 0; i < Items.Count; ++i)
    {
      Items[i].BeginTime = acc;
      acc += Items[i].Duration.TimeSpan;
    }
    for (int i = 0; i < Count; ++i)
    {
      targets[i].BeginAnimation(property, Items[i]);
    }
  }
}

Так же и с другими элементами. Удачи!

St. Petersburg ALT.NET Group
Tags:
Hubs:
+35
Comments 18
Comments Comments 18

Articles