Interaktive SVG mit AngularJS – Teil 2

Bei der Entwicklung von mobilen Web-Anwendungen mit Responsive Design bieten SVG eine praktikable Lösung zur Realisierung flexibler Bilder (flexible images).
AngularJS ermöglicht seinerseits, komplexe UI-Logik in individuellen HTML-Direktiven zu kapseln, was eine saubere und wartbare Modularisierung ermöglicht.
Die Kombination dieser Technologien stellt eine gute Basis für interaktive Steuerungs- und Status-Elemente dar. Sie ist sowohl dazu geeignet, hochgradig komplexe Spezial-Bedienelemente zu erstellen, als auch einfachere Anwendungsfälle in generischer Weise zu behandeln.

Teil 1 dieses Artikels untersucht verschiedene Methoden, SVG als flexible Bilder in browser-übergreifender Weise zu verwenden.

Teil 2 beschreibt die Verwendung von AngularJS zur Steuerung von SVG-Bildern, um individuelle Kontroll- und Status-Elemente zu realisieren.

SVG-Steuerung mit AngularJS

Bei der Entwicklung mobiler Web-Anwendungen mit Responsive Design stellen SVG eine praktikable Lösung für flexible Bilder (flexible images) dar. Sie skalieren automatisch auf jede Bildgröße, die das momentane Geräte-Display erfordert, und ihre vergleichsweise geringe Speichergröße hilft dabei, die Ladezeiten der Anwendung zu verbessern. Ferner unterstützen SVG sowohl CSS-Styling als auch Steuerung mittels DOM-API. Somit sind sie besonders gut für interaktive Steuerungs- und Status-Elemente geeignet, da ein einzelnes SVG alle Phasen eines hover/press/release- oder ok/warning/alert-Zyklus verkörpern kann.

Für den bequemen DOM-Zugriff erfordert ein solches SVG allerdings etwas zusätzliche Vorbereitung. Dazu sollte das Bild

  • keine unnötigen Gruppierungen, gedoppelten Shapes, oder ähnlich hinderliche Artefakte enthalten
  • die beabsichtigte logische Status-/Steuerungs-Struktur so nah wie möglich in seiner internen Dokumenten-Struktur widerspiegeln
  • ein eindeutiges XML id-Attribut für jeden logischen UI-Bestandteil vorsehen.

Letzteres kann man in handelsüblicher Design-Software meist dadurch erreichen, indem man der entsprechenden Grafik-Primitive ein Name zuweist oder sie in einen benannten Layer aufnimmt. Wenn man diese Vorlage dann nach SVG exportiert, wird aus dem Namen typischerweise ein id-Attribut, oder zumindest ein Teil des Attribut-Wertes.

JavaScript

Das folgende Beispiel stellt eine einfache „Ampel“-Statusanzeige dar. Das SVG enthält einen gefüllten Kreis als einzigen logischen Bestandteil, mit einem id-Attribute für den bequemen DOM-Zugriff:

<?xml version="1.0" encoding="UTF-8">
<?xml-stylesheet href="status.css" type="text/css">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
    viewBox="0 0 64 64">
  <circle id="status" fill="#888" cx="32" cy="32" r="32">
</svg>

Sein externer Stylesheet definiert eine Anzahl CSS-Klassen, von denen jede einen der möglichen Status-Zustände repräsentiert:

.normal  { fill: green }
.warning { fill: yellow }
.alert   { fill: red }

Um das SVG in einer Web-Anwendung zu verwenden, muss das HTML-Dokument es mittels eines object-Elements einbinden. Wie in Teil 1 dieses Artikels beschrieben wurde, ist dies die einzig zuverlässige Methode, um das SVG-DOM zugänglich zu machen. Der Einfachheit halber verwendet das Beispiel lediglich einen Button, der die Status-Änderung auslöst:

< object id="mysvg" type="image/svg+xml" data="status.svg">
</object>

<input type="button" value="alert" onclick="setStatus('alert')"/>

Schließlich muss das HTML-Dokument noch etwas JavaScript einbetten oder einbinden, welches die zugehörige Steuerlogik enthält. Die Implementierung sucht dabei einfach das status-Element heraus und fügt ihm die geeignete CSS-Klasse hinzu.

var oldStatus = "normal";
function setStatus(newStatus) {
  var statusElm = document.getElementById("mysvg")
    .getSVGDocument().getElementById("status");
  statusElm.classList.remove(oldStatus);
  statusElm.classList.add(newStatus);
  oldStatus = newStatus;
}

Dieser Ansatz funktioniert für einfache Beispiele noch recht gut. Er gerät aber leicht außer Kontrolle, wenn das SVG mehrere funktionale Bestandteile enthält oder anspruchsvolle Steuerlogik erfordert. Die Situation ist noch schlechter, wenn die Web-Anwendung mehrere Exemplare desselben interaktiven Elements beinhaltet. Je höher die Komplexität, desto schwieriger wird es den Überblick der richtigen id-Attribute und Callback-Funktionen zu behalten. Ein einziger Tippfehler, und das ganze fragile Konstrukt bricht auseinander.

AngularJS

Hier kommt AngularJS ins Spiel. Das Framework zielt darauf ab, die Entwicklung MVC-basierter Web-Anwendungen durch eine Anzahl mächtiger Features zu vereinfachen, wie Dependency Injection, automatisches bidirektionales Data Binding und angepasste HTML-Direktiven. Dadurch wird es sehr einfach, komplexe UI-Manipulations- und Steuerlogik in gut handhabbaren Komponenten zusammenzufassen.

Hier noch einmal das vorherige Ampel-Beispiel, diesmal nach Art von AngularJS. Die neue Direktive <my-status> kapselt die korrekte Verknüpfung des SVG sowie alle zugehörige Steuerlogik, und verbirgt dadurch ihre inneren Details. Eine einzige Scope-Variable currentStatus verbindet das Status-Element mit dem Steuer-Button, als typisches Beispiel für Angulars deklarative lose Koppelung.

<my-status watch="currentStatus"></my-status>
<input type="button" value="alert" ng-click="currentStatus='alert'"/>

Eine minimale Definition der neuen Direktive besteht aus zwei wesentlichen Bestandteilen. Der Erste ist ein Template, welches das <my-status>-Element zur Laufzeit ersetzt. Der Zweite ist eine Link-Funktion, welche die Steuerlogik vorbereitet und mit einem $watch-Ausdruck verknüpft.

myApp.directive("myStatus", function() {
  return {
    restrict: "E",
    replace: true,
    template: "< object type='image/svg+xml'
                 data='status.svg'></object>",
    link: function(scope, element, attrs) {
      var statusChanged = function(newValue, oldValue) {
        var statusElm = angular.element(element[0]
          .getSVGDocument().getElementById("status"));
        statusElm.removeClass(oldValue);
        statusElm.addClass(newValue);
      };
      scope.$watch(attrs.watch, statusChanged);
    }
  }
});

Standardmäßig verwendet AngularJS seine eigene jqLite-API für die DOM-Manipulation. Leider unterstützt diese weder getSVGDocument() noch getElementById(). Daher muss die statusChanged()-Funktion zunächst die Kapselung des Element-Verweises entfernen, dann den SVG-Zugriff mittels der regulären DOM-API durchführen, und zuletzt das Ergebnis wieder mit jqLite kapseln, damit es bequem und browser-unabhängig weiterverwendet werden kann.

Wird diese minimale Implementierung ausgeführt, so kann der Browser gelegentlich einen Fehler protokollieren, sobald der Code erstmalig auf das SVG-Element zugreift. Ursache dafür ist die browsertypische Arbeitsweise, das Laden einer Seite zu beschleunigen, indem verknüpfte Ressourcen asynchron abgerufen und im Cache zwischengespeichert werden. Hat der Browser den Ladevorgang des SVG-Dokuments dann noch nicht abgeschlossen, wenn AngularJS die Link-Funktion ausführt, so liefert getSVGDocument() zwangsweise null, und in Folge schlägt getElementById() fehl. Um diese Situation zu bereinigen, kann der Inhalt der Link-Funktion in eine init-Funktion gekapselt werden, die dann je nach Lage entweder sofort oder erst später ausgeführt wird.

link: function(scope, element, attrs) {
  var init = function() {
    var statusChanged = function(newValue, oldValue) {
      ...
    };
    scope.$watch(attrs.watch, statusChanged);
  };
  if (element[0].getSVGDocument()) {
    init();
  } else {
    element.on("load", init);
  }
}

Diese Beispiel-Direktive implementiert ein reines Status-Element. Allerdings lässt sich eine Steuerungs-Direktive auf recht ähnliche Weise realisieren. Um den gefüllten Kreis im SVG-Bild als Button zu benutzen, kann die init-Funktion folgendermaßen abgeändert werden:

var init = function() {
  var statusElm = angular.element(element[0]
    .getSVGDocument().getElementById("status"));
  statusElm.on("click", function(event) {
    scope.$apply(function() {
      scope.currentStatus='alert';
    });
  });
};

Dabei ist zu beachten, dass der Scope durch einen Event-Handler verändert wird, welcher sich innerhalb des SVG-DOM befindet. Der Code muss daher $apply verwenden, damit Angular diese Änderung erkennt und seine Data Bindings entsprechend aktualisiert.

Generalisierung

Die bisher besprochene Lösung eignet sich für SVG-basierte Steuerungs- und Status-Elemente beliebiger Komplexität. Für einfachere Fälle hat sie aber den entscheidenden Nachteil, dass jedes Element seine eigene Direktive benötigt – alle mit praktisch identischer Funktionalität, nur mit unterschiedlichen SVG-Ressourcen und id-Attributen. Hierfür wäre es deutlich eleganter, generische Direktiven zu definieren, die dann Angulars Data Bindings und eingebauten Bedingungsprüfungen effektiv nutzen können. Zum Beispiel:

<svg-control href="{{mysvg}}">
  <svg-toggle href="#status" clazz="normal"
    ng-if="currentStatus==='cool'"></svg-toggle>
  <svg-toggle href="#status" clazz="warn"
    ng-if="currentStatus==='heating'"></svg-toggle>
  <svg-toggle href="#status" clazz="alert"
    ng-if="currentStatus==='hot'"></svg-toggle>
  <svg-handle href="#status"
    click="currentStatus=''"></svg-handle>
</svg-control>

Die Grundidee dabei ist, dass die äußere <svg-control>-Direktive für die SVG-Einbindung zuständig ist, genauso wie im vorherigen monolithischen Beispiel. Allerdings soll sie deutlich flexibler sein und anstatt einer expliziten URL auch eine indirekte Angular-Interpolation zur Angabe der Bild-Ressource akzeptieren. Die inneren Direktiven dagegen beziehen sich jeweils auf einen benannten Teil des SVG-Bildes. Jeder <svg-toggle> wendet eine vorgegebene CSS-Klasse auf sein SVG-Element an; in Kombination mit Angulars ng-if-Bedingung realisiert dies die Status-Logik. <svg-handle> dagegen verknüpft sein SVG-Element mit einem Klick-Handler, und bietet so eine einfache Steuerungs-Logik.

Die Implementierung von <svg-control> arbeitet wie zuvor mit einem Ersetzungs-Template, welches das SVG durch ein object-Element einbindet. Allerdings kann man nicht einfach eine Angular-Interpolation als data-Attribut angeben. Browser gehen beim Laden externer Ressourcen typischerweise ziemlich aggressiv vor und legen meist damit los, bevor Angular den Interpolations-Wert im Template auflösen kann. Der spezielle Präfix ng-attr- trägt diesem Verhalten Rechnung. Entsprechend könnte eine einfache Umsetzung folgendermaßen aussehen:

myApp.directive("svgControl", function() {
  return {
    restrict: "E",
    replace: true,
    scope: { href: '@' },
    template: "< object type='image/svg+xml'
                 ng-attr-data='{{href}}'></object>"
  }
});

Leider hat dieser Ansatz zwei entscheidende Nachteile: Zum einen wird ein isolierter Scope eingerichtet, was später ein Problem darstellt wenn die <svg-handle>-Direktive ihren Handler-Ausdruck auswerten muss. Zum anderen können Browser recht eigenwillig sein, was die Modifikation von Link-Attributen wie data betrifft; es ist nicht garantiert dass dies tatsächlich einen Ladevorgang auslöst. Eine sicherere Methode ist die Verwendung von Angulars ng-hide-Klasse zu diesem Zweck: Die Implementierung verbirgt damit zunächst das Template, ändert dann explizit das data-Attribut, und zeigt zuletzt das fertige Element an.

myApp.directive("svgControl", function() {
  return {
    restrict: "E",
    replace: true,
    template: "< object type='image/svg+xml'
                 class='ng-hide'></object>",
     link: function(scope, element, attrs) {
      element.attr("data", attrs.href);
      element.removeClass("ng-hide"); // force reload
    }
  }
});

Dieser Ansatz lädt das SVG-Bild wie gewünscht. Allerdings ersetzt das Template dabei vollständig das <svg-control>-Element, inklusive seiner inneren Direktiven! Normalerweise ließe sich dies mittels Angulars Transclude-Mechanismus korrigieren. Aber bei einem object-Element ersetzt der Browser alle Kind-Elemente durch das geladene SVG-DOM, so dass die transkludierten Direktiven ebenso verloren gingen.
Die einzige Art sie zu erhalten ist eine Platzierung nebeneinander. Dafür ist es aber wiederum notwendig, ein weiteres <div> zur Gruppierung zu verwenden, da in AngularJS die Templates auf ein einziges Wurzel-Element beschränkt sind. Schließlich benötigt das object-Element noch eine Breite und Höhe von 100%, damit sich etwaige CSS-Größenangaben des <svg-control>-Elements auch auf den SVG-Viewport auswirken.
Das neue Template erfordert eine Änderung der Link-Funktion, welche nun dessen hierarchische Struktur durchqueren muss um wieder an das zu manipulierende object-Element zu gelangen:

myApp.directive("svgControl", function() {
  return {
    restrict: "E",
    replace: true,
    transclude: true,
    template:
      "<div> \
        < object type='image/svg+xml' class='ng-hide' \
          height='100%' width='100%'></object> \
        <div ng-transclude></div> \
      </div>",
    link: function(scope, element, attrs, ctrl) {
      var obj = element.children().eq(0);
      obj.attr("data", attrs.href);
      obj.removeClass("ng-hide");
    }
  }
});

Controller

Neben der Einrichtung dieser internen DOM-Struktur muss <svg-control> ferner ein Kommunikationsmittel zwischen sich und seinen inneren Direktiven zur Verfügung stellen. Wie in Angular üblich bietet ein Controller dazu die passende API. Sie enthält im Wesentlichen wieder die zwei Funktionalitäten, die schon im Rahmen des monolithischen Beispiels angesprochen wurden:
Die resolve()-Funktion findet einen benannten Teil des SVG, und liefert das betreffende Element in jqLite-Kapselung für den bequemen Zugriff.
Die init()-Funktion kümmert sich um den Start-Code; sie führt ihn entweder sofort aus oder verzögert es bis das SVG vollständig geladen wurde.
Die interne ready()-Funktion erledigt die nötige Vorarbeit: Sie bereitet den SVG-Verweis für die id-Suche vor, und führt dann allen eventuell verzögerten Start-Code aus.

controller: function($scope) {
  var svg = null;
  var deferred = [];
  this.init = function(fn) {
    if (svg) {
      fn($scope);
    } else {
      deferred.push(fn);
    }
  };
  this.resolve = function(href) {
    var id = href.replace("#", "");
    var dom = svg.getElementById(id);
    return dom ? angular.element(dom) : null;
  };
  this.ready = function(obj) {
    svg = obj[0].getSVGDocument();
    deferred.forEach(function(fn) {
      fn($scope);
    });
  };
}

Die <svg-control>-Direktive verwendet ihre Link-Funktion zur Einrichtung des Controllers, sobald das SVG-Dokument fertig geladen ist. Zu diesem Zweck registriert sie einen "load"-Event-Handler beim object-Element, der dann die ready()-Funktion des Controllers aufruft. Diese Registrierung findet statt, bevor das Laden der SVG-Ressource angestossen wird, so dass der Event auch garantiert wie erwartet auftritt.

link: function(scope, element, attrs, ctrl) {
  var obj = element.children().eq(0);
  obj.on("load", function() { ctrl.ready(obj) });
  obj.attr("data", attrs.href);
  obj.removeClass("ng-hide");
}

Innere Direktiven

Nachdem der Controller steht, können nun die inneren Direktiven realisiert werden, welche das SVG-Bild um Steuerungs- und Status-Logik anreichern.

Die Implementierung der <svg-toggle>-Direktive findet den <svg-control>-Controller per require-Option. Damit kann sie dessen resolve()-Funktion innerhalb ihrer toggle()-Kernfunktion verwenden, um an das Ziel-Element im SVG zu gelangen und die spezifizierte CSS-Klasse darauf anzuwenden.
Die Direktive verwendet ferner die init()-Funktion zur verzögerten Initialisierung. Sie ruft die toggle()-Funktion einmalig auf sobald die Direktive aktiv wird, und noch einmal per Event-Handler wenn ihr lokaler Scope gelöscht wird. Dadurch ist <svg-toggle> kompatibel zum Mechanismus der DOM-Manipulation, auf dem Angulars ng-if– und ng-switch-Bedingungen basieren.

myApp.directive("svgToggle", function() {
  return {
    restrict: "E",
    require: "^svgControl",
    link: function(scope, element, attrs, ctrl) {
      var toggle = function() {
        ctrl.resolve(attrs.href).toggleClass(attrs.clazz);
      };
      ctrl.init(function() {
        toggle();
        scope.$on("$destroy", toggle);
      });
    }
  };
});

Die Implementierung der <svg-handle>-Direktive ist etwas schwieriger. Es ist leider nicht möglich, Angulars eingebaute Event-Direktiven wie ng-click direkt mit SVG-Elementen zu verwenden. Denn obwohl das SVG-DOM ins HTML-DOM eingebettet ist, betrachtet Angular es nicht als Teil seines ng-app-Templates.
Daher muss <svg-handle> seinen eigenen "click"-Handler beim gewünschten SVG-Element registrieren. Zu diesem Zweck findet die Direktive ebenfalls den <svg-control>-Controller per require-Option, und verwendet dessen init()– und resolve()-Funktionen in bewährter Weise.

app.directive("svgHandle", function($parse) {
  return {
    restrict: "E",
    require: "^svgControl",
    link: function(scope, element, attrs, ctrl) {
      ctrl.init(function() {
        ctrl.resolve(attrs.href).on("click",
          function(event) { 
            ...
          });
      });
    }
  };
});

Die Behandlung der spezifizierten Klick-Anweisung erfordert ein wenig zusätzlichen Aufwand. Glücklicherweise enthält Angular den sehr mächtigen (aber etwas obskuren) $parse-Service. Die Direktive kann diesen Service verwenden, um die Anweisung in eine Auswertungs-Funktion zu übersetzen, um diese dann später im Klick-Handler aufzurufen. Eine solche Funktion erwartet einen Kontext-Scope als erstes Argument, und ein Objekt mit zusätzlichen Variablen als zweites Argument. Die Implementierung verwendet letztes, um die Ausführungsumgebung von ng-click zu simulieren, die den ursprünglichen DOM-Event in der $event-Variablen enthalten muss.

Die Angabe des korrekten Scope erfordert allerdings etwas Sorgfalt. Man bedenke dass <svg-handle> ein transkludiertes Element ist, und Angular daher automatisch einen neuen Scope dafür anlegt. Hingegen muss die Klick-Anweisung im Scope von <svg-control> ausgeführt werden, damit sie wie erwartet funktioniert. Für solche Zwecke übergeben die init()– und ready()-Funktionen des Controllers den Eltern-Scope als optionales Argument an ihre Initialisierungs-Funktionen. Die <svg-handle>-Direktive kann diesen dann sowohl als Kontext für die Auswertungs-Funktion verwenden, als auch Angular mittels $apply benachrichtigen wie für DOM-Handler erforderlich. Das Ergebnis ist die folgende Link-Funktion:

link: function(scope, element, attrs, ctrl) {
  var fn = $parse(attrs.click);
  ctrl.init(function(parentScope) {
    ctrl.resolve(attrs.href).on("click",
      function(event) {
        parentScope.$apply(function() {
          fn(parentScope, {$event:event});
        });
      });
  });
}

Natürlich ist diese Lösung nicht auf Mausklicks beschränkt. Die <svg-handle>-Direktive könnte genauso gut auch weitere Steuerungs-Attribute anbieten. Für jeden solchermaßen spezifizierten Event-Typ kann sie dann eine zusätzliche Initialisierungs-Funktion an die init()-Funktion des Controllers übergeben.

Fazit

Mittels SVG und AngularJS lassen sich mobile Web-Anwendungen leicht um interaktive Steuerungs- und Status-Elemente anreichern. Komplexe Teile der UI-Logik werden dabei in hochgradig konfigurierbaren Spezial-Direktiven gekapselt, die mit der Anwendungsumgebung über Angulars intuitives Data Binding verbunden werden können. Andererseits genügen ein paar wenige generische Direktiven, um den Bedürfnissen einfacherer UI-Elemente gerecht zu werden. Das Resultat ist sauberer und wartbarer Code, gepaart mit geringen Ressourcen-Anforderungen und einer natürlichen Affinität zu Responsive Design. Was will man mehr?

CAVEAT: Der Code wurde zur besseren Darstellung vereinfacht. Geeignete Fehlerbehandlung und browserübergreifende Kompatibilität sind dem geneigten Leser überlassen. 🙂

Ein Gedanke zu “Interaktive SVG mit AngularJS – Teil 2

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s