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:
I can change the opacity of the tiles to make the issue more obvious:
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?
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:
And with the opacity turned up a bit so you can really see the fix in action:
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.