zurück zum Artikel

Responsive Layouts: Mit Flutter von der Mobile-App zur Web-App

Thomas Schwarzott

(Bild: Prostock-studio/Shutterstock.com)

Das von Google entwickelte SDK Flutter und ein paar Tricks erleichtern das Entwickeln von Anwendungen sowohl für Desktop- als auch für Mobile-Geräte.

Flutter erfreut sich als Software Development Kit (SDK) zur Entwicklung von Cross-Plattform-Mobile-Apps zunehmender Beliebtheit, da es Anbietern von Apps ermöglicht, mit einer Codebasis die beiden großen Zielplattformen Android und iOS zu bedienen.

Im März 2021 veröffentlichte Google Version 2.0 des auf mobile Endgeräte ausgelegten Frameworks. Zur gleichen Zeit konnten Flutter-Entwicklerinnen und -Entwickler das wahrscheinlich wichtigste neue Feature in der als stabil geltenden Version testen: Flutter Web [1]. Damit lassen sich Webanwendungen mit Flutter und Dart erstellen, einer ebenfalls von Google entwickelten Programmiersprache, die auf Cliententwicklung für Webanwendungen und mobile Clients ausgelegt ist. Für Letztere kommt das Cross-Plattform-Framework Flutter ins Spiel.

Die mobilen Ansichten responsiver Webseiten orientieren sich heute vor allem an User-Interface-(UI)-Patterns von Mobile-Anwendungen, etwa Drawer-Navigation. Wie sehr sich Website und App ähneln, zeigt beispielsweise die Website zum Sportmagazin kicker (s. Abb. 1). Der Haupt-Content behält bei beiden Ansichten die Reihenfolge bei: Bild, Headline und Text. Die Navigation ist über einen Icon-Button in der Header-Bar erreichbar, gleitet in Browser und App von links als Overlay in den Viewport und weist eine beinahe identische hierarchische Gliederung auf.

Website- und App-Ansicht von kicker (Abb. 1)

(Bild: kicker.de [2])

Im Web herrschte lange "Gewaltenteilung": HTML für die Struktur, CSS für das Styling und JavaScript für Logik und Kontrollfluss. In deklarativen Frameworks wie Flutter und React verschwimmen die Grenzen – Komponenten werden ineinander verschachtelt und erhalten Callbacks und Styling-Angaben als Parameter. Dabei ist es umso wichtiger, mit ausgelagerten Klassen und Services zu arbeiten, um Übersichtlichkeit und Wartbarkeit des Codes aufrechtzuerhalten. Grundsätzlich hilft in diesem Fall eine saubere und disziplinierte objektorientierte Programmierung. In der Praxis jedoch lässt sich Spaghetti-Code an der einen oder anderen Stelle aufgrund von komplexen Logik- und UI-Strukturen, Sonderfällen und vielen Abhängigkeiten häufig nur durch eine sehr elaborierte Projektarchitektur vermeiden.

Nach der ersten Euphorie befällt Entwicklerinnen und Entwickler daher schnell die Sorge, ob "die eine" Codebasis für drei verschiedene Plattformen wirklich eine gute Idee ist – oder ob sie die entwicklungstechnische Büchse der Pandora öffnen. Üblicherweise klären sich solche Fragen am besten mit Praxistests.

Wie entstehen responsive Ansichten? Während Smartphones – abgesehen von Gaming-Apps – vorwiegend hochkant bedient werden, haben Browserfenster auf dem Desktop tendenziell eher Querformat und sind wesentlich größer. In den ersten Jahren der Verbreitung responsiver Websites wurden sie daher oft via CSS-MediaQueries so erweitert, dass mehrspaltige Elemente zum Umbruch gezwungen waren. Zeilen wurden zu Spalten. Bei vielen optisch vollen Websites mit komplexen UI-Konstrukten führt das zu Herausforderungen für Entwickler.

Mit den Jahren verbreitete sich zunehmend der Mobile-First-Ansatz: Websites werden zunächst für Smartphone-Größen gestaltet und dann zur Desktopgröße hochskaliert. Wenn auch in der Entwicklung diese Reihenfolge eingehalten wird, ist das Ergebnis in der Regel eine saubere, weniger Bug-anfällige Codebasis und nebenbei meist auch eine optisch deutlich aufgeräumtere Website. Die verwendete Technologie – MediaQueries – bleibt hierbei die gleiche.

heise-Konferenz zu Flutter

Die Online-Konferenz betterCode() Flutter gibt Software-, Web- und App-Entwicklern einen Einblick in die Möglichkeiten des Cross-Plattform-Werkzeugs Flutter. Die von heise Developer und dpunkt.verlag organisierte Veranstaltung findet am 24. Mai 2022 ganztägig statt. Das Event richtet sich an Interessierte, die sich tiefergehend mit Flutter auseinandersetzen möchten. Informationen und Tickets gibt es über die Konferenz-Webseite [3].

Wie lässt sich nun responsives Layout für alle denkbaren Bildschirmgrößen umsetzen? Flutter stellt dafür diverse Widgets und Klassen bereit, zum Beispiel die LayoutBuilder [4]- oder MediaQuery-Klassen [5].

Wichtig für beide Ansätze: Wird der Viewport der Anwendung in seiner Größe verändert, führt Flutter automatisch die build-Funktion aus und triggert so ein neues Rendering. Es ist kein eigener Event Listener für das Resizing des Viewports notwendig.

Damit das Layout sauber auf die gegebenen Größenparameter reagiert, müssen die Inhaltskomponenten in den richtigen Widgets verschachtelt sein. Dazu gilt es zunächst die Navigation zu betrachten. Der Einfachheit halber wird in diesem Beispiel nur eine Ebene implementiert.

Die Navigation ist in der mobilen Ansicht wegen des geringen Platzes auf ein Logo und ein Hamburger-Icon beschränkt. Das Hamburger-Icon triggert onTap, also nach Berührung, das Öffnen des Menüs in einem Drawer-Widget. Damit sind die Navigationselemente aufgeräumt. Nutzer erreichen diese aber durch das von vielen Anwendungen gelernte Drawer/Sidebar-Verhalten, bei dem eine Navigation nach Tap auf ein Menü-Icon von rechts oder links in den Viewport fährt und die verfügbaren Optionen präsentiert.

Um dieses Layout umzusetzen, können sich Entwickler an einem Row-Widget bedienen, das ein Logo und Hamburger-Icon als Children erhält. Da Row- und Column-Widgets in Flutter ein CSS-Flexbox-ähnliches Verhalten haben, können Children in diesen Widgets in der gewünschten Reihenfolge übergeben werden und das Parent-Widget verteilt die Children je nach aktuellem cross- oder mainAxisAlignment-Setting. Dabei nehmen die Children jeweils nur ihre nötige Mindestbreite an. Der restliche Platz bleibt frei. MainAxisAlignment.spaceBetween sorgt dafür, dass der gesamte freie Platz in der Row zwischen den Children aufgezogen wird. Dadurch lässt sich das Logo am linken Ende der Row positionieren, das Hamburger-Icon am rechten Ende.

class MobileNavbar extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return Container(
     padding: EdgeInsets.symmetric(horizontal: 30),
     child: Row(
       mainAxisAlignment: MainAxisAlignment.spaceBetween,
       children: [
         SvgPicture.asset('images/logo.svg'),
         IconButton(
           icon: Icon(Icons.menu),
           onPressed: () => Scaffold.of(context).openEndDrawer(),
         ),
       ],
     ),
   );
 }
}

Der Drawer selbst muss dem Scaffold-Widget der Page ĂĽbergeben werden. Da er von rechts in den Viewport fahren soll, ist er als endDrawer zu definieren. Dabei stellt drawerEnableOpenDragGesture sicher, dass der Drawer nur mobil zum Einsatz kommt.

Scaffold(
 backgroundColor: Color(0xFFFFFFFF),
 drawerEnableOpenDragGesture: !isDesktop,
 endDrawer: isDesktop
   ? null
   : Drawer(
     child: SafeArea(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Learn'),
            SizedBox(height: 40),
            Text('Inspire'),
            SizedBox(height: 40),
            Text('About'),
            SizedBox(height: 40),
            Text('Info'),
            SizedBox(height: 40),
            Text('Help'),
          ],
        ),
      ),
    ),
  ďż˝
)

Die Desktop-Ansicht zeigt nun eine klassische Navigation an, die alle Elemente horizontal nebeneinander einreiht.

class DesktopNavbar extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return Container(
     constraints: BoxConstraints(
       maxWidth: 1200,
     ),
     decoration: BoxDecoration(
       border: Border(
         bottom: BorderSide(
           color: Color(0xFFF3F3F4),
         ),
       ),
     ),
     child: Row(
       children: [
         SvgPicture.asset('images/logo.svg'),
         SizedBox(width: 20),
         Row(
           children: [
             Text('Learn'),
             SizedBox(width: 20),
             Text('Inspire'),
             SizedBox(width: 20),
             Text('About'),
             SizedBox(width: 20),
             Text('Info'),
             SizedBox(width: 20),
             Text('Help'),
           ],
         ),
         Spacer(),
         IconButton(icon: Icon(Icons.search), onPressed: () {}),
         LoginButton(),
         RegisterButton(),
       ],
     ),
   );
 }
}

Beim Größer- und Kleinerziehen der Seite in der Desktopansicht fällt auf, dass sich nur der Whitespace zwischen den Navigationselementen und den Buttons am rechten Ende sowie der Whitespace zwischen Navigationsbereich und rechtem und linkem Bildschirmrand verändert. Dabei ist der Navigationsbereich im Viewport immer horizontal zentriert.

Dieses Verhalten kennen die Besucher von Websites, die mit CSS-Frameworks wie Bootstrap aufgesetzt wurden (s. Abb. 2). Dort gibt es in der Regel einen zentrierten Inhaltsbereich, der eine gewisse maximale Breite annimmt, danach jedoch nicht weiter wächst. Das verhindert, dass beim Verarbeiten der Inhalte die Augen vom linken bis zum rechten Bildschirmrand wandern müssen. Vor allem textlastige Websites machen sich das zunutze, um die Lesbarkeit der Teaser und Artikel zu erhöhen.

Medium-Website mit von Bootstrap bekanntem Spaltenaufbau (Abb. 2)

(Bild: medium.com [6])

Dieses Verhalten lässt sich in Flutter mit einfachen Basis-Widgets umsetzen. Mit einem Blick in den Scaffold fällt im Body eine Column auf, die je nach Mobile- oder Desktopansicht die entsprechenden Widgets als Children erhält. crossAxisAligment dient dazu, die Children horizontal zu zentrieren.


Scaffold(
 ďż˝
  body: SafeArea(
    child: SingleChildScrollView(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.center,
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
    isDesktop ? _buildDesktopPage() : _buildMobilePage(context)
        ],
      ),
    ),
  ),
)

Die DesktopNavbar wiederum enthält als erstes Child einen Container, dessen Breite auf 1200 Pixel begrenzt ist.

class DesktopNavbar extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      constraints: BoxConstraints(
        maxWidth: 1200,
      ),
    );
  }
}

Damit wird der Haupt-Content horizontal zentriert und wächst nur bis zu einer gewissen Breite. Doch warum wächst der Whitespace zwischen den Navigationselementen und den Buttons am rechten Ende der Navigation? Die Children der Navbar-Row geben Aufschluss darüber:

Row(
  children: [
    SvgPicture.asset('images/logo.svg'),
    SizedBox(width: 20),
    Row(
      children: [
        Text('Learn'),
        SizedBox(width: 20),
        Text('Inspire'),
        SizedBox(width: 20),
        Text('About'),
        SizedBox(width: 20),
        Text('Info'),
        SizedBox(width: 20),
        Text('Help'),
      ],
    ),
    Spacer(),
    IconButton(icon: Icon(Icons.search), onPressed: () {}),
    LoginButton(),
    RegisterButton(),
  ],
)

Dort befindet sich an der betreffenden Stelle ein Spacer-Widget. Damit lässt sich ein Anker platzieren, an dem sich der restliche Whitespace bei mehreren Children an einer Stelle konzentrieren kann. Daher ordnen sich in diesem Fall Logo und Navigationspunkte links und die Buttons rechts an, während der gesamte übrige Whitespace zwischen den beiden Bereichen gesammelt wird. Dieser Whitespace wächst, je breiter der vorhandene Platz ist.

Warum aber ist die Navbar in ein Desktop- und ein Mobile-Widget getrennt? Diese Frage ist durchaus berechtigt, da die einzelnen Elemente der Navigation auch einfach in ein Navbar-Widget zusammengefasst und die Widgets darin je nach Desktop- oder Mobile-Ansicht aus- und eingeblendet werden könnten. Um die Komplexität in den einzelnen Widgets möglichst gering zu halten, wurde die Unterscheidung im Code frühzeitig gesetzt. So muss auch die Viewport-Größe seltener geprüft werden. Der in Kauf genommene Nachteil (Trade-off) ist natürlich der potenziell erhöhte Wartungsaufwand durch die doppelte Verwendung von Widgets.

Allerdings kann es bei anderen Widgets dazu kommen, dass die Reihenfolge der Children responsiv angepasst werden muss. Erhöht sich dabei die Komplexität durch Desktop-/Mobile-Unterscheidungen noch weiter, kann es zu Komplikationen bei der Erweiterung, Anpassung oder Wartung der Komponente kommen.

Die Stage sieht auf den ersten Blick recht simpel aus: Neben- oder untereinander soll sie einen Textblock und ein Bild anzeigen, beide Elemente vor einem einheitlichen hellgrauen Hintergrund. Auch hier sollen die Inhaltselemente wieder zentriert mit einer Maximalbreite und Abstand nach links und rechts angeordnet werden, um das Basis-Layout der Navigation konsistent weiterzufĂĽhren.

Daraus ergeben sich folgende kleine Challenges:

Bei der Abbildung der Reihenfolge hilft die Unterscheidung zwischen Mobile- und Desktop-Widgets. So lassen sich die Children jeweils in der richtigen Abfolge in DesktopStage und MobileStage platzieren. Wichtig ist hierbei, dass bei Mobile eine Column benutzt wird, da die Widgets untereinander erscheinen sollen. Auf dem Desktop sorgt hingegen eine Row fĂĽr die Darstellung der Widgets nebeneinander.

Für die Realisierung des grauen Hintergrundes über die volle Viewport-Breite kommt ein klassisches Bootstrap-Container-Verhalten zum Einsatz. Bei Bootstrap haben Container immer eine gewisse Maximalbreite – je nach Größe des Viewports – und werden horizontal zentriert. Die entsprechende Verschachtelung von Elementen ermöglicht, dass die Elemente in Containern links und rechts gleich ausgerichtet sind und eine gemeinsame Flucht entsteht. Die Eltern der Container behalten hingegen grundsätzlich die volle Viewport-Breite und lassen sich dann entsprechend mit einer Hintergrundfarbe oder einem Hintergrundbild belegen.

In diesem Beispiel läuft ein Container-Widget mit der grauen Hintergrundfarbe über die volle Breite. In ihm wird ein Center-Widget platziert, welches wiederum einen Container als Child erhält. Der constraints-Parameter begrenzt den Container auf eine maximale Breite von 600 Pixeln bei Mobile und 1200 Pixeln auf dem Desktop. Der Container erhält eine Row mit den Stage-Komponenten.

class MobileStage extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return Container(
     color: Color(0xFFf9f8fd),
     child: Center(
       child: Container(
         constraints: BoxConstraints(maxWidth: 600),
         child: Column(
           children: [
             StageImage(),
             StageTextBlock(),
           ],
         ),
       ),
     ),
   );
 }
}

Die letzte Herausforderung ist das saubere, responsive Umbrechen des Textes in der Stage. Leider kommt es bei der Nutzung von Text-Widgets häufig vor, dass Texte nicht optimal umbrechen. Flutter trennt Wörter meistens nicht mit korrekter Silbentrennung. Deshalb benötigt das Elternelement eine fest definierte Breite. Das sollte man allerdings mit Blick auf eine hohe Responsivität vermeiden. Auch die dynamische Berechnung von MediaQueries und fest definierten Faktoren ist auf Dauer umständlich.

Hier hilft das RichText-Widget, das den Text und den zugehörigen Style als TextSpan übergibt. Knackpunkt ist hierbei dessen softWrap-Parameter, der auf true gesetzt wird. Das Ergebnis: ein automatisch richtig und sauber umgebrochener Text:

class StageSubline extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return RichText(
     text: TextSpan(
       text:
           'Running out of space in your head? 
           You don\'t know where to put all your ideas and creativity?',
       style: AppTextStyles.regularText,
     ),
     softWrap: true,
   );
 }
}

So sieht das Praxisbeispiel am Ende aus:

Desktop-Browser- und Mobile-App-Ansicht im Vergleich (Abb. 3)

Der Clou: der Code funktioniert sowohl als responsive Website, als auch als App auf dem Handy – hier zu sehen im iOS-Simulator unter macOS.

Mobile-Browser- und Mobile-App-Ansicht im Vergleich (Abb. 4)

Das Erstellen einer responsiven Webanwendung ist mit Flutter gut umsetzbar. Die verfügbaren Layout-Möglichkeiten funktionieren auch für große Desktopauflösungen und Tools wie MediaQueries ermöglichen es den Entwicklern, die Kontrolle über das responsive Layout zu behalten. Wer aus dem Web-Bereich kommt und die Layout-Konzepte von CSS kennt, dem fällt es umso leichter.

Für gut wartbaren Code sind allerdings eine saubere Architektur und Disziplin notwendig. Wer mithilfe von Konzepten wie Domain-driven Design (DDD) eine stabile und skalierbare Architektur aufbaut, hat dafür jedoch beste Voraussetzungen. Zudem lohnt es sich, auch über die Auslagerung der Bestandteile des UI – für Mobile und Desktop – in eigenen Dart-Packages nachzudenken.

Thomas Schwarzott
ist als Entwickler bei der bytabo GmbH – Digital Crew tätig, die Mittelständler:innen bei der Digitalen Transformation begleitet. Er beschäftigt sich dort insbesondere mit der Frontend-Umsetzung von Web- und Mobile-Apps.

(mdo [7])


URL dieses Artikels:
https://www.heise.de/-6536014

Links in diesem Artikel:
[1] https://www.heise.de/news/Cross-Plattform-Entwicklung-Flutter-2-will-den-Desktop-erobern-5071507.html
[2] https://www.kicker.de/
[3] https://flutter.bettercode.eu/
[4] https://api.flutter.dev/flutter/widgets/LayoutBuilder-class.html
[5] https://api.flutter.dev/flutter/widgets/MediaQuery-class.html
[6] https://medium.com/
[7] mailto:mdo@ix.de