Custom HTML Controls für Windows 8 Apps mit WinJS

Wer halbwegs komplexe Apps für Windows 8  in Javascript erstellen möchte, der wird sich früher oder später damit beschäftigen, wie er den Quellcode strukturieren kann. Gerade bei Javascript sind da vielleicht nicht alle Möglichkeiten geläufig. Ich möchte in diesem Blogpost vorstellen, wie man eigene Custom HTML Controls erstellen kann, die einem das Leben als Entwickler erleichtern – aus den in objektorientierten Programmiersprachen gängigen Gründen wie der Wiederverwendbarkeit und Wartbarkeit. Zunächst einmal müssen wir definieren, welches Control wir eigentlich erstellen möchten. Aus gegebenem Anlass fällt meine Wahl auf ein GraphControl. Ich möchte also ein Control erstellen, was abhängig von ein paar Optionen, die ich definiere, einen Graphen zeichnet. Im Endeffekt soll das ganze am Ende ungefähr so aussehen – natürlich ein bisschen sauberer, das ist nur die Version, die ich mal eben hingescribbelt habe.    

 

Dabei möchte ich die Möglichkeit haben sowohl die Größe des umgebenden Divs vorzugeben. Außerdem möchte ich die Farbe der Graphen  sowie – natürlich – die Punkte der Graphen festzulegen. Weil ich es dem Entwickler, der später das Control einsetzt, halbwegs leicht machen will, soll das Control schlau genug sein und sich – abhängig von der Größe des Controls halbwegs intelligent rendern. Dazu später mehr. Gut, wir wissen also, was wir wollen. Aber wie kriegen wir das hin? Wir können uns mal ein bisschen Inspiration holen, indem wir einfach mal einen Blick in die WinJS riskieren. WinJS? Ist das nicht die Library, die Microsoft zur Verfügung stellt? Ja genau. In jeder Windows 8 App, die auf HTML/JS basiert ist die WinJS standardmäßig integriert. Das Schöne ist, dass wir den Quellcode einsehen können (und deshalb bei Bedarf auch mal reindebuggen können. Noch besser ist allerdings, dass man den Quelltext und die Struktur als „Quell der Inspiration“ nutzen kann. Zum Beispiel können wir uns ansehen, wie die WinJS Controls realisiert und uns an diese Mechanismen anlehnen, wenn wir unser GraphControl bauen wollen.

Divide …

Wir riskieren also einfach mal einen Blick in die ui.js Datei. Hoffentlich erschlagen uns die gut 35.000 Zeilen nicht. Wir könnten jetzt einfach mal ein bisschen durchscrollen, aber ich schlage ein strukturierteres Vorgehen vor: Die WinJS implementiert ja das FlipView Control. Suchen wir also einfach mal nach „FlipFiew“ (Ctrl+F).

Das bringt uns – in der RC Version von WinJS – ungefähr zu Zeile 11.000. Ein paar Zeilen weiter oben finden wir ein für den ein oder anderen eventuell unbekanntes Konstrukt:

(function flipperInit(WinJS) { // ...

Mein Vorschlag: Einfach mal den Cursor vor die geschweifte Klammer stellen und dann Ctrl + Shift +`  klicken. Dadurch wird der komplette Inhalt des umgebenden Klammerpaares markiert. Das Ganze kopieren wir jetzt in ein separates File, um es mal ein wenig zu analysiseren. Danach vervollständigen wir noch den Teil vor der öffnenden geschweiften Klammer und suchen uns den Teil nach der geschlossenen geschweiften Klammer, so dass wir ungefähr diesen Codeblock haben:

(function flipperInit(WinJS) {

       // ganz viel Code

})(WinJS); 

Die erste Frage ist jetzt vielleicht: Was zum Henker sehen wir hier? Eine sogenante „Immediate Function“. Eine Immediate Function ist eine Funktion, die unmittelbar nach der Definition der Funktion auch schon ausgeführt wird. Das heißt durch das umschließende Konstrukt mit den 4 runden Klammern sorgen wir dafür, dass die Funktion im inneren ausgeführt wird. Das WinJS am Ende (rot) ist das WinJS Objekt, das wir als Parameter mit übergeben. Das WinJS am Anfang (grün)  ist der Parametername – in diesem Fall auch WinJS, aber hier könnte ebenso „obj“, „arg_WinJS“ oder „Heinrich“ stehen – abhängig von dem gewählten Namen können wir dann aus der Funktion auf das Objekt zugreifen – wie gewohnt.

Ein wichtiger Vorteil der Immediate Functions ist, dass Definitionen innerhalb der geschweiften Klammern lediglich lokal innerhalb der Funktion zur Verfügung stehen. Das sorgt dafür, dass wir eben nicht den globalen Scope „verschmutzen“ und dadurch sauberer und sicherer programmieren. Wenn wir uns den Inhalt innerhalb der geschweiften Klammern ansehen, werden wir feststellen, dass das zwar ingesamt viel Code ist, wir aber alles auf 3 Bereiche herunterbrechen können. Am besten geht das, indem wir einzelne Codeblöcke einfach ausblenden.

Wir markieren einfach mal alles innerhalb der geschweiften Klammern bis zum Anfang von WinJS.Namespace.define, und wählen Ctrl+M, Ctrl+H um den Inhalt auszublenden. Dann blenden wir auf die gleiche Weise alles innerhalb von WinJS.Namespace.define aus. Wir stellen also den Cursor vor die öffnende geschweifte Klammer und wählen Ctrl+Shift+` um alles bis zum Ende der Klammer zu markieren und lassen mit Ctrl+M und Ctrl+H alles verschwinden.  Jetzt bleibt tatsächlich nicht mehr viel. Wir wählen alles unterhalb von // Events und blenden es auf die gleiche Weise aus.

Übrig bleibt:

 

Sieht doch ganz übersichtlich aus, oder? Kann also gar nicht so schwer sein, ein eigenes Control zu bauen  :-)

… & Conquer

Werfen wir mal einen Blick in den Inhalt, angefangen beim ersten Block. Wenn wir den ersten Block aufklappen sehen wir zwei Funktionsdefinitionen und ein paar frisch angelegte Variablen und deren Initialisierung. Spannend? Nein. Wichtig für uns: Auf diese Variablen & Funktionen kann von überall innerhalb dieser Funktion zugegriffen werden.

Schauen wir uns zunächst den dritten Block an: Hier finden wir irgendetwas zum Thema Events, wie uns der Kommentar mitteilt. Interessiert uns das jetzt gerade? Nein – aber ich denke, ich werde mich in einem späteren Blogpost darum kümmern. Vergessen wir also für dieses Posting  auch diesen Block.

Dann bleibt eigentlich nur ein Block übrig: Der Inhalt von WinJS.Namespace.define(…){…}. Diese Funktion können wir nutzen um Objekte global verfügbar zu machen. In diesem Fall das Objekt „FlipView“, das über den Aufruf von WinJS.Class.define() definiert wird. Moment: Gerade eben haben wir alles in eine Immediate Function gekapselt um im globalen Scope NICHT zuviele Spuren zu hinterlassen – und jetzt machen wir es doch? Genau. Der Trick ist hier, dass durch die Immediate Funktion erstmal sichergestellt wird, dass wir überhaupt keine Spuren im globalen Scope hinterlassen. Und über die Verwendung von WinJS.Namespace.define können wir dann explizit definieren, welches Objekt im globalen Scope doch vorhanden sein soll. Das ist schlicht und ergreifend sauberer, sicherer und gibt uns die Möglichkeit unseren Code zu strukturieren, indem wir über die Punktnotation unterschiedliche Hierarchien von Namespaces definieren. Und unterm Strich bleibt eben auch nur ein einziges Objekt übrig.

Wirklich spannend wird es bei der Definition der Klasse über WinJS.Class.define(). Hierin wird definiert, wie unser Control aussehen soll, indem wir als erstes Argument eine Konstruktorfunktion übergeben. In der Konstruktorfunktion legen wir das neue Element im DOM Tree an. Hier können wir auch beliebige Properties des Elements setzen.

Als zweites Argument können wir noch eine beliebige Anzahl von Members übergeben. Das FlipView bietet hier zum Beispiel „next()“ und „previous()“. Außerdem finden wir ein paar Methoden die mit einem Unterstrich beginnen, bspw. „_animationsFinished()“. Der Unterstrich sorgt dafür, dass diese Funkionen nicht angeboten werden, wenn wir später per Intellisense die Member des Controls listen lassen.

Als drittes Argument könnte man potentiell noch statische Member hinzufügen. Beim FlipView gibt’s hier keine. Der Vorteil statischer Funktionen wäre, dass wir diese auch aufrufen könnten, ohne vorher unser Objekt auch mittels „new“ anzulegen.

Zur Sache

Damit haben wir eigentlich den grundsätzlichen Aufbau von Controls verstanden. Was wir jetzt tun ist unser eigenes Control zu bauen. Auf Grund dessen, was wir gerade gelernt haben, bauen wir jetzt alles schön Schritt für Schritt nach, um hier möglichst nahe an dem zu bleiben, was Microsoft in der WinJS vorgemacht hat.

Step 1: Dateien anlegen& referenzieren

Wir legen also zunächst mal eine eigene Datei an, die den Namen unseres Controls bekommt:
GraphViewer. Bei Bedarf können wir später noch eine GraphViewer.css hinzufügen.

 

 

Die GraphViewer.js referenzieren wir in der Default.html – das geht am leichtesten per Drag&Drop der Datei in die Default.html an die richtige Stelle – der Pfad wird dann automatisch angepasst.

<script type="text/javascript" src="../../Controls/GraphViewer/GraphViewer.js"></script>

Step 2: Immediate Function

Innerhalb der GraphViewer.js legen wir eine Immediate Funktion an, um den global Scope nicht zuzumüllen.

(function () {

 // Hier geht’s gleich zur Sache.

})();

Step 3: WinJS.Namespace.define & WinJS.Class.define()

Innerhalb der Immediate Function definieren wir einen Namespace und  bauen unser Control. Weitere Kommentare inline.

(function () {

   // als Namespace Namen wähle ich DMX – weil mir Daniel Meixner zu lang ist.

    WinJS.Namespace.define( "DMX", {

       // innerhalb des Namespaces gibt es nur ein Objekt „GraphViewer”

    // element ist das Element im DOM, das zum Control werden soll

    // options ist ein Objekt, das beliebige Properties beinhaltet.

        GraphViewer: WinJS.Class.define( function (element, options) {

           // hier werde ich gleich die Konstruktor function implementieren

        }, {

           // hier folgen gleich die Member

        }

       )

    });

})(); 

Step 4: Der Konstruktor

Wir implementieren die Logik des Konstruktors. Das ist nicht so kompliziert, wie es klingt. Im Prinzip sorgen wir lediglich dafür, dass wir damit umgehen können, wenn kein Element übergeben wurde. Die notwendige Logik dafür können wir uns beim FlipView ansehen: Wenn element null ist erzeugen wir einfach ein neues div. Danach sorgen wir dafür, dass options nicht mehr null ist, sondern zumindest ein leeres Objekt – aber Vorsicht: Die Ausnahmebehandlung ist in diesem Fall nicht vollständig. Ist ja nur ein kleines Beispiel!

Anschließend rufen wir eine Methode auf, die wir noch implementieren müssen: _createVisualTree(options) und geben das element zurück. _createVisualTree müssen wir gleich noch implementieren. Diese Funktion wird dafür sorgen, dass wir aus dem Div unser Control erzeugen.

Der Konstruktor sieht also so aus:

 function (element, options) {

    element = element || document.createElement( "div");

   this.options = options || {};

   this._element = element;

   this._createVisualTree(options);    

Step 5a: Die eigentliche Arbeit

Jetzt geht’s an die Implementierung von _createVisualTree. Ok, hier ist mir eine textuelle Beschreibung zu kompliziert. Stattdessen habe ich den Code inline kommentiert, in der Hoffnung, dass Ihr versteht, was Sache ist. Man kann hier sicher noch ganz viel verschönern und in Funktionen auslagern. Um das Ganze nicht zu verkomplizieren (nur für dieses Beispiel!) hab ich es mal ganz simple gehalten.

 

Zum besseren Verständnis ist es sicher noch sinnvoll, sich anzusehen, wie das ganze aufgerufen werden soll und welche Parameter wir übergeben – das können wir nachher z.B. in der Default.js platzieren.

// wir legen ein neues div element an
var myDiv = document.createElement("div");
// wir legen ein options Objekt an
var options = {};
// wir legen einen Classname fest, der dem div übergeben werden soll
options.className = "graphDiv";
// wir legen die Breite & Höhe fest
options.width = 448;
options.height = 168;
// der Abstand vom Koordinatensystem zum Rand
options.margin = 10;
// Die Graphdaten: Ein Graph besteht aus einer Farbe, einer Strichstärke, einem Classname und natürlich: Den Punkten
options.graphs = [
    { className: "graph", color: "red", strokeWidth: 2, line: [{ x: 30, y: 7 }, { x: 50, y: 40 }, { x: 100, y: 80 }] },
    { className: "graph", color: "green", strokeWidth: 2, line: [{ x: 9, y: 60 }, { x: 9, y: 60 }, { x: 100, y: 60 }] },
    { className: "graph", color: "blue", strokeWidth: 2, line: [{ x: 90, y: 7 }, { x: 90, y: 7 }, { x: 90, y: 7 }] },
];

// Wir rufen den Konstruktor auf
var graphViewer = new DMX.GraphViewer(myDiv, options);

// und hängen danach das neue Control in den DOM
var parent = document.getElementById("myElement");
parent.appendChild(myDiv);

 

  

Step 5b: Create Visual Tree (_createVisualTree)

Und wie sieht die Funktion nun aus? So – auch hier die Kommentare wieder inline. Die Funktion wird im Member-Bereich  von WinJS.Class.define angelegt.

 WinJS.Namespace.define("DMX", { GraphViewer: WinJS.Class.define(function (element, options) { // . . . this._createVisualTree(options); }, { // o sind die Options _createVisualTree: function (o) { // wir legen ein child element an - ein neues div, in das wir alles reinpacken, was wir brauchen. // Ganz am Ende hängen wir es in das übergebene div var child = document.createElement("div"); // wir legen die Größe fest - abhängig von den übergebenen Parametern child.style.width = o.width + "px"; child.style.height = o.height + "px"; // wir setzen den Classname des divs - die sollten wir später auch im css definieren child.setAttribute("class", o.className); // wir wollen Kurven malen - das machen wir am besten mit HTML5 SVGs var svgns = "https://www.w3.org/2000/svg"; // vereinfachend gehen wir davon aus, dass der Startpunkt jeder Kurve bei Koordinate 0/0 ist. var startx = 0; var starty = 0; // wir wollen das Koordinatensystem nicht ganz an den Rand pressen, also gibt's einen kleinen Margin, den der // Anwender bestimmen kann var diffX = o.margin; // der Startpunkt auf der Y Achse wird verschoben - da ja die Pixelposition links oben bei 0/0 ist var diffY = o.height - o.margin; // logischerweise verschiebt sich damit auch startx & starty startx += diffX; starty += diffY; if (o.graphs) { // wir malen die Graphen - dazu legen wir erstmal ein svg element an var svg = document.createElementNS(svgns, "svg"); // wir setzen Breite & Höhe svg.setAttribute("xmlns", svgns); svg.setAttribute("width", o.width + "px"); svg.setAttribute("height", o.height + "px"); // Wir suchen aus allen übergebenen Graphendaten das Maximum heraus für X und Y Koordinaten // um die Graphen gut ausrichten zu können var maxX = 0; var maxY = 0; o.graphs.forEach(function (graph) { graph.line.forEach(function (point) { if (point.x > maxX) { maxX = point.x; }

if (point.y > maxY) { maxY = point.y; } }, this); }, this);

// jetzt "kalibrieren" wir die Einheiten in denen wir rechnen. Eine EInheit ist der Quotient aus zur Verfügung stehender Länge in X/Y Richtung und dem jeweiligen Maximalwert var unitX = (o.width - o.margin * 2) / maxX; var unitY = (o.height - o.margin * 2) / maxY; // genug gerechnet - jetzt wird gemalt // wir malen erstmal zwei Achsen für's Koordinatensystem var xAxis = document.createElementNS(svgns, "path"); var yAxis = document.createElementNS(svgns, "path"); // Startwert ist jeweils 00, dann geht's weiter nach oben oder rechts var xAxisData = "M " + startx + " " + starty + " " + ((unitX * maxX) + diffX) + " " + starty; var yAxisData = "M " + startx + " " + starty + " " + startx + " " + ((unitY * maxY) - starty + o.margin * 2); xAxis.setAttributeNS(null, "d", xAxisData); yAxis.setAttributeNS(null, "d", yAxisData); xAxis.setAttributeNS(null, "stroke", "black"); yAxis.setAttributeNS(null, "stroke", "black"); svg.appendChild(yAxis); svg.appendChild(xAxis); // dann malen wir die "echten" Graphen o.graphs.forEach(function (graph) { // wir legen wieder reihenwiese Pfade an var path = document.createElementNS(svgns, "path"); var pathElem = "M " + startx + " " + starty + " C "; // wir iterieren über alle Punkte pro Kurve graph.line.forEach(function (point) { pathElem += " " + ((point.x * unitX) + diffX) + " " + ((point.y * unitY) + diffY - starty) + " "; }, this); // wir setzen ein paar Attribute // den Pfad path.setAttributeNS(null, "d", pathElem); // die Farbe path.setAttributeNS(null, "stroke", graph.color); // wir möchten das ganze nicht gefüllt path.setAttributeNS(null, "fill", "none"); // und wir übergeben noch einen CSS Classname path.setAttributeNS(null, "class", graph.className); // dann hängen wir das ganze an svg.appendChild(path); }, this); // fehlt nur noch, dass wir das SVG selbst anhängen child.appendChild(svg); } // und das child wird ans root element gehängt. this._element.appendChild(child); // fertig } }// class define Ende } //namespace define Ende

  

So – unterm Strich gar nicht so schwer, oder?

Step 6: Einbetten in HTML

Wenn wir das Control einbinden wollen, können wir das relativ einfach über Javascript tun, oder eben direkt im HTML:

 <div id="SomeDiv" style="width:600px; height:400px;background-color:blue;"> <div data-win-control="DMX.GraphViewer" data-win-options="{ className : 'graphDiv', width : 448, `` height : 168, margin : 10,

        graphs : [                 { className: 'graph', color: 'red', strokeWidth: 2, line: [{ x: 30, y: 7 }, { x: 50, y: 40 }, { x: 100, y: 80 }] },                 { className: 'graph', color: 'green', strokeWidth: 2, line: [{ x: 9, y: 60 }, { x: 9, y: 60 }, { x: 100, y: 60 }] },                 { className: 'graph', color: 'blue', strokeWidth: 2, line: [{ x: 90, y: 7 }, { x: 90, y: 7 }, { x: 90, y: 7 }] },      ]}"></div>  </div> 

Und so sieht das dann zum Beispiel im Simulator aus. (Hintergrund wurde für bessere Sichtbarkeit eingefügt.)

 

Zum Vergleich nochmal unser ursprüngliches Ziel:

Passt schon – oder?

Interessant auch: Wenn wir über JS ein solches Control anbieten zeigt uns Intellitrace nicht die Funktion _createVisualTree für das neue Objekt. Möchten wir echte public Members erzeugen, so dürfen die nicht mit einem Unterstrich anfangen. Legen wir beispielsweise eine Funktion „hurz“ an , so ist diese aber sichtbar:

WinJS.Namespace.define("DMX", { GraphViewer: WinJS.Class.define(function (element, options) { // ... }, { hurz: function(){ // ... }, _createVisualTree: function (o) { // ... }

}

}

Natürlich könnte man hier noch jede Menge feinschleifen. Vor allem würde es sich anbieten die ultralange _createVisualTree mal ein bisschen zu überarbeiten. Für den „echten“ Einsatz, wäre mir die Funktion viel zu lang.  Aber als kleine Einführung, wie man eigene Controls baut, sollte das schon taugen.

Bevor Ihr wahnsinnig werdet, wenn Ihr den obigen Code nicht aufs erste mal versteht und lieber im Visual Studio den komplett formatierten Code sehen möchtet: Das komplette Projekt findet Ihr auch zum Download und zwar hier.

Feedback ist natürlich  willkommen.