Last active
January 2, 2024 17:42
-
-
Save loic-sharma/2b07d54b06f65bf4a240eee13dce0250 to your computer and use it in GitHub Desktop.
Windows compositor animation regression
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// The Windows compositor changes regresses the animation in | |
// the `material_floating_search_bar_2` package: | |
// | |
// https://pub.dev/packages/material_floating_search_bar_2/versions/0.5.0 | |
// | |
// Below is a minimal repro of the broken animation. This has two pieces: | |
// | |
// 1. The background fades to a grey color | |
// 2. A box is "revealed" using a custom clipper | |
// | |
// On framework commit b417fb828b332b0a4b0c80b742d86aa922de2649 this animation is broken on Windows. | |
// On framework commit 9c2a7560096223090d38bbd5b4c46760be396bc1 this animation works as expected on Windows. | |
// | |
// Good gif: https://publish-01.obsidian.md/access/b48ac8ca270cd9dac18c4a64d11b1c02/assets/2023-12-28-compositor_animation_regression_good.gif | |
// Bad gif: https://publish-01.obsidian.md/access/b48ac8ca270cd9dac18c4a64d11b1c02/assets/2023-12-28-compositor_animation_regression_bad.gif | |
import 'dart:math'; | |
import 'dart:ui'; | |
import 'package:flutter/material.dart'; | |
void main() { | |
runApp(const MyApp()); | |
} | |
class MyApp extends StatelessWidget { | |
const MyApp({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
// Not using `MaterialApp` is necessary to reproduce: | |
return Container( | |
color: Colors.white, | |
child: const Directionality( | |
textDirection: TextDirection.ltr, | |
child: FloatingSearchBar(), | |
), | |
); | |
// Switching to `MaterialApp` fixes the issue: | |
// return const MaterialApp( | |
// home: Scaffold( | |
// body: FloatingSearchBar(), | |
// ), | |
// ); | |
} | |
} | |
class FloatingSearchBar extends StatefulWidget { | |
const FloatingSearchBar({super.key}); | |
@override | |
FloatingSearchBarState createState() => FloatingSearchBarState(); | |
} | |
class FloatingSearchBarState extends State<FloatingSearchBar> with SingleTickerProviderStateMixin { | |
late final AnimationController _controller = AnimationController( | |
vsync: this, | |
duration: const Duration(seconds: 2), | |
); | |
@override | |
void dispose() { | |
_controller.dispose(); | |
super.dispose(); | |
} | |
void _animate() { | |
if (_controller.isDismissed || _controller.status == AnimationStatus.reverse) { | |
_controller.forward(); | |
} else { | |
_controller.reverse(); | |
} | |
} | |
@override | |
Widget build(BuildContext context) { | |
return AnimatedBuilder( | |
animation: _controller, | |
builder: (BuildContext context, _) { | |
return Stack( | |
children: <Widget>[ | |
if (!_controller.isDismissed) | |
FadeTransition( | |
opacity: _controller, | |
child: const SizedBox.expand( | |
child: DecoratedBox( | |
decoration: BoxDecoration(color: Colors.black26), | |
), | |
), | |
), | |
_buildSearchBar(), | |
], | |
); | |
}, | |
); | |
} | |
Widget _buildSearchBar() { | |
return Column( | |
crossAxisAlignment: CrossAxisAlignment.stretch, | |
children: <Widget>[ | |
// This is where the search text input would go... | |
GestureDetector( | |
onTap: () => _animate(), | |
child: Text( | |
switch (_controller.status) { | |
AnimationStatus.forward || AnimationStatus.completed => 'Click to close', | |
AnimationStatus.reverse || AnimationStatus.dismissed => 'Click to open', | |
}, | |
style: const TextStyle(color: Colors.black), | |
), | |
), | |
// Below are where the search results would be. Clicking on the search | |
// input above reveals the results below. | |
// Removing this fixes the background's fade transition. | |
ClipOval( | |
clipper: _CircularRevealClipper( | |
fraction: _controller.value, | |
), | |
child: DecoratedBox( | |
decoration: BoxDecoration( | |
color: Colors.white, | |
// Removing this line fixes the background's fade transition. | |
borderRadius: BorderRadius.circular(16.0), | |
), | |
child: const Padding( | |
padding: EdgeInsets.all(64.0), | |
child: Text( | |
'Hello world', | |
style: TextStyle(color: Colors.black), | |
), | |
), | |
), | |
), | |
], | |
); | |
} | |
} | |
class _CircularRevealClipper extends CustomClipper<Rect> { | |
const _CircularRevealClipper({required this.fraction}); | |
final double fraction; | |
@override | |
Rect getClip(Size size) { | |
final double halfWidth = size.width * 0.5; | |
final double maxRadius = sqrt(halfWidth * halfWidth + size.height * size.height); | |
final double radius = lerpDouble(0.0, maxRadius, fraction) ?? 0; | |
return Rect.fromCircle( | |
center: Offset(halfWidth, 0), | |
radius: radius, | |
); | |
} | |
@override | |
bool shouldReclip(CustomClipper<Rect> oldClipper) { | |
if (oldClipper is _CircularRevealClipper) { | |
return oldClipper.fraction != fraction; | |
} | |
return true; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment