Een WPF StoryBoard starten vanuit het MVVM Viewmodel

 

In need of an English translation? Please drop me a note.

Sinds enige tijd werk ik aan een project waarbij de UI volledige in WPF4 is geschreven. Hierbij heb ik mezelf het doel gesteld om de architectuur volledig op MVVM te baseren.

Dit betekent concreet dat de schermen (de Views) geen code-behind hebben maar via een ViewModel interactie hebben met het Model. Alle schermwijzigingen worden met bindings en commands afgehandeld.

Zelf MVVM implementeren kan vast wel maar er zijn ook veel frameworks: grote, kleine, goede en foute… Uiteindelijk ben ik bij MVVMLight uitgekomen.

Ontwikkelen in WPF blijft door het gebruik van MVVM een feest, ondanks de steile leercurve maar dankzij de strikte scheiding tussen de te tonen data (ViewModel) en de representatie (XAML).

En ik ben ook begonnen met het gebruik van StoryBoards. Dit zijn notaties om de vorm verschijning en plaats van Xaml objecten aan te passen op een tijdslijn. Zo is het mogelijk om animaties in de UI te verwerken die wel degelijk van functioneel nut kunnen zijn.

StoryBoards worden bij voorkeur aan gebeurtenissen gehangen. Als voorbeeld toon ik hier hoe een plaatje langzaam zichtbaar wordt bij het starten van de applicatie door de Opacity in vijf seconden van 0 naar 1 te laten lopen.

Hieronder is de XAML beschreven en de code in het viewmodel. Dit zijn Snippets die zo te combineren zijn met een MvvmLight WPF4 template project.

<Grid x:Name="LayoutRoot"
      Width="284">
  <TextBox FontSize="36"
           FontWeight="Bold"
           Foreground="Purple"
           Text="{Binding WelcomeTitle}"
           VerticalAlignment="Center"
           HorizontalAlignment="Center"
           TextWrapping="Wrap"
           Margin="12,12,42,100" />
  <Image Height="51"
         Name="image"
         Source="Assets\troll-face.png"
         Opacity="0.5"
         Stretch="Uniform"
         HorizontalAlignment="Left"
         Margin="178,182,0,0"
         VerticalAlignment="Top"
         Width="61">
    <Image.Triggers>
      <EventTrigger RoutedEvent="Image.Loaded">
        <BeginStoryboard Name="MyBeginStoryboard">
          <Storyboard>
            <DoubleAnimation Storyboard.TargetProperty="Opacity"
                             From="0"
                             To="1"
                             Duration="0:0:5" />
          </Storyboard>
        </BeginStoryboard>
      </EventTrigger>
    </Image.Triggers>
  </Image>
</Grid>

Er staat een Textbox op de pagina voor de invoer van een string en een plaatje. Dit plaatje bevat een StoryBoard dit wordt afgevuurd als de pagina wordt geladen voor vertoning.

De Textbox is gebonden aan de WelcomeTitle op het ViewModel.

public class MainViewModel : ViewModelBase
{
  public MainViewModel()
  {
    WelcomeTitle = "Hello";
  }

  public const string WelcomeTitlePropertyName = "WelcomeTitle";
  private string _welcomeTitle = string.Empty;
  public string WelcomeTitle
  {
    get
    {
      return _welcomeTitle;
    }
    set
    {
      if (_welcomeTitle == value)
      {
        return;
      }

      _welcomeTitle = value;

      RaisePropertyChanged(WelcomeTitlePropertyName);
    }
  }
}

De uiteindelijke functionaliteit is eenvoudig. Indien de Textbox wordt verlaten zal de Getter van WelcomeTitle uitgevoerd worden.

Het scherm zier er zo uit:

Mijn wens is dat het Troll-plaatje pas getoond wordt als de textbox wordt verlaten en de WelcomeText korter dan drie karakters is.

Maar door de MVVM ‘belemmering’ (ik kan geen (visuele) XAML onderdelen direct vanuit code aanraken en aanspreken) wordt het lastig om StoryBoards aan te spreken. Een Command werkt ook niet want die vuurt juist in de richting van het viewmodel af (bv. indien de gebruiker een knop indruk of via de MVVMLight EventToCommand).

Ik moet iets anders gebruiken en het antwoord is: DataTriggers.

Het is mogelijk om de XAML naar wijzigingen op het ViewModel te laten luisteren en hierop te laten reageren.

Wat heb ik nodig?

  1. Een bindable (boolean) property op het ViewModel. Hiervoor voeg ik de “ShowTroll” property toe aan het ViewModel.
  2. Op het Viewmodel moet de nieuwe property van waarde kunnen veranderen. Dit gebeurt in de Setter van de WelcomeTitle. Als een waarde met een lengte kleiner dan drie karakters voorbij komt, dan wordt de ShowTroll op True gezet.
  3. Een Button in de Xaml. Deze doet op zich niets maar kan wel de Focus krijgen. Als deze aangeraakt wordt, zal de TextBox de focus verliezen en wordt de Setter van WelcomeTitle uitgevoerd. Een dummy dus.
  4. Een DataTrigger op de Image, gekoppeld aan de ShowTroll boolean
  5. Twee StoryBoards. Als de ShowTroll op true wordt gezet, zal de ene afgespeeld worden en anders de andere.

Dit betekent dus dat het viewmodel met een ShowTroll property wordt  uitgebreid en dat deze verandert in de Setter van de WelcomeTitle property:

public class MainViewModel : ViewModelBase
{
  public MainViewModel()
  {
    WelcomeTitle = "Hello";
  }

  public const string WelcomeTitlePropertyName = "WelcomeTitle";
  private string _welcomeTitle = string.Empty;
  public string WelcomeTitle
  {
    get
    {
      return _welcomeTitle;
    }
    set
    {
      if (_welcomeTitle == value)
      {
        return;
      }

      _welcomeTitle = value;
      ShowTroll = (_welcomeTitle.Length < 3); // Force data change
      RaisePropertyChanged(WelcomeTitlePropertyName);
    }
  }

  public const string ShowTrollPropertyName = "ShowTroll";
  private bool _ShowTroll = false;
  public bool ShowTroll
  {
    get
    {
      return _ShowTroll;
    }
    set
    {
      if (_ShowTroll == value)
      {
        return;
      }

      _ShowTroll = value;
      RaisePropertyChanged(ShowTrollPropertyName);
    }
  }
}

En er wordt dus in de Xaml een Button toegevoegd en de Image wordt iets aangepast. Zie dat de Opacity standaard nul (volledige doorzichtig) is. En er zijn twee StoryBoards om de Opacity binnen twee seconden op één of op nul te brengen.

De DataTrigger is aan de ShowTroll property gekoppeld en reageert zowel op True als op False.

<Grid x:Name="LayoutRoot"
      Width="284">
  <TextBox FontSize="36"
           FontWeight="Bold"
           Foreground="Purple"
           Text="{Binding WelcomeTitle}"
           VerticalAlignment="Center"
           HorizontalAlignment="Center"
           TextWrapping="Wrap"
           Margin="12,12,42,100" />
  <Button Content="Button"
          Height="23"
          HorizontalAlignment="Left"
          Margin="31,226,0,0"
          Name="button1"
          VerticalAlignment="Top"
          Width="75" />
  <Image Height="51"
         Name="image"
         Source="Assets\troll-face.png"
         Opacity="0"
         Stretch="Uniform"
         HorizontalAlignment="Left"
         Margin="178,182,0,0"
         VerticalAlignment="Top"
         Width="61">
    <Image.Resources>
      <Storyboard x:Key="TrollBeginStoryboard">
        <DoubleAnimation Storyboard.TargetProperty="Opacity"
                         From="0"
                         To="1"
                         Duration="0:0:2" />
      </Storyboard>
      <Storyboard x:Key="TrollEndStoryboard">
        <DoubleAnimation Storyboard.TargetProperty="Opacity"
                         From="1"
                         To="0"
                         Duration="0:0:2" />
      </Storyboard>
    </Image.Resources>
    <Image.Style>
      <Style>
        <Style.Triggers>
          <DataTrigger Binding="{Binding ShowTroll}"
                       Value="true">
            <DataTrigger.EnterActions>
              <BeginStoryboard>
                <Storyboard>
                  <StaticResource ResourceKey="TrollBeginStoryboard" />
                </Storyboard>
              </BeginStoryboard>
            </DataTrigger.EnterActions>
            <DataTrigger.ExitActions>
              <BeginStoryboard>
                <Storyboard>
                  <StaticResource ResourceKey="TrollEndStoryboard" />
                </Storyboard>
              </BeginStoryboard>
            </DataTrigger.ExitActions>
          </DataTrigger>
        </Style.Triggers>
      </Style>
    </Image.Style>
  </Image>
</Grid>

Hiervoor gebruiken we zowel de EnterActions als de ExitActions.

Dit geeft dus als effect dat als er een langere tekst is ingevoerd, de ‘troll’ niet zichtbaar is.

Maar als de gebruiker een tekst korter dan drie karakters invult, dan wordt binnen twee seconden de Troll zichtbaar.

En indien de tekst weer langer wordt gemaakt, dan is de Troll weer verstopt.

Ik wil in dit voorbeeld aangeven dat het relatief eenvoudig is om complexe UI veranderingen door te voeren door slecht een property op een ViewModel te manipuleren. Hiermee wordt het opeens mogelijk om binnen StoryBoards meerdere objecten te wijzigen. Dit is oogsnoep voor de gebruiker en zo wordt het een visueel feestje voor zowel de ontwikkelaar (of moet ik zeggen: Interactive Designer) en de gebruiker.

Advertenties

Integratie van Knockout in Asp.Net MVC3


In need of an English translation? Please drop me a note!

Wie door mijn blog heen bladert ziet dat ik < understatement >redelijk enthausiast</understatement> over Asp.Net MVC ben. Ik vind het een framework wat echt zijn nut bewijst wat betreft snelheid van ontwikkelen, onderhoudbaarheid en flexibiliteit. Natuurlijk is het nog een redelijk jonge implementatie en kan het altijd beter. En wat ik eigelijk het meest ‘irritante’ vind, dat is de JavaScript.

Nu heb ik altijd geroepen dat JavaScript de plakband is die een controls op een webpagina bij elkaar moeten houden en had er ook een haat-liefde verhouding mee. Met de komst van jQuery en nog enkele andere (open souce) bibliotheken is het toch nog goed gekomen tussen mij en JavaScript.

Maar met de komst van jQuery en het gemak waarmee Ajax calls gemaakt worden, komt er wel een gevaar om de hoek: Al die Javascript, verdeelt over meerdere bestanden en over nog meer functies, kan echt spaghetti worden. Vooral als in de functies weer doorverwezen wordt naar de controls op het scherm…

Maar het kan anders! Opeens kwam KnockoutJs  om de hoek.

KnockoutJs is gebouwd op vier peilers:

  1. Declarative Bindings
  2. Automatic UI Refresh
  3. Dependency Tracking
  4. Templating

Dit maakt het mogelijk om MVVM toe te passen in JavaScript. Klinkt moeilijk, is het ook best wel maar ik heb onderstaand voorbeeld uitgewerkt en dit moet je flink op weg helpen.

En hou in je hoofd: we veranderen niks aan de manier waarop je Asp.Net MVC toepast. Het enige wat we willen voorkomen is spaghetti in JavaScript.

Start dus een nieuw Asp.Net MVC3 project:

We gebruiken de lege template:

Vervolgens werken we even de Nuget packages bij met de laatste versies.

Voor mij was het voldoende om de laatste jQuery te installeren. De gelieerde packages worden gewoon bijgewerkt. De jQuery Visual Studio IntelliSense kan daarna gedeinstalleerd worden, dit is in de originele jQuery package opgenomen. En vergeet niet om ook de Knockoutjs package en de Knockout.Mapping package toe te voegen. Nu moeten de volgende packages geïnstalleerd zijn:

De EntityFramework package en Modernizr package zijn out-of-scope voor deze blog.

Vervolgens moet de _Layout.cshtml bijgewerkt worden. (in MVC2 zou dit de master page zijn geweest)

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>@ViewBag.Title</title>
  <link href="@Url.Content("~/Content/Site.css")"
        rel="stylesheet"
        type="text/css" />
  <script src="@Url.Content("~/Scripts/jquery-1.6.4.min.js")"
          type="text/javascript"></script>
   <script src="@Url.Content("~/Scripts/knockout-1.3.0beta.js")"
           type="text/javascript"></script>
   <script src="@Url.Content("~/Scripts/knockout.mapping-latest.js")"
           type="text/javascript"></script>
   @RenderSection("Scripts", false)
</head>
<body>
  @RenderBody()
</body>
</html>

Tip: Controleer nog even of hier de juiste versies van de bestanden genoemd zijn. Er komen regelmatig nieuwe versies uit, tenslotte.

Wat hier opvalt is dat we een extra Scripts sectie toegevoegd hebben. Deze zal straks toegepast gaan worden binnen de View.

Maar eerst gaan we de controller en de ViewModel op de server creeeren.

Als ViewModel heb ik object genomen die een gezin moet voorstellen. Dit gezin woont op een adres:

public class HomeEditViewModel
{
  public int Id { get; set; }
  public string FamilyName { get; set; }
  public bool IsScary { get; set; }
  public Address Address { get; set; }
}

public class Address
{
  public string Street { get; set; }
  public int Number { get; set; }
}

Omdat ik een scherm wil demonstreren waarin een ViewModel wordt onderhouden, is dit een redelijk normale situatie die wel vaker voorkomt.

Voeg nu  een lege controller toe:

De controller ziet er (ingevuld) als volgt uit:

public class HomeController : Controller
{
  public ActionResult Edit()
  {
    var model = new HomeEditViewModel();
    model.Id = 42;
    model.FamilyName = "The Adams family";
    model.IsScary = true;
    model.Address = new Address {
            Street = "Cemetery Lane", Number = 1313 };
    return View(model);
  }

  [HttpPost]
  public ActionResult Edit(HomeEditViewModel model)
  {
    if (!ModelState.IsValid)
    {
      // do something with the data provided
    }

    return View(model);
  }
}

De essentie is hier dat er data verzameld wordt en dat de View hiermee gevuld gaat worden door de ViewEngine. Ik heb de PostBack methode toegevoegd zodat te controleren is of de Submit van een gewijzigde View ook correct verwerkt wordt.

Nu wordt het spannend , we gaan de View aanmaken. Voeg deze toe aan de Edit Methode:

Tot zover is alles nog een redelijk normale MVC3 applicatie.

Laten we nu eens naar de gegenereerde View gaan kijken. Ik heb de standaard gegeneerde view vervangen door onderstaande code:

@model KnockoutMvcApplication.Models.HomeEditViewModel
@{
 ViewBag.Title = "Demo of MVC edit view combined with KnockoutJs (MVVM implemention)";
}
@section Scripts{
 <script src="@Url.Content("~/Scripts/home.edit.js")"
         type="text/javascript"></script>
 <script type="text/javascript">
   var serverViewModel = @Html.Raw(Json.Encode(Model));
 </script>
}

<h2>MVC edit view combined with KnockoutJs (MVVM implemention)</h2>

<script src="@Url.Content("~/Scripts/jquery.validate.min.js")"
        type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")"
        type="text/javascript"></script>

@using (Html.BeginForm()) {
  @Html.ValidationSummary(true)
  <fieldset>
  <legend>KnockoutJS</legend>
  @Html.HiddenFor(model => model.Id)
  @Html.LabelFor(model => model.FamilyName)
  @Html.TextBoxFor(model =>
    model.FamilyName, new { data_bind = "value: FamilyName" })
  <label>@Html.CheckBoxFor(model =>
    model.IsScary, new { data_bind = "checked: IsScary" })Is scary</label>
  <hr/>
  @Html.LabelFor(model => model.Address.Number)
  @Html.TextBoxFor(model =>
    model.Address.Number, new { data_bind = "value: Address.Number" })
  <br/>
  @Html.LabelFor(model => model.Address.Street)
  @Html.TextBoxFor(model =>
    model.Address.Street, new { data_bind = "value: Address.Street" })
  <p>
  <input type="submit" value="Save" />
  <button data-bind="click : doSomethingNifty">Do something nifty here!</button>
  </p>
  </fieldset>
}

Family info: <span data-bind="text: FamilyInfo"></span>
<br/>
Full address: <span data-bind="text: FullAddress"></span>
<br/>

Wat we zien is dat het scherm netjes het ViewModel onderhoudt. De ID is hidden maar de familienaam en adres kunnen gemanipuleerd worden (validatie velden zijn hier weggelaten). We gebruiken hier GEEN gebruik van EditorFor maar van TextBoxFor. Hier is een reden voor! De magie van KnockOut is afhankelijk van de (hier aan de TextBoxFor toegevoegde) HTML5 attribuut DATA-BIND. De ViewEngine liet bij de EditorFor de hier toegevoegde DATA-BIND achterwege. TextBoxFor kent dit probleem niet.

Ook is er met die data-bind iets aan de hand. Zoals je ziet, schijf is die DATA-BIND met een underscore. Dit is een limitatie van Visual Studio, die accepteert geen mintekens in attributen maar een underscore _ wordt door de viewengine automatisch naar een minteken – omgezet.

Vanaf hier begint de knockout implentatie. In dit scherm heb ik drie schermelementen opgenomen die geinteresseerd zijn in wijzigingen op het scherm:

  1. Er is een span opgenomen die geïnteresseerd is in “FamilyInfo”. Die wilt dus bijgewerkt worden als de Familinaam aangepast wordt.
  2. Er is een span opgenomen die geïnteresseerd is in “FullAddress”. Die wilt dus bijgewerkt worden als het adres aangepast wordt.
  3. Er is een button op het scherm (naast de submit button) die op de clickactie de functie “doSomethingNifty” uitvoert

Normaal zouden hiervoor dus drie functies geschreven moeten worden die aan de ‘click’ of ‘change’ van bepaalde controls gehangen zouden worden. Dit zijn dus de controls die de gebruiker aanpast. Welke controls worden er dan gewijzigd? Dat wordt dan in de code bijgehouden…

Enter KnockoutJS. Zoals hier zichtbaar is, hebben we alleen data-bind attributen aan enkele controls toegevoegd.

Dan volgt nu de laatste stap.

We hebben bovenaan in een Scripts sectie (zie de _Layout.cshtml) een verwijzing naar een javascript bestand opgenomen: /Scripts/home.edit.js.

Hier kom ik zo op terug. En we laten de viewengine het totale model omzetten naar een javascript object. Dit is een heel belangrijke stap. De data op van het ViewModel is dus geplaatst in de controls (textboxes, etc.) en het is straks in het browser-geheugen beschikbaar.

Voordat we hier verder op door gaan, wat staat er in de JavaScript:

/// <reference path="jquery-1.6.4-vsdoc.js" />
/// <reference path="knockout-1.3.0beta.debug.js" />
/// <reference path="knockout.mapping-latest.debug.js" />

$(function () {
  var clientViewModel = ko.mapping.fromJS(serverViewModel);

  clientViewModel.FullAddress = ko.dependentObservable(function () {
    return this.Address.Number() + ' ' + this.Address.Street();
  }, clientViewModel);

  clientViewModel.FamilyInfo = ko.dependentObservable(function () {
    return this.Id() + ' ' + this.FamilyName();
  }, clientViewModel);

  clientViewModel.doSomethingNifty = function () {
    if (this.IsScary()) {
      alert("Booh!");
    }
    else {
      alert("I could do some Ajax stuff here with Id "
                  + this.Id() + ' and family ' + this.FamilyName());
    }
  };

  ko.applyBindings(clientViewModel);
});

De referenties bovenaan zijn puur om betere intellisence op de code te krijgen

Wat we hier zien is dat als de webpagina geheel in de browser geladen is, via Knockout Mapping het javascript object met ons server model omgezet wordt naar een Knockout Observable object. Dat is handig en scheelt een hoop handmatig werk.

Aan deze clientViewModel worden twee extra ‘dependent observable’ properties toegevoegd en er wordt een extra functie doSomethingNifty toegevoegd. De functie kan via de ‘this’ bij alle andere properties. Deze drie zagen we ook al genoemd worden bij de bindings op de controls.

En uiteindelijk zien we dat onze clientViewModel dus voor Knockout beschikbaar wordt gesteld. Hierdoor ontstaat binnen de browser een twee-weg binding tussen de inhoud van bv. de textboxen en een verbinding tussen de functie en de button-click.

Alle javascript logica is dus nu gecentraliseerd binnen dit clientviewmodel.

Laten we nu eens kijken of het allemaal werkt. We starten de pagina in de browser

Wat we al gelijk zien is dat de Family info en de Full Address onderaan gesynchroniseerd zijn.

Als we enkele velden wijzigen dan worden de twee spans (en ook alleen die spans) netjes bijgewerkt.

Een click op de extra knop leest ook netjes het Knockout viewmodel uit:

Alles bijbehorende javascript voor deze pagina is dus netjes in één JavaScript bestand beschikbaar met een correcte scheiding tussen de logica (het knockout viewmodel) en de representatie (de controls op de pagina). Geen spaghetti meer!

Hoe nu verder?

Kijk nu nog eens naar de filmpjes

http://channel9.msdn.com/posts/ASPNET-MVC-With-Community-Tools-Part-11-KnockoutJS

http://channel9.msdn.com/Events/MIX/MIX11/FRM08/

* Helaas kan ik Channel9 filmpjes niet direct integreren 😦

Dus laat je inspireren hoe jij dit framework gaat toepassen.

Er is natuurlijk wel die overhead van een extra object en de mapping. Persoonlijk gebruik ik Knockout vooral op pagina’s waar flink interactief gewerkt wordt, en dit zijn meestal maar een handvol pagina’s binnen een website. De overige pagina’s hebben geen interactie of slechts minimaal, in dat geval is Knockout wat overkill. Maar maak steeds de afweging: als ik nu nog geen Knockout op deze pagina toepas, kan ik het dan straks nog makkelijk integreren?

Succes met KnockOut!

English: Liked the examples but could not understand the Dutch gibberish? Want to know more? Please contact me!