Flutter: Layout multiple children with differing z coordinates from matrix transformations with correct overlap

340

So I found a solution. It's not perfect, but it works so far.

The issue is that Flutter paints widgets in the order that they appear. Since the larger cards are higher up on the list, even though they have a closer z coordinate, they are painted first, and the smaller card is painted on top of them.

The solution is to use a Flow() widget to dynamically change the order in which the cards are painted. In the children widgets, where I'd normally build them, I instead stored their transformations in a map. Then, in the flow delegate function, access these transformations, calculate the z coordinate of each widget after transforming (row 3, column 3 (1 indexed) in the transformation matrix). Sort the widgets by z coordinate, and then paint them on screen in that order.

Here is the code for the same:

The part with the root Flow widget:

@override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: widget.animationControllerX,
      builder: (context, _) {
        double rotateX =
            widget.animationControllerX.value / Constants.turnResolution;
        double rotateY =
            widget.animationControllerY.value / Constants.turnResolution;
        return Container(
          transform: Matrix4.identity()
            ..translate(
              MediaQuery.of(context).size.width / 2,
            ),
          child: Flow(
            clipBehavior: Clip.none,
            delegate: CustomFlowDelegate(rotateY: -rotateX, rotateX: rotateY),
            children: List<Widget>.generate(
              Constants.numberOfElements,
              (index) => ElementCard(
                atomicNumber:
                    Constants.elements[(index + 1).toString()].atomicNumber,
              ),
            ),
          ),
        );
      },
    );
  }

The FlowDelegate for the widget:

class CustomFlowDelegate extends FlowDelegate {
  CustomFlowDelegate({this.rotateX, this.rotateY});
  final rotateY, rotateX;
  @override
  void paintChildren(FlowPaintingContext context) {
    Map<int, double> zValue = {};
    for (int i = 0; i < context.childCount; i++) {
      var map = Constants.transformationsMap[i + 1];
      Matrix4 transformedMatrix = Matrix4.identity()
        ..setEntry(3, 2, 0.001)
        ..setEntry(3, 1, 0.001)
        ..rotateY(this.rotateY)
        ..rotateX(this.rotateX)
        ..translate(
            map['translateOneX'], map['translateOneY'], map['translateOneZ'])
        ..translate(map['translateTwoX'])
        ..rotateY(map['rotateThreeY'])
        ..translate(map['translateFourX']);
      zValue[i + 1] = transformedMatrix.getRow(2)[2];
    }
    var sortedKeys = zValue.keys.toList(growable: false)
      ..sort((k1, k2) => zValue[k1].compareTo(zValue[k2]));
    LinkedHashMap sortedMap = new LinkedHashMap.fromIterable(sortedKeys,
        key: (k) => k, value: (k) => zValue[k]);
    for (int key in sortedMap.keys) {
      var map = Constants.transformationsMap[key];
      context.paintChild(
        key - 1,
        transform: Matrix4.identity()
          ..setEntry(3, 2, 0.001)
          ..setEntry(3, 1, 0.001)
          ..rotateY(this.rotateY)
          ..rotateX(this.rotateX)
          ..translate(
              map['translateOneX'], map['translateOneY'], map['translateOneZ'])
          ..translate(map['translateTwoX'])
          ..rotateY(map['rotateThreeY'])
          ..translate(map['translateFourX']),
      );
    }
  }

  @override
  bool shouldRepaint(covariant FlowDelegate oldDelegate) {
    return true;
  }
}

Here's what the result looks like:

Element Helix Transparent

And with the opacity turned up a bit so you can really see the fix in action:

Element Helix Opaque

And yes, I know storing the transformations in a map and then sorting them from another page isn't best practice.

Hope this helps someone.

Share:
340
Zac
Author by

Zac

Updated on December 26, 2022

Comments

  • Zac
    Zac over 1 year

    I'm trying to build a Flutter Web version of the threejs Periodic Table Helix view (see here: https://mrdoob.com/lab/javascript/threejs/css3d/periodictable/ and click on "Helix")

    Currently, I place all the element tiles in a stack, and position and rotate them with matrix transformations. See below code:

      @override
      Widget build(BuildContext context) {
        double initialRotateX = (widget.atomicNumber - 1) *
            (math.pi / Constants.numberOfElementsPerHalfCircle);
        return Transform(
          transform: Matrix4.identity()
            ..translate(
              math.sin((widget.atomicNumber - 1) *
                      (math.pi / Constants.numberOfElementsPerHalfCircle)) *
                  Constants.radius,
              widget.atomicNumber * 4,
              -math.cos((widget.atomicNumber - 1) *
                      (math.pi / Constants.numberOfElementsPerHalfCircle)) *
                  Constants.radius,
            )
            ..translate(Constants.elementCardHalfSize)
            ..rotateY(-initialRotateX)
            ..translate(-Constants.elementCardHalfSize),
          child: Container(
            decoration: BoxDecoration(
                color: Constants.elementCardColor,
                border: Border.all(width: 1, color: Colors.white.withOpacity(0.3))),
            child: SizedBox(
              width: Constants.elementCardWidth.toDouble(),
              height: Constants.elementCardHeight.toDouble(),
              child: ElementText()
              
            ),
          ),
        );
    

    All these cards are then placed inside a stack widget in another class, and that widget is rotated, like so:

    return AnimatedBuilder(
          animation: widget.animationControllerX,
          builder: (context, _) {
            double rotateX =
                widget.animationControllerX.value / Constants.turnResolution;
            double rotateY =
                widget.animationControllerY.value / Constants.turnResolution;
            return Transform(
              transform: Matrix4.identity()
                ..setEntry(3, 2, 0.001)
                ..rotateY(-rotateX)
                ..rotateX(rotateY),
              alignment: Alignment.center,
              child: Container(
                child: Stack(
                  children: List<Widget>.generate(
                    Constants.numberOfElements,
                    (index) => ElementCard(
                      atomicNumber:
                          Constants.elements[(index + 1).toString()].atomicNumber,
                    ),
                  ),
                ),
              ),
            );
          },
        );
    

    As you can see, there is a "..rotateY" transformation applied, which makes a card appear to move forward when the stack is rotated.

    However, when rotating, cards that should be in the back are smaller, yet paint over the cards that should be in the front. (There is no doubt that the cards are in the right positions, since I can rotate them along the x axis and see that for myself, but when painting, the card on the back is painted over the text in the card on the front. I believe this is because a stack always positions the elevation of its widgets according to the order they're provided in, and the order is fixed in the list. Is there any way I can fix this?

    Screenshots to explain what I mean:

    Stacked Element Cards in Flutter

    I can change the opacity of the tiles to make the issue more obvious:

    Stacked Element Cards in Flutter (Opaque)

    As you can see, the larger cards are closer to the screen, since that's how they're transformed, but they're painting behind the smaller cards. Any ideas on how to make this happen?