İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

Flutter: Widgets überlagern mit Stack und Positioned

Ahmet Balaman

9 Min. Lesezeit

FlutterStackPositionedLayoutBadgeOverlay
Flutter: Widgets überlagern mit Stack und Positioned

Row ordnet Kinder nebeneinander an, Column untereinander. Stack legt sie übereinander: Das erste Kind der Liste wird ganz unten gezeichnet, das letzte ganz oben. Eine Benachrichtigungszahl an der Ecke eines Icons, ein Titel über einem Foto, eine Ladeschicht, die beim Speichern den Bildschirm überdeckt, ein Profilbild, das über den Rand eines Titelbilds ragt: All das beruht auf dieser Idee. In diesem Beitrag geht es darum, wie ein Stack seine Größe bestimmt, um die Parameter fit, alignment und clipBehavior, die Positioned-Familie, häufige Rezepte und die Layoutfehler, denen Sie am häufigsten begegnen.

Grundlegende Verwendung

Stack(
  alignment: Alignment.center,
  children: [
    Container(width: 200, height: 120, color: Colors.indigo),
    Container(width: 120, height: 70, color: Colors.amber),
    const Text('Ganz oben'),
  ],
)

Alle drei Kinder werden zentriert und der Reihe nach übereinandergelegt: zuerst die blaue Box, darauf die gelbe, ganz oben der Text. Flutter kennt keine Eigenschaft wie z-index; welches Widget oben liegt, bestimmt die Reihenfolge in der Liste children.

Die Größe des Stacks und fit

Die Kinder eines Stacks zerfallen in zwei Gruppen. Nicht positionierte Kinder (die nicht mit Positioned umschlossen sind) bestimmen die Größe des Stacks: Der Stack wird so groß wie sein größtes nicht positioniertes Kind. Positionierte Kinder (Positioned) fließen in diese Berechnung nicht ein; sie werden relativ zum Stack platziert, sobald seine Größe feststeht.

Welche Constraints an die nicht positionierten Kinder weitergegeben werden, legt fit fest:

fit Was mit nicht positionierten Kindern passiert
StackFit.loose (Standard) Sie dürfen beliebig klein sein; die Obergrenze ist der Platz, den der Stack erhält
StackFit.expand Sie müssen den größten Platz füllen, den der Stack erhält
StackFit.passthrough Die Constraints des Stacks werden unverändert weitergegeben

StackFit.expand ist besonders nützlich für „Hintergrundbild und die Schichten darauf sollen dieselbe Fläche bedecken“. Noch ein Detail: Hat der Stack gar kein nicht positioniertes Kind, sind also alle Kinder Positioned, wählt der Stack die größtmögliche Größe. Warum das Probleme macht, erklärt der Abschnitt über Fehler weiter unten.

alignment: Wo nicht positionierte Kinder landen

alignment (Standard AlignmentDirectional.topStart, bei Sprachen mit Links-nach-rechts-Schrift die obere linke Ecke) legt fest, wo nicht positionierte Kinder im Stack liegen. Positioned-Kinder sind davon nicht betroffen; lassen Sie aber die Werte für eine Achse weg, gilt auf dieser Achse ebenfalls alignment: Ein Positioned-Kind mit nur top: 8 wird horizontal nach alignment platziert.

Um ein einzelnes Kind an eine andere Stelle zu setzen, können Sie es mit Align umschließen; den Unterschied zwischen Align und Positioned im Stack erkläre ich ausführlich im Align-Beitrag.

Die Positioned-Familie

Positioned platziert sein Kind in Pixeln relativ zu den Kanten des Stacks:

SizedBox(
  width: 300,
  height: 200,
  child: Stack(
    children: [
      const Positioned.fill(child: ColoredBox(color: Colors.black12)),
      const Positioned(top: 8, right: 8, child: Icon(Icons.close)),
      const Positioned(left: 16, right: 16, bottom: 16, child: Text('Unterer Streifen')),
      const PositionedDirectional(top: 8, start: 8, child: Icon(Icons.star)),
      Positioned(top: 60, width: 100, height: 40, child: Container(color: Colors.teal)),
    ],
  ),
)
  • left, top, right, bottom: Abstand zu einer Kante. Sind left und right beide gesetzt, ergibt sich die Breite des Kindes aus dem Abstand dazwischen; so funktioniert der untere Streifen.
  • width, height: Direkte Größe. Pro Achse sind höchstens zwei Werte erlaubt: left, right und width dürfen nicht alle drei gesetzt sein.
  • Positioned.fill: Alle vier Kanten auf 0; das Kind bedeckt den ganzen Stack. Die häufigste Abkürzung für Schichten.
  • PositionedDirectional: Nimmt start/end statt left/right und folgt der Schreibrichtung. In Sprachen mit Rechts-nach-links-Schrift wie Arabisch oder Hebräisch wird start zur rechten Seite. Unterstützt Ihre App mehrere Sprachen, ist es eine gute Gewohnheit, Icons und Buttons damit zu platzieren.

clipBehavior: überstehende Kinder

Das Standard-clipBehavior: Clip.hardEdge schneidet alles ab, was über die Grenzen des Stacks hinausragt. Für ein Badge, das leicht über die Ecke eines Icons ragt, oder einen Avatar, der unter ein Titelbild hängt, setzen Sie clipBehavior: Clip.none. Dabei gibt es eine wichtige Falle: Der überstehende Teil ist sichtbar, empfängt aber keine Berührungen. Flutter führt den Hit-Test innerhalb der Grenzen des Elternelements durch; ein Tippen auf ein Pixel außerhalb des Stacks erreicht das Kind nie. Soll der überstehende Teil antippbar sein, machen Sie den Stack groß genug, dass er ihn einschließt. Das Mini-Szenario unten macht genau das. Zur Gestenerkennung lesen Sie den GestureDetector-Beitrag.

Rezept 1: ein Benachrichtigungs-Badge

class CartIconWithBadge extends StatelessWidget {
  const CartIconWithBadge({super.key, required this.count});

  final int count;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;

    return Stack(
      clipBehavior: Clip.none,
      children: [
        const Icon(Icons.shopping_cart_outlined, size: 28),
        if (count > 0)
          Positioned(
            top: -6,
            right: -8,
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
              decoration: BoxDecoration(
                color: colors.error,
                borderRadius: BorderRadius.circular(10),
              ),
              constraints: const BoxConstraints(minWidth: 18),
              child: Text(
                count > 99 ? '99+' : '$count',
                textAlign: TextAlign.center,
                style: TextStyle(color: colors.onError, fontSize: 11),
              ),
            ),
          ),
      ],
    );
  }
}

Das Icon bestimmt die Größe des Stacks; das Badge ragt mit negativen Werten für top und right über die Ecke hinaus und wird dank Clip.none nicht abgeschnitten. Für eine Standard-Benachrichtigungszahl müssen Sie das nicht selbst schreiben: Das eingebaute Badge-Widget von Material 3 erledigt dasselbe und nutzt die Theme-Farben von selbst: Badge.count(count: count, isLabelVisible: count > 0, child: const Icon(Icons.shopping_cart_outlined)). Mit einem Stack selbst zu bauen, lohnt sich, wenn Form oder Inhalt des Badges vom Standard abweichen.

Rezept 2: ein Titel über einem Bild

class ImageCaptionCard extends StatelessWidget {
  const ImageCaptionCard({super.key, required this.image, required this.title});

  final ImageProvider image;
  final String title;

  @override
  Widget build(BuildContext context) {
    return ClipRRect(
      borderRadius: BorderRadius.circular(16),
      child: AspectRatio(
        aspectRatio: 16 / 9,
        child: Stack(
          fit: StackFit.expand,
          children: [
            Image(image: image, fit: BoxFit.cover),
            DecoratedBox(
              decoration: BoxDecoration(
                gradient: LinearGradient(
                  begin: Alignment.topCenter,
                  end: Alignment.bottomCenter,
                  colors: [
                    Colors.transparent,
                    Colors.black.withValues(alpha: 0.7),
                  ],
                  stops: const [0.5, 1.0],
                ),
              ),
            ),
            Positioned(
              left: 16,
              right: 16,
              bottom: 12,
              child: Text(
                title,
                maxLines: 2,
                overflow: TextOverflow.ellipsis,
                style: Theme.of(context).textTheme.titleMedium?.copyWith(
                      color: Colors.white,
                    ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

StackFit.expand streckt Bild und Verlauf über die ganze Karte, AspectRatio fixiert das Seitenverhältnis und ClipRRect rundet die Ecken. Der nach unten dunkler werdende Verlauf hält den weißen Text lesbar, egal welche Farben das Bild hat. Die Textfarbe ist hier bewusst fest auf Weiß gesetzt: Der Text liegt immer auf einem dunklen Verlauf, nicht auf der Oberfläche des Themes. Als ImageProvider können Sie AssetImage('assets/cover.jpg') oder NetworkImage(url) übergeben; die Details zum Einbinden von Bildern stehen im Beitrag zum Hinzufügen von Bildern.

Rezept 3: eine Schicht, die beim Speichern den Bildschirm sperrt

class SaveOverlay extends StatelessWidget {
  const SaveOverlay({super.key, required this.isSaving, required this.child});

  final bool isSaving;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        child,
        if (isSaving) ...[
          Positioned.fill(
            child: ModalBarrier(
              dismissible: false,
              color: Theme.of(context).colorScheme.scrim.withValues(alpha: 0.3),
            ),
          ),
          const Positioned.fill(
            child: Center(child: CircularProgressIndicator()),
          ),
        ],
      ],
    );
  }
}

Der Seiteninhalt (child) bestimmt die Größe des Stacks; die Schichten bedecken mit Positioned.fill genau diese Fläche. ModalBarrier fängt Berührungen ab, die für den Inhalt darunter gedacht sind, sodass der Nutzer den Button während des Speicherns nicht ein zweites Mal drücken kann. Positioned.fill ist hier keine Geschmacksfrage, sondern notwendig: ModalBarrier will so groß wie möglich werden; als nicht positioniertes Kind würde es auch den Stack vergrößern und innerhalb eines scrollbaren Bereichs einen Fehler wegen unendlicher Höhe auslösen.

IndexedStack: immer nur ein Kind

Auch IndexedStack hält seine Kinder übereinander, zeigt aber nur das mit der Nummer index. Die anderen bleiben im Baum, ihr State geht also nicht verloren: Scrollen Sie in einem Tab eine Liste nach unten und wechseln den Tab, sind Sie bei der Rückkehr wieder an derselben Stelle. Das ist der Standardweg, den Seitenzustand beim Tabwechsel über eine untere Navigationsleiste zu erhalten; ein Beispiel steht im BottomNavigationBar-Beitrag.

Mini-Szenario: ein Profil-Header

Ein Layout, das man in sozialen Apps oft sieht: oben ein farbiger Titelbereich, rechts oben ein Button zum Bearbeiten des Titelbilds, links unten ein runder Avatar, der über den Rand des Titelbereichs ragt, und an seiner Ecke ein Online-Indikator.

class ProfileHeader extends StatelessWidget {
  const ProfileHeader({super.key});

  static const _coverHeight = 140.0;
  static const _avatarRadius = 44.0;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;

    return SizedBox(
      height: _coverHeight + _avatarRadius,
      child: Stack(
        children: [
          Positioned(
            top: 0,
            left: 0,
            right: 0,
            height: _coverHeight,
            child: DecoratedBox(
              decoration: BoxDecoration(
                gradient: LinearGradient(
                  colors: [colors.primary, colors.tertiary],
                ),
              ),
            ),
          ),
          PositionedDirectional(
            top: 8,
            end: 8,
            child: IconButton.filledTonal(
              onPressed: () {},
              tooltip: 'Titelbild ändern',
              icon: const Icon(Icons.edit_outlined),
            ),
          ),
          PositionedDirectional(
            start: 16,
            bottom: 0,
            child: Stack(
              clipBehavior: Clip.none,
              children: [
                CircleAvatar(
                  radius: _avatarRadius,
                  backgroundColor: colors.surface,
                  child: CircleAvatar(
                    radius: _avatarRadius - 4,
                    backgroundColor: colors.primaryContainer,
                    child: Text(
                      'AB',
                      style: TextStyle(
                        fontSize: 24,
                        color: colors.onPrimaryContainer,
                      ),
                    ),
                  ),
                ),
                Positioned(
                  right: 4,
                  bottom: 4,
                  child: Container(
                    width: 18,
                    height: 18,
                    decoration: BoxDecoration(
                      color: Colors.green,
                      shape: BoxShape.circle,
                      border: Border.all(color: colors.surface, width: 3),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Die zentrale Entscheidung in diesem Layout: Statt den Avatar mit einem negativen bottom-Wert hinauszuschieben, wird der Stack so hoch wie Titelbereich + Avatarradius. Der Titelbereich belegt die oberen 140 Pixel, der Avatar sitzt am unteren Rand des Stacks und ragt zur Hälfte in den Titelbereich. So bleibt der Avatar vollständig im Stack: Er ist antippbar, überdeckt nicht den Inhalt darunter, und das nächste Widget in einer Column beginnt an der richtigen Stelle. Zur Rolle der SizedBox, die dem Stack seine feste Höhe gibt, lesen Sie den SizedBox-Beitrag. Weil Bearbeiten-Button und Avatar mit PositionedDirectional platziert sind, tauschen sie in einer Rechts-nach-links-Sprache automatisch die Seiten. Der Rahmen in der Farbe surface um den Online-Indikator trennt den Punkt vom Avatar und sieht in beiden Themes richtig aus; die Idee der Farbrollen steht im Beitrag zu Theme und ThemeData.

Wann Stack, wann etwas anderes?

Weil Stack so mächtig ist, wird er bei jeder Überlappung als Erstes gewählt, obwohl es oft einen einfacheren Weg gibt. Brauchen Sie hinter einem Widget nur eine Farbe, einen Verlauf oder einen Rahmen, genügt decoration von Container oder eine DecoratedBox. Um ein einzelnes Kind um ein paar Pixel zu verschieben, reicht Transform.translate, um ein einzelnes Kind an eine Ecke zu setzen, Align. Für einen Aktionsbutton unten rechts nutzen Sie statt eines Stacks das Feld floatingActionButton des Scaffold; das Zusammenspiel mit Tastatur und SnackBars regelt der Scaffold dann selbst. Und für eine Standard-Benachrichtigungszahl gibt es das oben gezeigte Badge. Heben Sie Stack für Fälle auf, in denen sich mehrere Schichten wirklich dieselbe Fläche teilen: ein Bild und der Text darauf, Inhalt und die Ladeschicht darüber, ein Titelbild und der Avatar, der es überlappt.

Häufige Fehler

1. Positioned nicht als direktes Kind des Stacks

Setzen Sie ein Positioned in ein Padding, Center oder eine Column oder verwenden Sie es außerhalb eines Stack, erhalten Sie diesen Fehler:

Incorrect use of ParentDataWidget.

Positioned funktioniert nur als direktes Kind eines Stack. Derselbe Fehler erscheint, wenn Sie ein Expanded in einen Stack setzen; Expanded ergibt nur in Row, Column und Flex Sinn (siehe Expanded-Beitrag).

2. Ein Stack nur mit Positioned-Kindern in einer Column oder ListView

Ein Stack ohne nicht positionierte Kinder wählt die größtmögliche Größe. In einer ListView oder einer scrollbaren Column, die vertikal unbegrenzten Platz anbieten, heißt das „unendlich“, und das Layout schlägt fehl. Der Fehlertext hängt von der Version ab: Ab Flutter 3.38 beginnt er mit A Stack requires bounded constraints from its parent, in älteren Versionen sehen Sie RenderStack object was given an infinite size during layout. Die Lösung: Geben Sie dem Stack mit einer SizedBox eine Größe oder ergänzen Sie mindestens ein nicht positioniertes Kind.

3. left, right und width gleichzeitig setzen

Auf derselben Achse dürfen nicht drei Werte gesetzt sein. Positioned(left: 0, right: 0, width: 100, ...) löst einen Assertion-Fehler aus; geben Sie entweder beide Kanten oder eine Kante plus eine Größe an.

4. Der überstehende Teil empfängt keine Berührungen

Ein mit Clip.none herausragender Button lässt sich nicht antippen. Grund ist die oben beschriebene Hit-Test-Regel: Berührungen außerhalb der Grenzen des Elternelements werden nicht erkannt. Machen Sie den Stack groß genug, dass er den überstehenden Teil einschließt.

5. Align oder Center vergrößert den Stack

Haben Sie ein nicht positioniertes Align verwendet, um ein Badge in eine Ecke zu setzen, dehnt sich der Stack auf die Bildschirmbreite aus, weil Align so viel Platz wie möglich beansprucht, und das Badge landet in der Ecke des Bildschirms. Positionieren Sie mit Positioned oder geben Sie dem Stack eine Größe; die Center-Variante desselben Problems steht im Center-Beitrag.

Häufig gestellte Fragen

Wie hole ich ein Widget nachträglich nach vorn oder schiebe es nach hinten?

Flutter hat kein z-index; die Zeichenreihenfolge ist die Reihenfolge der Liste children. Verschieben Sie das Kind, das vorn liegen soll, ans Ende der Liste. Ändert sich die Reihenfolge durch Nutzerinteraktion (etwa weil die ausgewählte Karte nach vorn kommen soll), halten Sie die Liste im State, ändern ihre Reihenfolge und geben jedem Kind einen Key; sonst ordnet Flutter die State-Objekte der Kinder womöglich falsch zu.

Was ist der Unterschied zwischen Stack und Overlay?

Ein Stack hält seine Schichten innerhalb der Fläche, die er selbst belegt. Für ein Dropdown-Menü, einen Tooltip oder eine Vorschlagsbox, die aus einem Bereich heraus über den Rest der Seite schweben muss, nutzt man die Overlay-Infrastruktur von Flutter; das Widget OverlayPortal ist ihre Form, die gut zum Widget-Baum passt. Für Schichten in einem Teil des Bildschirms ist Stack das richtige Werkzeug, für Elemente, die über allem in der App schweben, Overlay.

Lassen sich Positioned-Werte in Prozent angeben?

Positioned nimmt nur Pixel. Für relative Platzierung eignet sich ein nicht positioniertes Align (zum Beispiel Alignment(0.8, -0.6)), oder Sie umschließen den Stack mit einem LayoutBuilder und berechnen die Pixel aus der gelieferten Breite und Höhe.

Kommentare