Skip to content

[Shadows] Flutter 3.41.1: ShapeDecoration shadow becomes inconsistent under Transform.rotateY when custom PathBuilderBorder includes an out-of-bounds point #182659

Description

@EdgarMagalhaesPersonal

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:

  • topLeft
  • topRight
  • bottomRight
  • Offset(rect.center.dx, rect.bottom + 35) (outside point)
  • bottomLeft

When animated with perspective (Matrix4.setEntry(3, 2, 0.0015)) and rotateY, the shadow appears detached/warped relative to the filled shape.

Environment

  • Flutter: 3.41.1
  • Tested both on iOS simulator and real iOS device

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
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);
  }
}

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]

Metadata

Metadata

Assignees

Labels

P2Important issues not at the top of the work listc: regressionIt was better in the past than it is nowhas reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-iosiOS applications specificallyteam-engineOwned by Engine teamtriaged-engineTriaged by Engine team

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions