22 novembre 2025
PageView in 3D con Flutter
Sono 3 mesi che sono assente dal blog, sto lavorando duro sull'app Omnichord e il tempo è sempre poco. Presto tornerò a parlare dello sviluppo del motore audio.
In questo post voglio invece parlare di un piccolo sviluppo frontend su Flutter nella mia app.
Nella vista landscape ho una sezione dedicata ai controlli dei parametri, divisa in più pagine scorrevoli.
Volevo evitare l'effetto di scorrimento e volevo un effetto più gradevole alla vista, perchè odio quando nelle interfacce una parte di UI scorre venendo troncata nel vuoto. Ho pensato che poteva essere carina una rotazione in 3D.
In questo post voglio invece parlare di un piccolo sviluppo frontend su Flutter nella mia app.
Nella vista landscape ho una sezione dedicata ai controlli dei parametri, divisa in più pagine scorrevoli.
Volevo evitare l'effetto di scorrimento e volevo un effetto più gradevole alla vista, perchè odio quando nelle interfacce una parte di UI scorre venendo troncata nel vuoto. Ho pensato che poteva essere carina una rotazione in 3D.
Non ho utilizzato librerie esterne, nei commenti al codice trovere una spiegazione spero esaustiva.
Il numero ad inizio di ogni commento è l’ordine di lettura consigliato.
Tutto l’articolo, il codice sorgente e i commenti sono scritti di mio pugno, nessun LLM è stato utilizzato :-)
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// --- 01 ---
/// Il modello è lo stato modificabile dall'esterno che andrà passato al Widget principale tramite
/// Dependency Injection.
/// Il funzionamento è molto semplice, quando si vuole cambiare pagina, si chiama il metodo goToPage.
/// La nuova pagina da raggiungere viene inserita in una coda FIFO
class PageView3DModel extends ChangeNotifier {
List<int> pageFIFO = [];
void goToPage(int n) {
pageFIFO.add(n);
notifyListeners();
}
}
class PageView3D extends StatefulWidget {
final List<Widget> children;
final PageView3DModel model;
final double width;
final double height;
final Color backgroundColor;
const PageView3D({
super.key,
required this.children,
required this.model,
required this.width,
required this.height,
required this.backgroundColor
});
@override
State<PageView3D> createState() => _PageView3DState();
}
class _PageView3DState extends State<PageView3D> with SingleTickerProviderStateMixin {
late AnimationController animationController;
late Animation<double> animation;
late final double halfHeight;
final double halfPi = math.pi / 2.0;
int newPage = 0;
int currentPage = 0;
late final BoxDecoration decoration;
/// --- 02 ---
/// All'inizializzazione del Widget, instanzio un oggetto AnimationController, il cui scopo è generare un
/// numero da 0.0 a 1.0 nel lasso di tempo definito in duration, lo fa in modo lineare.
///
/// Per ottenere un effetto più naturale, il valore di AnimationController viene modificato da CurveTween,
/// che esegue una mappatura dei valori da 0.0 a 1.0 seguendo la curva ease In Out (sinusoidale).
/// In altre parola, l'animazione parte lentamente, a metà velocizza, e alla fine rallenta.
/// L'effetto risulta più naturale.
///
/// Ho aggiunto il listener che invoca la funzione handleModelChange ogni volta
/// che viene richiesto un cambio pagina.
///
/// Al cambio di stato dell'AnimationController viene invece invocata handleAnimationStatus,
/// questo servirà a gestire gli eventi al termine dell'animazione.
@override
void initState() {
super.initState();
decoration = BoxDecoration(color: widget.backgroundColor, border: Border.all(color: Colors.grey.shade300, width: 0.1));
halfHeight = widget.height / 2.0;
animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
animationController.addStatusListener(handleAnimationStatus);
animation = CurveTween(curve: Curves.easeInOutSine).animate(animationController);
widget.model.addListener(handleModelChange);
}
@override
void dispose() {
widget.model.removeListener(handleModelChange);
animationController.removeStatusListener(handleAnimationStatus);
animationController.dispose();
super.dispose();
}
/// --- 03 ---
/// Si occupa di leggere il primo elemento nella FIFO, azzerarare l'AnimationController
/// e far partire l'animazione. Questo succede solo se non è mai stata avviata
/// alcuna animazione, o se l'animazione è completata.
void handleModelChange() {
if (animationController.isCompleted || animationController.isDismissed) {
newPage = widget.model.pageFIFO.first;
animationController.value = 0.0;
animationController.forward();
}
}
/// --- 08 ---
/// Quando l'animazione termina, viene chiamata questa funzione.
/// Il suo scopo è controllare che non ci siano altri elementi in coda.
/// Qualora ci siano, allora estrae l'elemento e fa ripartire l'animazione.
///
/// Se gli elementi in coda sono più di quanti siano i figli, allora
/// L'animazione viene velocizzata, più elementi ci sono in coda,
/// più l'animazione diventa veloce.
void handleAnimationStatus(AnimationStatus status) {
if (animationController.isCompleted) {
currentPage = widget.model.pageFIFO.removeAt(0);
if (widget.model.pageFIFO.isNotEmpty) {
if (widget.model.pageFIFO.length > widget.children.length) {
animationController.duration = Duration(milliseconds: (300 / widget.model.pageFIFO.length).round());
}
newPage = widget.model.pageFIFO.first;
animationController.value = 0.0;
animationController.forward();
}
else {
animationController.duration = const Duration(milliseconds: 300);
}
}
}
@override
Widget build(BuildContext context) {
/// --- 04 ---
/// Il Widget che si occupa di disegnare l'interfaccia, reagisce all'AnimationController
///
/// La foregroundDecoration serve ad aggiungere un layer ai container.
/// Il contenuto delle facciate che ruotano verso l'alto o verso il basso
/// scompare gradualmente in base al valore dell'animazione.
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
// --- 09 ---
// Se non ci sono più elementi in coda, allora mostro solo il children corrente
if (widget.model.pageFIFO.isEmpty) {
return Container(width: widget.width, height: widget.height, decoration: decoration, child: widget.children[currentPage]);
}
final BoxDecoration foregroundDecoration = BoxDecoration(color: widget.backgroundColor.withAlpha(((1.0 - animation.value) * 255).ceil().clamp(0, 255)));
/// --- 05 ---
/// La matrice di partenza per ottenere la Perspective Projection.
/// Partendo dalla matrice identità,
/// modifico l'elemento in posizione (3,2)
/// In questo modo, quando avverà la rotazione sull'asse X
/// sarà visibile la prospettiva, cioè le parti dietro sembreranno più lontane,
/// quelle avanti più vicine.
///
/// Piccola spiegazione matematica: x, y, z sono le coordinate di partenza
/// |x'| | 1, 0, 0 , 0 | |x|
/// |y'| = | 0, 1, 0 , 0 | * |y|
/// |z'| | 0, 0, 1 , 0 | |z|
/// |w'| | 0, 0, -0.001, 1 | |1|
///
/// x', y' e z' equivalgono a x,y e z
/// w' = 0*x + 0*y + (-0.001 * z) + (1 * 1) = 1 -0.001 * z
/// Per ottenere le coordinate x, y, z finali, dobbiamo dividere
/// x, y e z per w'
/// Quindi più sarà grande il valore di z (la profondità),
/// più piccole diventeranno le coodinate x e y sullo schermo.
/// Questo darà l'illusione della prospettiva.
///
/// Applico successivamente una traslazione sull'asse Z,
/// ovvero allontano l'oggetto dall'osservatore di un valore
/// pari a metà dell'altezza.
/// L'altezza corrisponde anche alla profondità del parallelepipedo,
/// serve a compensare l'avvicinamento che farò dopo.
Matrix4 matrix4 = Matrix4.identity()
..setEntry(3, 2, -0.001)
..translateByDouble(0.0, 0.0, -halfHeight, 1.0);
/// --- 06 ---
/// La facciata frontale del parallelepipedo.
/// Per ottenere l'effetto prospettiva durante la rotazione,
/// l'asse Z è stato traslato in avanti della metà dell'altezza
/// (l'ho quindi avvicinato verso l'osservatore)
Transform frontFace = Transform(
transform: Matrix4.translationValues(0.0, 0.0, halfHeight),
child: Container(width: widget.width, height: widget.height, decoration: decoration, child: widget.children[currentPage])
);
/// --- 07 ---
/// Se la nuova pagina è maggiore di quella attuale,
/// vuol dire che devo ruotare la figura verso l'alto, e viceversa
///
/// Quando inizia la rotazione, tramite il Widget Stack posiziona
/// l'elemento frontale, poi l'elemento in basso o in alto
/// (in base al verso di rotazione)
/// Gli elementi in basso o in alto sono posizionati inizialmente
/// e ruotati opportunamene tramite il Transform all'interno dello Stack
/// Quindi l'elemento in alto sarà inizialmente ruotato di -90°, e quello in basso di +90°
///
/// La rotazione effettiva di tutto il parallelepipedo
/// viene invece gestita tramite il Transform esterno, che ruota sull'asseX
/// in base al valore letto in quel momento dalla variabile animation
if (newPage > currentPage) {
// Downwards
return Transform(
alignment: Alignment.center,
transform: matrix4..rotateX(animation.value * halfPi),
child: Stack(
children: [
frontFace,
Transform(
transform: Matrix4.translationValues(0.0, widget.height, halfHeight)..rotateX(-halfPi),
child: Container(width: widget.width, height: widget.height, decoration: decoration, foregroundDecoration: foregroundDecoration, child: widget.children[newPage])
)
]
)
);
}
else {
// Upwards
return Transform(
alignment: Alignment.center,
transform: matrix4..rotateX(-animation.value * halfPi),
child: Stack(
children: [
frontFace,
Transform(
transform: Matrix4.translationValues(0.0, 0.0, -halfHeight)..rotateX(halfPi),
child: Container(width: widget.width, height: widget.height, decoration: decoration, foregroundDecoration: foregroundDecoration, child: widget.children[newPage])
)
]
)
);
}
}
);
}
}
Sono consapevole che potrebbero esserci dei bug, ad esempio se viene svuotata la coda dall’esterno, o se si richiede di accedere ad una pagina non presente tra i children.
Ma nella mia app questo non può succedere, quindi tutto ok.
000011.it
Desideri che il tuo nome venga memorizzato per i commenti futuri su questo sito?
Se acconsenti, memorizzeremo il tuo nome in modo che tu non debba digitarlo di nuovo.
Il tuo nome verrà conservato per 6 mesi.
Puoi revocare questo consenso in qualsiasi momento cancellando i cookie del tuo browser.
commenti
È stato inviato e sarà moderato prima della pubblicazione.