Perché andare oltre Row, Column e Stack

La maggior parte delle interfacce Flutter si costruisce componendo widget di layout standard: Row, Column, Stack, Wrap, Flex. Prima o poi, però, capita un requisito che con la composizione diventa un labirinto di LayoutBuilder, Positioned e calcoli a mano: una griglia a cascata, un menu radiale, avatar sovrapposti con offset dinamico, un tooltip che si posiziona rispetto a un ancoraggio evitando i bordi dello schermo.

In questi casi Flutter mette a disposizione una scala di strumenti, dal più semplice al più potente:

  1. CustomSingleChildLayout — posiziona e vincola un figlio tramite un delegate.
  2. CustomMultiChildLayout — posiziona più figli identificati da LayoutId.
  3. Flow — layout basato su trasformazioni, ottimizzato per animazioni.
  4. RenderObject custom — controllo totale su misura, posizionamento, paint e hit test.

Prima di scrivere codice, però, serve capire come Flutter calcola un layout.

Il protocollo di layout in tre regole

Il motore di layout di Flutter si riassume in una frase spesso citata:

I vincoli scendono, le dimensioni salgono, il genitore posiziona.

In concreto:

  • Il genitore passa al figlio un BoxConstraints (minWidth, maxWidth, minHeight, maxHeight).
  • Il figlio sceglie la propria Size rispettando quei vincoli.
  • Il genitore decide l'offset del figlio al proprio interno.

Un figlio non sa dove verrà posizionato, e il genitore non può imporre una dimensione se non tramite i vincoli. Questo passaggio unico dall'alto verso il basso e ritorno rende il layout lineare (O(n)) e molto veloce, ma spiega anche perché certe cose "non si possono fare": ad esempio un widget non può dimensionarsi in base alla posizione che assumerà.

Da Flutter è disponibile anche il dry layout (computeDryLayout / getDryLayout): un modo per chiedere "che dimensione avresti con questi vincoli?" senza eseguire davvero il layout. È fondamentale per implementare RenderObject corretti.

CustomSingleChildLayout: posizionare un figlio con logica propria

È lo strumento giusto quando hai un solo figlio e devi decidere vincoli e posizione con una regola non banale. Esempio classico: un pannello ancorato a un punto, che si mantiene dentro i limiti del genitore.

class AnchoredLayoutDelegate extends SingleChildLayoutDelegate {
  AnchoredLayoutDelegate({required this.anchor, this.margin = 8});

  /// Punto di ancoraggio in coordinate del genitore.
  final Offset anchor;
  final double margin;

  @override
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
    // Il figlio può essere al massimo grande quanto lo spazio meno i margini.
    return constraints.loosen().copyWith(
      maxWidth: constraints.maxWidth - margin * 2,
      maxHeight: constraints.maxHeight - margin * 2,
    );
  }

  @override
  Offset getPositionForChild(Size size, Size childSize) {
    // Centro orizzontalmente sull'ancora, poi "clamp" dentro i bordi.
    var dx = anchor.dx - childSize.width / 2;
    var dy = anchor.dy + margin;

    dx = dx.clamp(margin, size.width - childSize.width - margin);
    if (dy + childSize.height > size.height - margin) {
      // Non c'è spazio sotto: ribalto sopra l'ancora.
      dy = anchor.dy - childSize.height - margin;
    }
    return Offset(dx, dy);
  }

  @override
  bool shouldRelayout(AnchoredLayoutDelegate oldDelegate) =>
      anchor != oldDelegate.anchor || margin != oldDelegate.margin;
}

Uso:

CustomSingleChildLayout(
  delegate: AnchoredLayoutDelegate(anchor: tapPosition),
  child: const _TooltipCard(),
)

Punti chiave:

  • getSize (opzionale) definisce la dimensione del widget stesso; per default riempie i vincoli.
  • shouldRelayout deve tornare true solo quando i parametri del delegate cambiano davvero: è la tua ottimizzazione principale.

CustomMultiChildLayout: più figli, ognuno con il suo ruolo

Quando i figli sono più di uno e hanno ruoli semantici diversi, CustomMultiChildLayout è la scelta naturale. Ogni figlio viene marcato con LayoutId e il delegate li misura e posiziona nell'ordine che preferisci — anche in funzione delle dimensioni degli altri.

Esempio: un header in cui il titolo occupa lo spazio residuo lasciato da un badge e da un'azione.

enum _Slot { badge, title, action }

class HeaderLayoutDelegate extends MultiChildLayoutDelegate {
  HeaderLayoutDelegate({required this.gap});

  final double gap;

  @override
  void performLayout(Size size) {
    var used = 0.0;
    Size badgeSize = Size.zero;
    Size actionSize = Size.zero;

    if (hasChild(_Slot.badge)) {
      badgeSize = layoutChild(
        _Slot.badge,
        BoxConstraints.loose(size),
      );
      used += badgeSize.width + gap;
    }

    if (hasChild(_Slot.action)) {
      actionSize = layoutChild(
        _Slot.action,
        BoxConstraints.loose(size),
      );
      used += actionSize.width + gap;
    }

    if (hasChild(_Slot.title)) {
      final titleSize = layoutChild(
        _Slot.title,
        BoxConstraints(
          maxWidth: (size.width - used).clamp(0.0, size.width),
          maxHeight: size.height,
        ),
      );
      positionChild(
        _Slot.title,
        Offset(badgeSize.width + gap, (size.height - titleSize.height) / 2),
      );
    }

    if (hasChild(_Slot.badge)) {
      positionChild(_Slot.badge, Offset(0, (size.height - badgeSize.height) / 2));
    }
    if (hasChild(_Slot.action)) {
      positionChild(
        _Slot.action,
        Offset(size.width - actionSize.width, (size.height - actionSize.height) / 2),
      );
    }
  }

  @override
  bool shouldRelayout(HeaderLayoutDelegate oldDelegate) => gap != oldDelegate.gap;
}

E il widget:

SizedBox(
  height: 56,
  child: CustomMultiChildLayout(
    delegate: HeaderLayoutDelegate(gap: 12),
    children: const [
      LayoutId(id: _Slot.badge, child: CircleAvatar(radius: 20)),
      LayoutId(id: _Slot.title, child: Text('Titolo molto lungo…', maxLines: 1)),
      LayoutId(id: _Slot.action, child: Icon(Icons.more_vert)),
    ],
  ),
)

Regole da rispettare, altrimenti scattano gli assert in debug:

  • ogni figlio deve essere misurato esattamente una volta con layoutChild;
  • ogni figlio deve essere posizionato con positionChild (se non lo fai, finisce a Offset.zero);
  • il delegate non può leggere la dimensione di un figlio che non ha ancora misurato.

Un limite importante: CustomMultiChildLayout non può dimensionarsi in base ai figli (la size è determinata dai vincoli ricevuti o da getSize). Se ti serve un contenitore che si adatta al contenuto, devi passare a un RenderObject custom.

Flow: layout e trasformazioni ottimizzati per l'animazione

Flow è pensato per casi in cui la posizione dei figli cambia continuamente (menu a espansione, carousel, effetti). La sua caratteristica è che i figli vengono disegnati applicando una matrice di trasformazione in fase di paint: cambiare le posizioni non ricalcola il layout, cosa che lo rende molto efficiente per animazioni.

class RadialFlowDelegate extends FlowDelegate {
  RadialFlowDelegate({required this.animation, required this.radius})
      : super(repaint: animation);

  final Animation<double> animation;
  final double radius;

  @override
  void paintChildren(FlowPaintingContext context) {
    final count = context.childCount;
    for (var i = 0; i < count; i++) {
      final angle = (math.pi / (count - 1)) * i + math.pi; // semicerchio
      final distance = radius * animation.value;
      final dx = context.size.width / 2 + math.cos(angle) * distance;
      final dy = context.size.height + math.sin(angle) * distance;

      context.paintChild(
        i,
        transform: Matrix4.translationValues(
          dx - context.getChildSize(i)!.width / 2,
          dy - context.getChildSize(i)!.height / 2,
          0,
        ),
      );
    }
  }

  @override
  bool shouldRepaint(RadialFlowDelegate oldDelegate) =>
      animation != oldDelegate.animation || radius != oldDelegate.radius;
}

Attenzione: con Flow i figli disegnati fuori dai limiti del widget possono essere tagliati e la gestione del tocco segue le trasformazioni applicate, quindi verifica sempre l'hit test con debugPaintPointersEnabled o provando l'interazione reale.

Il livello più basso: un RenderObject su misura

Quando serve un contenitore che si dimensiona in base ai figli, che espone dimensioni intrinseche o che partecipa in modo particolare al paint, si scende al livello di RenderBox.

Esempio concreto e molto richiesto: una lista di avatar sovrapposti, larga quanto serve, con overlap configurabile.

1. Il widget

class AvatarStack extends MultiChildRenderObjectWidget {
  const AvatarStack({
    super.key,
    this.overlap = 0.35,
    required super.children,
  });

  /// Percentuale di sovrapposizione (0 = nessuna, 0.5 = metà).
  final double overlap;

  @override
  RenderAvatarStack createRenderObject(BuildContext context) =>
      RenderAvatarStack(overlap: overlap);

  @override
  void updateRenderObject(BuildContext context, RenderAvatarStack renderObject) {
    renderObject.overlap = overlap;
  }
}

2. Il RenderObject

class _AvatarParentData extends ContainerBoxParentData<RenderBox> {}

class RenderAvatarStack extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, _AvatarParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, _AvatarParentData> {
  RenderAvatarStack({required double overlap}) : _overlap = overlap;

  double _overlap;
  double get overlap => _overlap;
  set overlap(double value) {
    if (_overlap == value) return;
    _overlap = value;
    markNeedsLayout(); // richiede un nuovo layout, non solo un repaint
  }

  @override
  void setupParentData(RenderBox child) {
    if (child.parentData is! _AvatarParentData) {
      child.parentData = _AvatarParentData();
    }
  }

  @override
  void performLayout() {
    final childConstraints = BoxConstraints.loose(constraints.biggest);
    var dx = 0.0;
    var height = 0.0;

    var child = firstChild;
    while (child != null) {
      child.layout(childConstraints, parentUsesSize: true);
      final data = child.parentData! as _AvatarParentData;
      data.offset = Offset(dx, 0);

      dx += child.size.width * (1 - overlap);
      height = math.max(height, child.size.height);
      child = data.nextSibling;
    }

    // Recupero la parte "tagliata" dall'ultimo elemento.
    final width = childCount == 0
        ? 0.0
        : dx + (lastChild!.size.width * overlap);

    size = constraints.constrain(Size(width, height));
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    defaultPaint(context, offset);
  }

  @override
  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
    // Testa i figli dall'ultimo al primo: l'avatar in cima riceve il tocco.
    return defaultHitTestChildren(result, position: position);
  }
}

Cosa sta succedendo:

  • ContainerRenderObjectMixin fornisce la lista concatenata dei figli (firstChild, nextSibling).
  • parentUsesSize: true indica che il genitore legge la size del figlio: senza questo flag Flutter non può ottimizzare correttamente i relayout boundary.
  • size = constraints.constrain(...) garantisce che la dimensione scelta rispetti i vincoli ricevuti.
  • defaultPaint e defaultHitTestChildren arrivano da RenderBoxContainerDefaultsMixin e gestiscono ordine di disegno e tocco.

3. Dimensioni intrinseche e dry layout

Se il tuo RenderObject verrà usato dentro IntrinsicWidth, Row con IntrinsicHeight o Table, conviene implementare anche gli intrinsics e il dry layout:

@override
double computeMinIntrinsicHeight(double width) {
  var height = 0.0;
  var child = firstChild;
  while (child != null) {
    height = math.max(height, child.getMinIntrinsicHeight(width));
    child = (child.parentData! as _AvatarParentData).nextSibling;
  }
  return height;
}

@override
Size computeDryLayout(BoxConstraints constraints) {
  var width = 0.0;
  var height = 0.0;
  RenderBox? child = firstChild;
  Size lastSize = Size.zero;

  while (child != null) {
    lastSize = child.getDryLayout(BoxConstraints.loose(constraints.biggest));
    width += lastSize.width * (1 - overlap);
    height = math.max(height, lastSize.height);
    child = (child.parentData! as _AvatarParentData).nextSibling;
  }
  if (childCount > 0) width += lastSize.width * overlap;
  return constraints.constrain(Size(width, height));
}

Gli intrinsics sono costosi (possono far ripetere il layout più volte): implementali solo se servono davvero.

Come scegliere: una tabella mentale

  • Align, Stack + Positioned, Wrap, Flexible → prima scelta. Se la logica si esprime componendo, componi.
  • LayoutBuilder → quando basta conoscere i vincoli per scegliere fra due layout diversi. Attenzione: introduce un rebuild in fase di layout, evitalo dentro liste molto lunghe.
  • CustomSingleChildLayout → un figlio, posizionamento calcolato.
  • CustomMultiChildLayout → figli con ruoli distinti e dipendenze reciproche, dimensione del contenitore dettata dai vincoli.
  • Flow → posizioni animate frequentemente, ricalcolate solo in paint.
  • RenderObject → dimensione dipendente dai figli, intrinsics, paint o hit test personalizzati, layout con logiche esotiche (griglie a cascata, layout circolari con collisioni).

Errori comuni e come evitarli

  • "BoxConstraints forces an infinite width": stai passando constraints.biggest in un contesto scrollabile orizzontalmente. Usa constraints.loosen() con un maxWidth finito o gestisci esplicitamente isTight/hasBoundedWidth.
  • Dimenticare markNeedsLayout / markNeedsPaint nei setter del RenderObject: la UI non si aggiorna. Regola pratica: se il valore cambia la geometria → markNeedsLayout; se cambia solo il disegno → markNeedsPaint.
  • shouldRelayout che ritorna sempre true: rilayout inutili a ogni frame.
  • Misurare due volte lo stesso figlio in MultiChildLayoutDelegate: assert in debug e comportamento imprevedibile in release.
  • Ignorare i vincoli: se non chiami constraints.constrain(...) rischi overflow o assert RenderBox does not meet its constraints.

Debug: gli strumenti che servono davvero

  • debugPaintSizeEnabled = true per vedere i box e i padding.
  • Il Widget Inspector di DevTools con la vista Layout Explorer: mostra vincoli, flex e dimensioni reali di ogni nodo.
  • debugDumpRenderTree() in console: stampa constraints e size di tutto l'albero di rendering, spesso è il modo più rapido per capire chi impone cosa.
  • I golden test sono l'alleato migliore per un layout custom: bloccano la resa visiva e ti avvisano se una modifica al delegate rompe un caso limite.

Conclusione

I layout personalizzati non sono materia esclusiva per chi scrive framework: appena l'interfaccia esce dagli schemi, saper scendere di un livello evita workaround fragili basati su MediaQuery e numeri magici. Parti sempre dalla composizione, passa ai delegate quando la logica di posizionamento diventa il cuore del problema e riserva i RenderObject ai casi in cui devi controllare misura, paint e hit test. Il protocollo di layout di Flutter è semplice — vincoli giù, dimensioni su, posizione dal genitore — e una volta interiorizzato apre la porta a interfacce che sembrano impossibili con i soli widget standard.