Ungehemmte Zwänge

oder wie man sich mit iOSLayoutConstraints anfreundet

Für viele Xojo-iOS-Anwender ist, liest man das Forum, das AutoLayout von iOS ein Grund, regelmäßig das Kriegsfahrrad auszubuddeln. Die Ähnlichkeit der IDE bei der Gestaltung von iOS-Benutzerinterfaces mit der einer Desktop-Anwendung verleitet dazu, den gleichen Arbeitsstil zu benutzen: Steuerelement aufs Layout ziehen und erst einmal ausprobieren; Feinangleichung kann später folgen.

Was auf dem Desktop funktioniert, geht bei iOS gerne in die Hose: Die Steuerelemente stehen einfach nicht dort, wo sie stehen sollten. Rotiert man das iOS-Gerät oder schaltet im Simulator auf eines mit einem anderen Display, wird es ganz übel – AutoLayout greift ein und erzeugt einfach nicht das gewünschte Bild.

Autolayout Desktop <> Autolayout iOS

Der Grund liegt auf der Hand: Auf dem Desktop erfolgt das automatische Layout über ein rahmenbasiertes System zusammen mit dem Locking von Steuerelement-Seiten, unter iOS sorgen Constraints für die Positionsberechnungen. Darob lohnt sich einmal genauere Betrachtung:

Frame-basiertes Layout

Anbei ein Canvas auf einem Desktop-Fenster. Seine Positionsangaben können über Left, Top, Width und Height eingestellt werden, und sind alle 4 Schlösser verriegelt, bleibt der Abstand zu den entsprechenden Fensterpositionen bei einer Größenveränderung des Fensters konstant.

Desktop1Canvas.png

An seine Grenzen stößt dieses rahmenbasierte Layout schon, wenn nur ein zweites Element dazukommt. Habe ich zwei nebeneinanderliegende Canvas-Controls, ist es ohne Code im Resizing-Event des Fensters nicht möglich, ihnen bei Skalierungen beizubringen, identische Breiten zu behalten (hier mal beide unterschiedlich gefärbt).

Desktop2Canvas

Entweder beide überschneiden sich, oder einer behält eine feste Breite, während der andere sich dynamischer verhält. Der Code im Window, um beide auf einem Abstand von 22 Punkt zueinander zu halten, sähe bei einem Außenabstand von 20 pt so aus:

Sub Resizing() Handles Resizing
 canvas1.Width = Self.Width/2-31 // Left + 22/2 abgezogen
 canvas2.Width = canvas1.Width
 canvas2.Left = Self.Width/2+11 // 22/2 dazu
End Sub

Das ist in diesem Fall noch ein vertretbarer Programmieraufwand. Wird das Layout komplizierter, legt man sich aber bald die Karten. Kommt dazu, dass das Locking der Steuerelemente bei Größenveränderungen nicht richtig greift: Ist der rechte Canvas oben, unten und rechts verriegelt, muss nach seiner Breitenveränderung trotzdem die linke Position neu gesetzt werden, soll er sich nicht aus dem Fenster rechts herausbewegen oder sichtbar schmaler werden – deshalb die letzte Code-Zeile im Eventhandler. So richtig Auto ist das Layout nicht mehr. Wenngleich hoch flexibel und anpassbar – wenn man alles von Hand programmieren mag.

iOSLayoutConstraints

Unter iOS gibt es – zumindest bei Xojo – kein rahmenbasiertes Layout. Stattdessen wird alles, was das Layout angeht, über LayoutConstraints berechnet: Gleichungen, die von der Layoutengine durchgerechnet werden, und deren Ergebnisse dann die Position und Größe des Steuerelements bestimmen. Das heißt, die Properties Left, Top, Width und Height gibt es zwar auch als Eigenschaften der iOSControl-Unterklassen, aber sie können nur gelesen werden. Constraint kann man als Bedingung, Zwang oder Hemmung übersetzen, und das ist auch, was die LayoutConstraints machen: Sie definieren Bedingungen für die Positionierung von graphischen GUI-Elementen. Oder, akademischer.

Constraints geben eine Bedingung vor, die zwischen definierten Dimensions-Ankerpunkten eines oder zweier GUI-Elemente eingehalten werden sollen. 

Also etwa: „Die linke Kante von Canvas2 soll gleich der rechten Kante von Canvas1 sein.“

 

Leading.png

Setzt man in Xojo ein LayoutConstraint in der IDE, ist Objekt 1 das gerade in Arbeit befindliche Steuerelement. Objekt 2 kann das View oder Mutterelement ein, sofern Objekt 1 in einem anderen Control oder SplitView eingebunden ist, aber auch Objekt 1 selbst, eine der beiden „Hilfslinien“ des Views (TopLayoutGuide bzw. BottomLayoutGuide), die Titel, Buttons und Tableisten berücksichtigen oder auch gar nichts (Nil), wenn man nur eine Konstante zuweisen will.

Ein LayoutConstraint mit der festen Height 200:

ConstraintHeight200.png

sieht im Detail in Xojo so aus:

ConstraintHeight200Detail.png

In obiger Schreibung wär das

Canvas1.Height = 1.0 x Nil.AttributeTypes.None + 200

(Nur um noch einmal zu verdeutlichen, dass die Form eines Constraints immer gewahrt bleibt, selbst wenn nur eine Konstante zugewiesen wird.)

Wollen Sie so ein Constraint via Code zuweisen, dann am besten so:

Canvas1.AddConstraint (New iOSLayoutContstraint (Canvas1, iOSLayoutConstraint.AttributeTypes.Height, iOSLayoutConstraint.RelationTypes.Equal, Nil, iOSLayoutConstraint.AttributeTypes.None, 1.0, 200, 1000))

Die Konstante (in Xojo: Offset) kann übrigens auch negativ sein, und es gibt eine vordefinierte Konstante, StandardGap, also Standard-Abstand, die einen Abstand gemäß Apple-Designrichtinien darstellt.

Die 1000 als Wert für die Priorität wird weiter unten genauer betrachtet.

Beachtenswertes

Eine verblüffende Feststellung am Anfang:

iOSLayoutConstraints sind keine Zuweisungen, sondern Gleichungen!

Wenn Sie, schon wegen der Ähnlichkeit mit einer Property-Zuweisung in Xojo naheliegend, gedacht haben, ein Constraint der Art des bebilderten Beispiels würde eine Zuweisung zu Canvas 1’s Trailing (die beendende Kante in der Leserichtung des Systems) bedeuten: Weit gefehlt!

Vielmehr nimmt sich die Layoutengine alle Constraints eines Objekts ähnlich wie bei Computed Properties in Xojo vor: Wird zur Berechnung ein anderer Wert, ganz egal ob nun vom selben Objekt oder einem anderen zugehörig benötigt, so wird zunächst dieser gerechnet. Dabei versucht sie, die Gleichung zu erfüllen. Es kann in diesem Beispiel also auch Canvas2.Leading (die „vordere“ Kante in Leserichtung) verändert werden, wenn die Constraints beider Canvase eine solche Berechnung nahelegen.

Weist man ein LayoutConstraint per Programmcode zu, dann kommt noch eine kleine Schwierigkeit hinzu: Aus naheliegenden Gründen kann nicht jedes Attribut mit jedem beliebigen anderen kombiniert werden. Eine untere Kante etwa kann nicht am horizontalen Mittelpunkt desselben Objekts ausgerichtet werden.

Keine Doppeldeutigkeiten bitte!

Auch wenn Sie ein großer Freund der gepflegten Ironie sein sollten: AutoLayout will es eindeutig! Und wann ist ein Rechteck eindeutig definiert? Wenn mindestens vier Punkte definiert wurden: Left, Top, Width und Height. Dabei können diese auch von hinten quer durchs Auge angegeben werden: Definiert man Top und Bottom, ergibt sich automatisch die Height – logisch, oder?

Deshalb hier mal alle Möglichkeiten des AttributeTypes im Klartext:

None Kein Bezug, also nur Konstante.
Left Die linke Kante des Controls.
Right Die rechte Kante des Controls.
Top Die obere Kante des Controls.
Bottom Die untere Kante des Controls.
Leading Die Kante, die den Beginn in Leserichtung des Systems angibt  (z.B. Deutsch: links, Hebräisch: rechts).
Trailing Die Kante, die das Ende in Leserichtung des Systems angibt  (z.B. Deutsch: rechts, Hebräisch: links).
Width Die Breite des Controls.
Height Die Höhe des Controls.
CenterX Horizontaler Mittelpunkt.
CenterY Vertikaler Mittelpunkt.
Baseline Die Grundlinie des Texts (wie die Unterkante von a, e, c, aber nicht die untere Linie von Buchstaben wie g, j, p). Bei Controls ohne Text identisch mit Bottom.
LeftMargin Left + StandardGap-Abstand.
RightMargin Right + StandardGap-Abstand.
TopMargin Top + StandardGap-Abstand.
BottomMargin Bottom + StandardGap-Abstand.
LeadingMargin Leading + StandardGap-Abstand.
TrailingMargin Trailing + StandardGap-Abstand.

Nicht nur Gleichungen sind möglich, sondern auch Spielräume. Hier die RelationTypes:

LessThanOrEqual (Max) Der Wert muss kleiner oder gleich der Vorgabe sein.
Equal Der Wert muss gleich der Vorgabe sein.
GreaterThanOrEqual (Min) Der Wert muss größer oder gleich der Vorgabe sein.
Beziehungskisten

Tja, und da sich Constraints auf ein anderes Objekt beziehen können, stellt sich die Frage, was besser ist: Jedes Control einzeln für sich constrainen oder auf ein „Master-Objekt“ bezogen?

Am praktischen Beispiel: Der linke Canvas besitzt vier LayoutConstraints. Beim Platzieren eines Controls auf dem Layout versucht Xojo passende Constraints zu erraten. Manchmal funktioniert das ganz gut, aber in der Regel ist ein bisschen Nachhilfe vonnöten, um das Layout auch der eigenen Vorstellung gerecht werden zu lassen. So wie hier:

2ConstraintsCanvas.png

Der rechte Canvas kann ebenso definiert werden, nur dass er sich an der rechten Kante von Canvas1 oder an der rechten Kante des Views (zzgl. StandardGap) ausrichten sollte, oder auch ganz und gar abhängig:

2ConstraintsCanvasRight.png

Beides funktioniert, und es gibt kein Richtig oder Falsch! Alles eine Frage der persönlichen Vorlieben. Der Bezug auf ein anderes Objekt kann viel Änderungsarbeiten überflüssig machen, wenn man nur dessen Constraints ändert. Er kann aber auch sehr viel Arbeit verursachen, wenn man dieses löschen sollte. Xojo ersetzt dann die relativen Constraints durch solche mit reinen Konstanten, und das ist selten das, was man möchte. Also: Etwas Planung tut gut!

Wie sieht es aber aus, wenn man die Eindeutigkeiten verlässt?

Hier mal ein ganz klar fragwürdiges Layout:

2ConstraintsCanvasambiguous.png

Die Höhe von Canvas2 soll ≥ Canvas1.Height sein und zugleich ≤ Canvas1.Height + 10. Ein klarer Widerspruch ohne eindeutige Lösung, weshalb AutoLayout dieses Ergebnis auch als ambiguous bezeichnet – „mehrdeutig“.

Dummerweise werden solche Konflikte nicht in Xojo angezeigt, und es kann bei einem komplexen Layout dazu kommen, dass das Ergebnis anders aussieht als im Layout Editor angezeigt – die Layout-Engine entscheidet sich für eines der beiden Ergebnisse.

Als kleine Hilfestellung: Laden Sie dieses Xojo-iOS-Projekt. Kopieren Sie das CSLayoutConstraint-Modul in Ihr iOS-Projekt (oder spielen Sie erst einmal mit der Demo). Wenn Sie dort auf den „Refresh“-Button klicken, wird das Layout via Declares getestet, und im Zweifelsfall sehen Sie das hier:

CSViewExtension

Ein Klick auf „Exercise“ lässt das Steuerelement mit dem doppeldeutigen Layout eine andere Variante durchspielen.

Das Modul bietet drei bzw. vier Methoden:

  • Public Function HasAmbiguousLayout(extends v as ioscontrol) as Boolean: Liefert einen Booleschen Wert zurück, der verrät, ob ein iOSControl ein mehrdeutiges Constraint-Layout besitzt.
  • Public Sub CheckAmbiguousLayout(extends v as iOSView): Prüft alle Controls eines Views auf Ambiguity und lässt ggf. die obige MsgBox erscheinen.
  • Public Sub CheckAmbiguousLayout(extends v as iOScontrol): Wie oben, nur für ein spezifiziertes Control (und eventuell darin eingebundene).
  • Public Sub ExerciseAmbiguousLayout(extends v as iOSControl): Wechselt zu einer anderen Variante des Layouts bei einer mehrdeutigen Constraint-Konfiguration.

Klarer Fall, dass dieses Modul fürs Debugger Ihrer iOS-App gedacht ist und nicht, um dem Endanwender präsentiert zu werden.

Layouts mit Prioritäten

Oben angekündigt: Sowohl in der IDE als auch programmatisch erwartet ein iOSLayoutConstraint die Angabe einer Priorität. Priority ist standardmäßig 1000 und bringt gegenüber der Namensvermutung eine weitere Besonderheit:

Nur LayoutConstraints mit Priority 1000 sind obligatorisch!

Stößt die Layout-Engine auf ein Constraint mit niedrigerer Priority, wird dieses zwar ebenso durchgespielt. Lässt sich seine Bedingung aber nicht erfüllen, wird diese ignoriert und das Layout erneut ohne die schwache Priority gerechnet. Das präsentierte Ergebnis auf dem Gerätebildschirm ist dann das, das der Priority näher kommt.

Sinnvoll einsetzen lassen sich die Priorities unter 1000 also etwa dort, wo man „Vorlieben“ definieren möchte. Apple gibt dabei das Beispiel eines intelligenten TextFields:

Kompression.png

Hat dieses den Platzbedarf seines Texts errechnet (hier im Pseudocode in TextHeight/TextWidth), kann es Constraints z.B. auf diese Art setzen (Rahmenabstand ein definierter Abstand, der um den Text gelassen werden sollte):

// Widerstand:
me.Height ≥ 1.0 x Nil.None + TextHeight + Rahmenabstand, priority 750
me.Widht ≥ 1.0 x Nil.None + TextWidth + Rahmenabstand, priority 750

// Kompression:
me.Height ≤ 1.0 x Nil.None + TextHeight + Rahmenabstand, priority 250
me.Widht ≤ 1.0 x Nil.None + TextWidth + Rahmenabstand, priority 250

Das Ergebnis ist ein Textfield, das eher die Bestrebung hat, den ganzen Text anzuzeigen, anstatt sich zu klein dafür schieben zu lassen.

Nebenbei: Sollte sich beim Umstieg eines iOS-Projekts auf Xojo 2017r2 (oder später) das Layout verändern, können Sie es in der Regel wieder einfangen, indem Sie alle Priorities auf 1000 setzen. In früheren Versionen wurde die Priority nämlich nicht berücksichtigt!

Es empfiehlt sich, die Priorities nicht wild zu verteilen, sondern sie in der Regel den Xojo-Vorgaben anzugleichen (oder sie ggf. leicht zu über- oder unterschreiten, wenn sich zwei Constraints mit gleicher Priorität beißen sollten). Die Werte für die Priority sind:

Highest (obligatorisch!) 1000
High 800
Medium-High 600
Medium 400
Low 200

Sollten Sie bisher mit den Constraints im Clinch gelegen haben: Geben Sie ihnen noch eine Chance! Insbesondere, da sie gegenüber den Desktop-Layouts noch einen Vorteil besitzen:

Constraints werden bei jeder Layout-Änderung neu gerechnet!

Also nicht nur bei einem Resized-Event, sondern auch dann, wenn z.B. ein Steuerelement durch Benutzereingriff verschoben wird.

Eigentlich eine Menge Vorteile, oder? Noch viel ausführlicher finden Sie das alles bei Apple beschrieben.

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