Benutzerdefinierte Animationen in Silverlight

by St. Lange 9. November 2009 22:18

Bei einer Silverlight Applikation ergab sich kürzlich die Aufgabe, einen bestimmten Bereich über eine Art „Maximize Button“ so zu vergrößern, dass er sich über den ganzen im Browser zur Verfügung stehenden Platz erstreckt. Hier zunächst ein Beispiel, wie es funktionieren soll:

Beispiel ohne Animation

Die äußeren Bereiche sind hier im Beispiel nicht ganz auf 0 verkleinert, um den Effekt deutlicher zu machen. Die Implementierung ist einfach: Die Row- und ColumnDefinition Objekte werden per Code einfach mit neuen GridLength Objekten umkonfiguriert, so dass der Content Bereich den gesamten verfügbaren Platz einnimmt.

void Maximize()
{
  const int zero = 15;  // set to non-zero for demonstration purposes only

  LayoutRoot.ColumnDefinitions[0].Width = new GridLength(zero, GridUnitType.Pixel);
  LayoutRoot.ColumnDefinitions[1].Width = new GridLength(1, GridUnitType.Star);
  LayoutRoot.ColumnDefinitions[2].Width = new GridLength(zero, GridUnitType.Pixel);

  InnerLayout.RowDefinitions[0].Height = new GridLength(zero, GridUnitType.Pixel);
  InnerLayout.RowDefinitions[1].Height = new GridLength(1, GridUnitType.Star);
  InnerLayout.RowDefinitions[2].Height = new GridLength(zero, GridUnitType.Pixel);
  InnerLayout.ColumnDefinitions[0].Width = new GridLength(zero, GridUnitType.Pixel);
  InnerLayout.ColumnDefinitions[1].Width = new GridLength(1, GridUnitType.Star);
}

void Restore()
{
  LayoutRoot.ColumnDefinitions[0].Width = new GridLength(1, GridUnitType.Star);
  LayoutRoot.ColumnDefinitions[1].Width = new GridLength(700, GridUnitType.Pixel);
  LayoutRoot.ColumnDefinitions[2].Width = new GridLength(1, GridUnitType.Star);

  InnerLayout.RowDefinitions[0].Height = new GridLength(100, GridUnitType.Pixel);
  InnerLayout.RowDefinitions[1].Height = new GridLength(1, GridUnitType.Star);
  InnerLayout.RowDefinitions[2].Height = new GridLength(30, GridUnitType.Pixel);
  InnerLayout.ColumnDefinitions[0].Width = new GridLength(180, GridUnitType.Pixel);
  InnerLayout.ColumnDefinitions[1].Width = new GridLength(1, GridUnitType.Star);
}

Für den Anwender sieht das Umschalten aber nicht besonders attraktiv aus. Der zu vergrößernde Bereich sollte sich beim Maximieren nicht schlagartig ausdehnen, sondern animiert anwachsen. Entsprechendes gilt natürlich auch für ein späteres Restore in die Ursprungsgröße.

Die Aufgabe ist nun nicht ganz so einfach, da eine Animation von Row/ColumDefinition weder in Silverlight noch in WPF vorgesehen ist. Das zu animierende Objekt ist der Value Type GridLengh, für den es keine im Framework vorhandene Animation gibt. In WPF könnte man nun eigene Animation GridLengthAnimation implementieren, die man von AnimationTimeline ableitet. Wie das geht wurde schon sehr oft erklärt.

In Silverlight ist die Ableitung eigener Animationen weder vorgesehen noch möglich. Von der abstrakten Klasse Timeline sind 7 fest definierte Animationen sowie die Klasse Storyboard abgeleitet; alle Ableitungen sind sealed. Diese vordefinieren Animationen sind in Silverlight lediglich Wrapperklassen. Die eigentliche Arbeit wird von der darunterliegenden Silverlight Runtime ausgeführt, was mit ziemlicher Sicherheit aus Performance Gründen so gemacht wurde. Leitet man probeweise eine eigene Klasse von Animation ab und fügt sie in einen Storyboard ein, erhält man sofort eine COM Runtime Exception. So einfach geht es also nicht.

Alternativen

Zunächst einmal sollte man sich natürlich fragen, ob es für ein gegebenes Problem wirklich notwendig ist eine eigene Animation zu implementieren. Bei meinem konkreten Maximize/Restore Problem könnte ich ja auch anders vorgehen: Anstatt die Breiten und Höhen von Zeilen und Spalten verschiedener Grids zu animieren, wäre es auch möglich, überall den GridUnitType auf Auto zu setzen und in die entsprechenden Grid Zellen Border Objekte packen. Border Objekte besitzen die von FrameworkElement geerbten Properties Width und Height, die ganz normal mittels DoubleAnimation animiert werden können. Da die Zeilen und Spalten der Grid Objekte auf Auto stehen und sich der Größe der darin liegenden Border Objekte anpassen, führt eine Animation der Border Objekte letztlich zu einer Animation des Gesamtlayouts. Der große Nachteil dabei ist aber, dass man dann das gesamte Layout selber berechnen muss, denn so etwas Praktisches wie GridUnitType.Star stünde einem dann nicht zur Verfügung. Also scheint es doch sinnvoll zu sein, ein wenig über die Animation von GridLength Objekten nachzudenken.

Lösungsansatz in Silverlight

Glücklicherweise konnte ich mich an einen Artikel von Charles Petzold erinnern, in dem er eines seiner Beispiele aus seinem WPF Buch auf Silverlight ans Laufen gebracht hat. Er demonstriert darin, wie man die WPF Animation MatrixAnimationUsingPath unter Silverlight nachbaut (siehe hier).

Meine Klasse MaximizeRestoreAnimation funktioniert nach dieser Vorlage. Die Grundidee besteht darin, sich in das Rendering Event der Klasse CompositionTarget einzuklinken. Dieser Event wird vor dem Rendern jedes einzelnen Frames aufgerufen und dient zur Berechnung des nächsten Animationsschrittes. Hier ein Quellcodeauszug:

void OnCompositionTargetRendering(object sender, EventArgs e)
{
  // Comes here for every rendered frame, so don't waste performance if nothing is to animate
  if (!(_startAnimation || _running))
    return;

  // Prevent division by zero
  if (!Duration.HasTimeSpan)
    return;

  // Calculate progress using the storyboard Ticks
  double progress = (double)Storyboard.GetCurrentTime().Ticks / Duration.TimeSpan.Ticks;

  if (_startAnimation)
  {
    // switch to running
    _startAnimation = false;
    _running = true;
  }

  if (_maximized)
    AnimateMaximize(progress);
  else
    AnimateRestore(progress);
}

Mit Hilfe eines Storyboards wird der Fortschritt der Animation als eine Zahl zwischen 0 und 1 berechnet. Diese Zahl dient dann zum Skalieren der Breiten und Höhen der Grid Rows/Columns. Petzolds Trick besteht darin, so viel wie möglich von der vorhandenen Silverlight Infrastruktur zu nutzen. So wird beispielsweise mit Storyboard.GetCurrentTime().Ticks der aktuelle Fortschritt der Animation berechnet.

Da die Startwerte der Animationen von der aktuellen Größe des Browser Fensters abhängen, muss man diese vor dem ersten Animationsschritt zunächst ermitteln. Außerdem muss man für bestimmte Elemente den GridUnitType ändern.

void StartMaximize()
{
  _lrc0Width = MainPage.LayoutRoot.ColumnDefinitions[0].ActualWidth;
  _lrc2Width = MainPage.LayoutRoot.ColumnDefinitions[2].ActualWidth;

  MainPage.LayoutRoot.ColumnDefinitions[0].Width = new GridLength(_lrc0Width);
  MainPage.LayoutRoot.ColumnDefinitions[1].Width = new GridLength(1, GridUnitType.Star);
  MainPage.LayoutRoot.ColumnDefinitions[2].Width = new GridLength(_lrc2Width);

  MainPage.InnerLayout.RowDefinitions[0].Height = new GridLength(100);
  MainPage.InnerLayout.RowDefinitions[1].Height = new GridLength(1, GridUnitType.Star);
  MainPage.InnerLayout.RowDefinitions[2].Height = new GridLength(30);
  MainPage.InnerLayout.ColumnDefinitions[0].Width = new GridLength(180);
  MainPage.InnerLayout.ColumnDefinitions[1].Width = new GridLength(1, GridUnitType.Star);
}

Nun kann bei jedem Redering Event die Größe der einzelnen Grids entsprechend skaliert werden. Die Animation sieht dabei insgesamt noch etwas geschmeidiger aus, wenn man den linearen Zeitverlauf durch eine Ease Funktion etwas modifiziert. Mit einem PowerEase Objekt wird die Bewegung kurz vor Erreichen der Endposition leicht abgebremst. Hier exemplarisch der Code für AnimateMaximize:

void AnimateMaximize(double progress)
{
  var factor = 1 - new PowerEase { EasingMode = EasingMode.EaseOut, Power = 3 }.Ease(progress);

  MainPage.LayoutRoot.ColumnDefinitions[0].Width = new GridLength(factor * _lrc0Width);
  MainPage.LayoutRoot.ColumnDefinitions[2].Width = new GridLength(factor * _lrc2Width);

  MainPage.InnerLayout.RowDefinitions[0].Height = new GridLength(factor * 100);
  MainPage.InnerLayout.RowDefinitions[2].Height = new GridLength(factor * 30);
  MainPage.InnerLayout.ColumnDefinitions[0].Width = new GridLength(factor * 180);
}

Für Restore ist die Implementierung analog.

Hier das Endergebnis zum Ausprobieren

Fazit

Zwar ist die Lösung letzlich ein Hack, funktioniert aber wesentlich besser als beispielsweise ein Rumtricksen mit einem DispatcherTimer. Der Animationscode ist komplett in einer eigenen Klasse versteckt und zieht sich nicht durch die Code Behind Datei. Außerdem werden durch die Verwendung des Rendering Events exakt so viele Animationsschritte berechnet, wie Silverlight in der entsprechenden Zeit Frames rendert. Das sorgt für einen ruckelfreien Verlauf.

Ein weiterer Vorteil in der direkten Animation der GridLenth Objekte liegt darin, dass keinerlei besondere Vorkehrungen im XAML Code gemacht werden müssen. So kann die Animation auch nachträglich in eine bestehende Anwendung eingebaut werden und es müssen keine Hilfsobjekte (wie Border oder etwas vergleichbares) nur zum Zweck der Animation eingeführt werden.

Das gezeigte Verfahren lässt sich leicht auf andere Situationen anpassen, bei denen Datentypen animiert werden müssen, für die es keine vordefinierten Animation gibt.

Hier der gesamte Quellcode zum Downloaden:

SilverlightGridAnimation.zip (16,10 kb)

Tags:

Silverlight

Powered by BlogEngine.NET 1.6.1.0 - Impressum