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