November 22, 2025

3D PageView with Flutter

I've been absent from the blog for 3 months; I've been working hard on the Omnichord app, and time is always limited. I'll soon return to discussing the audio engine development.
In this post, however, I want to talk about a small frontend development in Flutter within my app.
In the landscape view, I have a section dedicated to parameter controls, divided into several scrollable pages.

I wanted to avoid the scrolling effect and desired a more elegant effect, because I hate it when a part of the UI scrolls in interfaces and is abruptly cut off. I thought a 3D rotation could be nice.

I didn’t use any external libraries, and I hope the explanations in the code comments are exhaustive.
The number at the beginning of each comment is the recommended reading order.
The entire article, source code, and comments are written by me; no LLM was used :-)

import 'dart:math' as math;
import 'package:flutter/material.dart';

/// --- 01 ---
/// The model is the external mutable state that will be passed to the main Widget via
/// Dependency Injection.
/// The operation is very simple: when you want to change the page, you call the goToPage method.
/// The new page to reach is inserted into a FIFO queue
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 ---
  /// Upon Widget initialization, I instantiate an AnimationController object, whose purpose is to generate a 
  /// number from 0.0 to 1.0 within the time defined in duration, doing so linearly.
  ///
  /// To achieve a more natural effect, the AnimationController's value is modified by CurveTween,
  /// which maps the values from 0.0 to 1.0 following the easeInOutSine curve.
  /// In other words, the animation starts slowly, speeds up in the middle, and slows down at the end.
  /// The effect is more natural.
  ///
  /// I added the listener that invokes the handleModelChange function every time 
  /// a page change is requested.
  ///
  /// The handleAnimationStatus is invoked when the AnimationController's status changes;
  /// this will be used to manage events at the end of the animation.
  @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 ---
  /// This reads the first element in the FIFO, resets the AnimationController
  /// and starts the animation. This only happens if no animation has been started
  /// yet, or if the animation is completed.
  void handleModelChange() {
    if (animationController.isCompleted || animationController.isDismissed) {
      newPage = widget.model.pageFIFO.first;
      animationController.value = 0.0;
      animationController.forward();
    }
  }

  /// --- 08 ---
  /// This function is called when the animation ends.
  /// Its purpose is to check if there are other elements in the queue.
  /// If there are, it extracts the element and restarts the animation.
  ///
  /// If the elements in the queue are more than the number of children, then
  /// the animation is sped up; the more elements in the queue,
  /// the faster the animation becomes.
  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 ---
    /// The Widget responsible for drawing the interface; reacts to the AnimationController
    ///
    /// The foregroundDecoration is used to add a layer to the containers.
    /// The content of the faces rotating upwards or downwards
    /// gradually disappears based on the animation value.
    return AnimatedBuilder(
      animation: animation,
      builder: (context, child) {

        // --- 09 ---
        // If there are no more elements in the queue, then I only show the current children
        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 ---
        /// The starting matrix to obtain the Perspective Projection.
        /// Starting from the identity matrix,
        /// I modify the element at position (3,2)
        /// In this way, when the rotation occurs on the X axis
        /// the perspective will be visible, meaning the parts behind will appear farther,
        /// and the parts in front closer.
        /// 
        /// Small mathematical explanation: x, y, z are the starting coordinates
        /// |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', and z' are equivalent to x, y, and z
        /// w' = 0*x + 0*y + (-0.001 * z) + (1 * 1) = 1 -0.001 * z
        /// To obtain the final x, y, z coordinates, we must divide
        /// x, y, and z by w'
        /// Thus, the larger the value of z (depth),
        /// the smaller the x and y coordinates will become on the screen.
        /// This will give the illusion of perspective.
        ///
        /// I subsequently apply a translation on the Z axis,
        /// which is moving the object away from the observer by a value
        /// equal to half the height.
        /// The height also corresponds to the depth of the cuboid;
        /// it serves to compensate for the closer placement I'll do later.
        Matrix4 matrix4 = Matrix4.identity()
          ..setEntry(3, 2, -0.001)
          ..translateByDouble(0.0, 0.0, -halfHeight, 1.0);
          
        /// --- 06 ---
        /// The front face of the cuboid.
        /// To achieve the perspective effect during rotation,
        /// the Z axis has been translated forward by half the height
        /// (thus bringing it closer to the observer)
        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 ---
        /// If the new page is greater than the current one,
        /// it means I must rotate the figure upwards, and vice versa
        /// 
        /// When the rotation begins, using the Stack Widget, I position
        /// the front element, then the bottom or top element
        /// (based on the rotation direction)
        /// The bottom or top elements are initially positioned
        /// and rotated appropriately using the Transform inside the Stack
        /// So the top element will initially be rotated by -90°, and the bottom one by +90°
        ///
        /// The actual rotation of the entire cuboid
        /// is instead managed by the external Transform, which rotates on the X axis
        /// based on the value read at that moment from the animation variable
        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])
                )
              ]
            )
          );
        }
      }
    );
  }
}

I am aware that there might be bugs, for example if the queue is emptied externally, or if an attempt is made to access a page that is not present in the children list.
However, this cannot happen in my app, so everything is fine.

comments