RenderObject custom e SliverPersistentHeader in Flutter

Foto di Akhmad Muzakir su Unsplash

GuideAvanzato45 min Flutter 3.x

RenderObject custom e SliverPersistentHeader in Flutter

Il layer dei widget di Flutter è comodo, ma a volte insufficiente: quando ti serve un algoritmo di layout che nessun widget esistente offre (un flow radiale, un badge che si posiziona in base al figlio, una barra che si deforma allo scroll) devi lavorare con il render layer.

In questo tutorial avanzato vedremo:

  • come funziona il protocollo di layout RenderBox (constraints down, size up, parent sets position);
  • come implementare un RenderObject custom con SingleChildRenderObjectWidget e MultiChildRenderObjectWidget;
  • come integrarlo in un CustomScrollView tramite SliverPersistentHeader e un delegate personalizzato;
  • come debuggare layout e repaint con debugPaintSizeEnabled, RepaintBoundary e i DevTools.

Prerequisiti: buona dimestichezza con l'albero dei widget, CustomPaint, sliver e Canvas.

  1. 1

    Capire il protocollo di layout dei RenderBox

    Prima di scrivere codice, fissiamo le regole del render layer:

    1. I constraints scendono: il genitore passa un BoxConstraints (min/max width e height).
    2. Le dimensioni salgono: il figlio scrive size rispettando i constraints ricevuti.
    3. Il genitore posiziona: la posizione del figlio vive nel parentData (tipicamente un BoxParentData con offset).

    Da qui derivano alcuni vincoli pratici:

    • in performLayout puoi leggere child.size solo se hai chiamato layout(..., parentUsesSize: true);
    • se il tuo layout dipende dalla dimensione del figlio devi dichiararlo, altrimenti Flutter lancia un assert in debug;
    • markNeedsLayout() invalida il layout, markNeedsPaint() solo il disegno: usa il secondo quando cambia solo un colore o un offset di pittura.

    Un RenderBox minimale implementa performLayout, paint, hitTest e i metodi computeMin/MaxIntrinsicWidth/Height se il widget può stare in contesti che interrogano le dimensioni intrinseche.

    // Scheletro di un RenderBox con un figlio
    class RenderScaleBox extends RenderBox
        with RenderObjectWithChildMixin<RenderBox> {
      RenderScaleBox({required double scale}) : _scale = scale;
    
      double _scale;
      double get scale => _scale;
      set scale(double value) {
        if (_scale == value) return;
        _scale = value;
        markNeedsLayout(); // cambia la size => serve un nuovo layout
      }
    
      @override
      void performLayout() {
        final child = this.child;
        if (child == null) {
          size = constraints.smallest;
          return;
        }
        // parentUsesSize: true perché usiamo child.size qui sotto
        child.layout(constraints.loosen(), parentUsesSize: true);
        size = constraints.constrain(child.size * _scale);
      }
    
      @override
      void paint(PaintingContext context, Offset offset) {
        final child = this.child;
        if (child == null) return;
        context.pushTransform(
          needsCompositing,
          offset,
          Matrix4.diagonal3Values(_scale, _scale, 1.0),
          (ctx, o) => ctx.paintChild(child, o),
        );
      }
    }

    Risultato atteso

    Hai chiaro il ciclo constraints/size/offset e sai quando invalidare layout o paint.

  2. 2

    Esporre il RenderObject come widget

    Un RenderObject non si usa direttamente nell'albero: serve un RenderObjectWidget che lo crei (createRenderObject) e lo aggiorni (updateRenderObject). Con un solo figlio si estende SingleChildRenderObjectWidget.

    Punti chiave:

    • createRenderObject viene chiamato una sola volta per elemento;
    • updateRenderObject viene chiamato a ogni rebuild: assegna solo le proprietà, i setter decidono se invalidare;
    • implementa debugFillProperties per vedere i valori nel Widget Inspector.

    Aggiungiamo anche hitTest: senza di esso i tocchi non arrivano al figlio quando applichiamo una trasformazione.

    class ScaleBox extends SingleChildRenderObjectWidget {
      const ScaleBox({super.key, required this.scale, super.child});
    
      final double scale;
    
      @override
      RenderScaleBox createRenderObject(BuildContext context) =>
          RenderScaleBox(scale: scale);
    
      @override
      void updateRenderObject(BuildContext context, RenderScaleBox renderObject) {
        renderObject.scale = scale;
      }
    
      @override
      void debugFillProperties(DiagnosticPropertiesBuilder properties) {
        super.debugFillProperties(properties);
        properties.add(DoubleProperty('scale', scale));
      }
    }
    
    // da aggiungere in RenderScaleBox
    // @override
    // bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
    //   final child = this.child;
    //   if (child == null) return false;
    //   return result.addWithPaintTransform(
    //     transform: Matrix4.diagonal3Values(_scale, _scale, 1.0),
    //     position: position,
    //     hitTest: (r, p) => child.hitTest(r, position: p),
    //   );
    // }

    Risultato atteso

    `ScaleBox(scale: 1.4, child: ElevatedButton(...))` mostra il figlio ingrandito e cliccabile.

  3. 3

    Layout multi-figlio: un RenderBox che dispone i figli ad arco

    Per più figli si usa MultiChildRenderObjectWidget insieme a ContainerRenderObjectMixin e RenderBoxContainerDefaultsMixin. Ogni figlio ha un parentData in cui salviamo l'offset calcolato; se serviamo dati extra (peso, angolo) definiamo una sottoclasse di ContainerBoxParentData.

    Qui distribuiamo i figli lungo un arco: ogni figlio viene misurato con constraints laschi e posizionato su una circonferenza. Il paint usa i default del mixin, che rispetta gli offset in parentData.

    class ArcParentData extends ContainerBoxParentData<RenderBox> {}
    
    class RenderArcLayout extends RenderBox
        with
            ContainerRenderObjectMixin<RenderBox, ArcParentData>,
            RenderBoxContainerDefaultsMixin<RenderBox, ArcParentData> {
      RenderArcLayout({required double radius}) : _radius = radius;
    
      double _radius;
      set radius(double v) {
        if (_radius == v) return;
        _radius = v;
        markNeedsLayout();
      }
    
      @override
      void setupParentData(RenderObject child) {
        if (child.parentData is! ArcParentData) child.parentData = ArcParentData();
      }
    
      @override
      void performLayout() {
        size = constraints.constrain(Size(_radius * 2, _radius + 40));
        final count = childCount;
        var i = 0;
        var child = firstChild;
        while (child != null) {
          final data = child.parentData! as ArcParentData;
          child.layout(const BoxConstraints.tightFor(width: 56, height: 56),
              parentUsesSize: true);
          final t = count == 1 ? 0.5 : i / (count - 1);
          final angle = math.pi * (1 - t); // da 180° a 0°
          final center = Offset(size.width / 2, size.height - 20);
          final pos = center +
              Offset(math.cos(angle) * _radius, -math.sin(angle) * _radius);
          data.offset = pos - Offset(child.size.width / 2, child.size.height / 2);
          i++;
          child = data.nextSibling;
        }
      }
    
      @override
      void paint(PaintingContext context, Offset offset) =>
          defaultPaint(context, offset);
    
      @override
      bool hitTestChildren(BoxHitTestResult result, {required Offset position}) =>
          defaultHitTestChildren(result, position: position);
    }
    
    class ArcLayout extends MultiChildRenderObjectWidget {
      const ArcLayout({super.key, this.radius = 120, super.children = const []});
      final double radius;
    
      @override
      RenderArcLayout createRenderObject(BuildContext context) =>
          RenderArcLayout(radius: radius);
    
      @override
      void updateRenderObject(BuildContext context, RenderArcLayout renderObject) {
        renderObject.radius = radius;
      }
    }

    Risultato atteso

    I figli di `ArcLayout` si dispongono a semicerchio e restano interattivi.

  4. 4

    Un header collassabile con SliverPersistentHeaderDelegate

    SliverAppBar è già un SliverPersistentHeader preconfezionato. Quando serve un comportamento diverso (progress bar che si riempie, titolo che migra, contenuto che cambia layout) conviene scrivere il delegate.

    API essenziali del delegate:

    • minExtent / maxExtent: estensione minima e massima in pixel;
    • build(context, shrinkOffset, overlapsContent): shrinkOffset va da 0 a maxExtent - minExtent;
    • shouldRebuild(old): torna true solo se i parametri cambiano.

    Attenzione: il widget restituito da build deve poter occupare l'altezza corrente; usa SizedBox.expand o Stack con fit: StackFit.expand. Con pinned: true l'header resta agganciato, con floating: true ricompare allo scroll verso l'alto.

    class CollapsingHeader extends SliverPersistentHeaderDelegate {
      CollapsingHeader({required this.title, required this.progress});
    
      final String title;
      final double progress; // 0..1
    
      @override
      double get minExtent => 72;
      @override
      double get maxExtent => 220;
    
      @override
      Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
        final t = (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0);
        final scheme = Theme.of(context).colorScheme;
        return Material(
          color: Color.lerp(scheme.primaryContainer, scheme.primary, t),
          elevation: 4 * t,
          child: SizedBox.expand(
            child: Stack(
              children: [
                Positioned(
                  left: 16 + 8 * t,
                  bottom: lerpDouble(24, 20, t)!,
                  child: Text(
                    title,
                    style: TextStyle(
                      fontSize: lerpDouble(30, 18, t)!,
                      fontWeight: FontWeight.bold,
                      color: Color.lerp(scheme.onPrimaryContainer, scheme.onPrimary, t),
                    ),
                  ),
                ),
                Align(
                  alignment: Alignment.bottomCenter,
                  child: LinearProgressIndicator(value: progress, minHeight: 3),
                ),
              ],
            ),
          ),
        );
      }
    
      @override
      bool shouldRebuild(CollapsingHeader oldDelegate) =>
          oldDelegate.title != title || oldDelegate.progress != progress;
    }

    Risultato atteso

    Scrollando, l'header si comprime da 220 a 72 px interpolando colore, elevazione e dimensione del titolo.

  5. 5

    Comporre tutto in un CustomScrollView

    Mettiamo insieme header sliver e layout custom. Il SliverPersistentHeader è pinned; sotto inseriamo l'ArcLayout dentro un SliverToBoxAdapter e una lista.

    Nota: SliverToBoxAdapter costruisce sempre il figlio, anche fuori viewport. Per contenuti costosi preferisci SliverList/SliverGrid o incapsula in RepaintBoundary per isolare i repaint dell'header dal resto.

    class DemoPage extends StatelessWidget {
      const DemoPage({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: CustomScrollView(
            slivers: [
              SliverPersistentHeader(
                pinned: true,
                delegate: CollapsingHeader(title: 'Render layer', progress: 0.62),
              ),
              const SliverToBoxAdapter(
                child: RepaintBoundary(
                  child: Padding(
                    padding: EdgeInsets.symmetric(vertical: 24),
                    child: ArcLayout(
                      radius: 110,
                      children: [
                        Icon(Icons.home, size: 32),
                        Icon(Icons.search, size: 32),
                        Icon(Icons.star, size: 32),
                        Icon(Icons.settings, size: 32),
                      ],
                    ),
                  ),
                ),
              ),
              SliverList.builder(
                itemCount: 40,
                itemBuilder: (_, i) => ListTile(title: Text('Elemento $i')),
              ),
            ],
          ),
        );
      }
    }

    Risultato atteso

    Una schermata con header collassabile pinned, icone disposte ad arco e una lista scrollabile fluida.

  6. 6

    Debug, performance e checklist finale

    Il render layer è potente ma facile da sbagliare. Strumenti e regole:

    Debug visivo

    import 'package:flutter/rendering.dart';
    void main() {
      debugPaintSizeEnabled = true;      // bordi dei box
      debugRepaintRainbowEnabled = true; // chi ridisegna
      runApp(const MyApp());
    }
    

    Checklist

    • ogni setter pubblico invalida con markNeedsLayout o markNeedsPaint, mai entrambi inutilmente;
    • se usi child.size passa parentUsesSize: true;
    • implementa computeDryLayout se il tuo box può finire in un IntrinsicHeight o in una Table;
    • implementa hitTestChildren quando applichi trasformazioni o offset non banali;
    • aggiungi Semantics (o describeSemanticsConfiguration) per l'accessibilità: un render object custom è invisibile allo screen reader;
    • nel delegate sliver mantieni shouldRebuild il più selettivo possibile e non allocare oggetti costosi in build.

    Verifica in profilo: esegui flutter run --profile e controlla nei DevTools la timeline dei frame; se vedi picchi in Layout, probabilmente stai invalidando il layout quando basterebbe il paint.

    @override
    Size computeDryLayout(BoxConstraints constraints) {
      final child = this.child;
      if (child == null) return constraints.smallest;
      final childSize = child.getDryLayout(constraints.loosen());
      return constraints.constrain(childSize * _scale);
    }

    Risultato atteso

    Layout corretto anche in contesti intrinseci, nessun assert in debug e frame stabili sotto i 16 ms.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!