import 'dart:ui' as ui;
import 'package:flutter/material.dart';
void main() => runApp(const ReproApp());
class ReproApp extends StatelessWidget {
const ReproApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(debugShowCheckedModeBanner: false, home: ReproScreen());
}
}
class ReproScreen extends StatefulWidget {
const ReproScreen({super.key});
@override
State<ReproScreen> createState() => _ReproScreenState();
}
class _ReproScreenState extends State<ReproScreen> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(seconds: 3))..repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final t = _controller.value;
final angleY = -1.48 + (2.96 * t);
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.0015)
..rotateY(angleY),
child: child,
);
},
child: DecoratedBox(
decoration: ShapeDecoration(
color: Colors.blue,
shadows: const [BoxShadow(color: Colors.black87, offset: Offset(0, 2), blurRadius: 10, spreadRadius: 5)],
shape: PathBuilderBorder(
pathBuilder: (rect, _) {
final points = <Offset>[
rect.topLeft,
rect.topRight,
rect.bottomRight,
Offset(rect.center.dx, rect.bottom + 35), // with this point it breaks the shadow
rect.bottomLeft,
];
return Path()..addPolygon(points, true);
},
),
),
child: const SizedBox(
width: 180,
height: 320,
child: Center(
child: FlutterLogo(size: 80, style: FlutterLogoStyle.stacked, textColor: Colors.white),
),
),
),
),
),
);
}
}
typedef PathBuilder = ui.Path Function(ui.Rect bounds, double phase);
class PathBuilderBorder extends OutlinedBorder {
const PathBuilderBorder({required this.pathBuilder, super.side = BorderSide.none, this.phase = 0});
final PathBuilder pathBuilder;
final double phase;
@override
EdgeInsetsGeometry get dimensions => EdgeInsets.zero;
@override
ui.Path getInnerPath(ui.Rect rect, {ui.TextDirection? textDirection}) {
return getOuterPath(rect, textDirection: textDirection);
}
@override
ui.Path getOuterPath(ui.Rect rect, {ui.TextDirection? textDirection}) {
return pathBuilder(rect, phase);
}
@override
void paint(ui.Canvas canvas, ui.Rect rect, {ui.TextDirection? textDirection}) {
if (side != BorderSide.none) {
canvas.drawPath(pathBuilder(rect, phase), side.toPaint());
}
}
@override
ShapeBorder scale(double t) => this;
@override
OutlinedBorder copyWith({BorderSide? side}) {
return PathBuilderBorder(pathBuilder: pathBuilder, side: side ?? this.side, phase: phase);
}
}
Steps to reproduce
Using a custom OutlinedBorder (PathBuilderBorder) with ShapeDecoration and BoxShadow, I see incorrect shadow behavior during Y-axis 3D rotation.
The repro path is a mostly-rectangular polygon with one extra point outside the bottom edge:
When animated with perspective (Matrix4.setEntry(3, 2, 0.0015)) and rotateY, the shadow appears detached/warped relative to the filled shape.
Environment
Expected results
Shadow should track the transformed custom shape consistently during rotation as it works on Flutter SDK 3.38.0.
Actual results
Shadow projection/perspective does not match the transformed shape; it appears offset/distorted relative to the polygon.
Code sample
Code sample
Screenshots or Video
Screenshots / Video demonstration
[Upload media here]
Flutter 3.41.1
Screen.Recording.2026-02-20.at.11.10.40.mov
Flutter 3.38.0
Screen.Recording.2026-02-20.at.11.22.15.mov
Logs
Logs
[Paste your logs here]Flutter Doctor output
Doctor output
[Paste your output here]