flutter
flutter copied to clipboard
[Impeller] : Animation collapses on iOS
Steps to reproduce
(flutter version is 3.24.3)
- flutter create
- Run the following code to see the animation displayed in the center
import 'dart:math';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Container(
color: Colors.red,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
FilledCircularIndicator(
mainColor: const Color(0xFFEDEDED),
subColor: const Color(0xFFEDEDED).withOpacity(0.4),
borderColor: const Color(0xFFEDEDED),
),
],
),
),
),
);
}
}
class FilledCircularIndicator extends StatefulWidget {
const FilledCircularIndicator({
super.key,
this.height = 20.0,
this.width = 20.0,
this.borderWidth = 1.0,
this.animationDuration = const Duration(seconds: 5),
required this.mainColor,
required this.subColor,
required this.borderColor,
});
final double height;
final double width;
final double borderWidth;
final Duration animationDuration;
final Color mainColor;
final Color subColor;
final Color borderColor;
@override
_FilledCircularIndicatorState createState() =>
_FilledCircularIndicatorState();
}
class _FilledCircularIndicatorState extends State<FilledCircularIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: widget.animationDuration * 2,
upperBound: 2,
vsync: this,
)..repeat();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(5),
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
border: Border.all(
color: widget.borderColor,
width: widget.borderWidth,
),
borderRadius: BorderRadius.circular(20),
),
child: AnimatedBuilder(
animation: _animationController,
builder: (context, _) => _animationController.value > 1
? CircularProgressIndicator(
value: _animationController.value - 1,
color: widget.mainColor,
backgroundColor: widget.subColor,
strokeWidth: 10,
)
: Transform(
alignment: Alignment.center,
transform: Matrix4.rotationY(pi),
child: CircularProgressIndicator(
value: 1 - _animationController.value,
color: widget.mainColor,
backgroundColor: widget.subColor,
strokeWidth: 10,
),
),
),
);
}
}
Expected results
Circles form a nice regular circle.
Actual results
The center area is slightly collapsed.
Code sample
Code sample
import 'dart:math';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Container(
color: Colors.red,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
FilledCircularIndicator(
mainColor: const Color(0xFFEDEDED),
subColor: const Color(0xFFEDEDED).withOpacity(0.4),
borderColor: const Color(0xFFEDEDED),
),
],
),
),
),
);
}
}
class FilledCircularIndicator extends StatefulWidget {
const FilledCircularIndicator({
super.key,
this.height = 20.0,
this.width = 20.0,
this.borderWidth = 1.0,
this.animationDuration = const Duration(seconds: 5),
required this.mainColor,
required this.subColor,
required this.borderColor,
});
final double height;
final double width;
final double borderWidth;
final Duration animationDuration;
final Color mainColor;
final Color subColor;
final Color borderColor;
@override
_FilledCircularIndicatorState createState() =>
_FilledCircularIndicatorState();
}
class _FilledCircularIndicatorState extends State<FilledCircularIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: widget.animationDuration * 2,
upperBound: 2,
vsync: this,
)..repeat();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(5),
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
border: Border.all(
color: widget.borderColor,
width: widget.borderWidth,
),
borderRadius: BorderRadius.circular(20),
),
child: AnimatedBuilder(
animation: _animationController,
builder: (context, _) => _animationController.value > 1
? CircularProgressIndicator(
value: _animationController.value - 1,
color: widget.mainColor,
backgroundColor: widget.subColor,
strokeWidth: 10,
)
: Transform(
alignment: Alignment.center,
transform: Matrix4.rotationY(pi),
child: CircularProgressIndicator(
value: 1 - _animationController.value,
color: widget.mainColor,
backgroundColor: widget.subColor,
strokeWidth: 10,
),
),
),
);
}
}
Screenshots or Video
Screenshots / Video demonstration
https://github.com/user-attachments/assets/ea483b70-6f35-43cf-9a88-9286aba80fc4
Logs
Logs
[Paste your logs here]
Flutter Doctor output
Doctor output
[✓] Flutter (Channel stable, 3.24.3, on macOS 14.5 23F79 darwin-x64, locale ja-JP)
• Flutter version 3.24.3 on channel stable at /Users/XXXXX/fvm/versions/3.24.3
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 2663184aa7 (9 weeks ago), 2024-09-11 16:27:48 -0500
• Engine revision 36335019a8
• Dart version 3.5.3
• DevTools version 2.37.3
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
• Android SDK at /Users/XXXXXX/Library/Android/sdk
• Platform android-35, build-tools 35.0.0
• ANDROID_HOME = /Users/XXXXXXX/Library/Android/sdk
• ANDROID_SDK_ROOT = /Users/XXXXXXLibrary/Android/sdk
• Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
• Java version OpenJDK Runtime Environment (build 17.0.11+0-17.0.11b1207.24-11852314)
• All Android licenses accepted.
[✓] Xcode - develop for iOS and macOS (Xcode 16.0)
• Xcode at /Applications/Xcode_16.app/Contents/Developer
• Build 16A242d
• CocoaPods version 1.15.2
[✓] Chrome - develop for the web
• Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
[!] Android Studio (version unknown)
• Android Studio at /Applications/Android Studio Preview.app/Contents
• Flutter plugin can be installed from:
🔨 https://plugins.jetbrains.com/plugin/9212-flutter
• Dart plugin can be installed from:
🔨 https://plugins.jetbrains.com/plugin/6351-dart
✗ Unable to determine Android Studio version.
• Java version OpenJDK Runtime Environment (build 21.0.3+-77717978-b509.4)
[✓] Android Studio (version 2024.1)
• Android Studio at /Applications/Android Studio.app/Contents
• Flutter plugin can be installed from:
🔨 https://plugins.jetbrains.com/plugin/9212-flutter
• Dart plugin can be installed from:
🔨 https://plugins.jetbrains.com/plugin/6351-dart
• Java version OpenJDK Runtime Environment (build 17.0.11+0-17.0.11b1207.24-11852314)
[✓] VS Code (version 1.95.2)
• VS Code at /Users/XXXXXX/tools/Visual Studio Code.app/Contents
• Flutter extension version 3.100.0
[✓] Connected device (3 available)
• iPhone 15 Pro(iOS18.1) (mobile) • 2A9EA93E-D232-47A2-9E91-B786B8674029 • ios • com.apple.CoreSimulator.SimRuntime.iOS-18-1 (simulator)
• macOS (desktop) • macos • darwin-x64 • macOS 14.5 23F79 darwin-x64
• Chrome (web) • chrome • web-javascript • Google Chrome 130.0.6723.117
! Error: Browsing on the local area network for iPhone. Ensure the device is unlocked and attached with a cable or associated with the same local area network as this Mac.
The device must be opted into Developer Mode to connect wirelessly. (code -27)
! Error: Browsing on the local area network for iPhone13. Ensure the device is unlocked and attached with a cable or associated with the same local area network as this Mac.
The device must be opted into Developer Mode to connect wirelessly. (code -27)
[✓] Network resources
• All expected network resources are available.
! Doctor found issues in 1 category.
Thanks for the report. I verified on latest stable and master versions with and without impeller and observed that the reported behavior occurs with Impeller. Without Impeller, it works as expected as shown below:
https://github.com/user-attachments/assets/d7cc33b2-92dd-4b7f-9ab5-b1ce05c9192c
stable : 3.24.4
master : 3.27.0-1.0.pre.471
A simple version of this can be reproduced by drawing an arc whose stroke width is greater than the width of the arc's rectangle:
@bdero
import 'package:flutter/material.dart';
void main() {
runApp(CustomPaint(
size: const Size(300, 300),
painter: TestPainter(),
));
}
class TestPainter extends CustomPainter {
void paint(Canvas canvas, Size size) {
canvas.drawArc(
Rect.fromLTRB(100, 100, 180, 180),
0, 1.57,
false,
Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 100
);
}
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
| Impeller | Skia |
|---|---|
If the stroke width exceeds the radius that fits within the arc's rectangle, then Skia will extend the stroke outside the rectangle. Impeller does not match Skia's behavior.
import 'package:flutter/material.dart';
import 'dart:math';
void main() {
runApp(CustomPaint(
size: const Size(300, 300),
painter: TestPainter(),
));
}
class TestPainter extends CustomPainter {
void paint(Canvas canvas, Size size) {
Rect r = Rect.fromLTRB(100, 100, 200, 200);
canvas.drawRect(r, Paint()..color = Colors.red);
canvas.drawArc(
r,
0, pi / 2,
false,
Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 300
);
}
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
| Impeller | Skia |
|---|---|
@darshankawar @jason-simmons
What will be the priority?
My app is having an impact on clients, so I would like to see a quick response, but first I'm curious as to what the priorities will be.
We'll likely need to handle this specifically for the draw arc call, some ideas that come to mind would be inspecting the stroke width and converting the arc to a fill based on that width. I might also just go and read the skia source code to figure this out.
We do the conversion from the arc to a path here:
https://github.com/flutter/flutter/blob/master/engine/src/flutter/impeller/display_list/dl_dispatcher.cc#L639-L648
I considered clamping the fill stroke width to the radius of the circle that fits in the rect in the dl_dispatcher. The radius of the circle is the rect's width divided by 2, but since the stroke grows about it's center it needs to be twice as big, so we clamp to the rect's width.
import 'package:flutter/material.dart';
import 'dart:math';
void main() {
runApp(CustomPaint(
size: const Size(300, 300),
painter: TestPainter(),
));
}
class TestPainter extends CustomPainter {
void paint(Canvas canvas, Size size) {
Rect r = Rect.fromLTRB(100, 100, 200, 200);
canvas.drawRect(r, Paint()..color = Colors.red);
canvas.drawArc(
r,
0, pi / 2,
false,
Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = min(300, r.width)
);
}
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
After further consideration I realized this only works for a square though, not a rectangle where the radius is a function of f(Θ).
Naively switching to a fill stroke isn't going to give us what we want since the fill doesn't include the space up to the center of the rectangle, which we want. It's like slicing off the edge of an oval between 2 points on the edge, not cut like a pizza.
Considering the math above, here is the criteria that needs special consideration:
stroke_width > min(rect.height, rect.width)
I think we should do something like:
if (strokeWidth > min(rect.height, rect.width)) {
Rect expandedRect = rect.expand(strokeWidth / 2);
Path path;
path.moveTo(rect.center);
path.lineTo(toOvalCartesian(startAngle, expandedRect.width, expandedRect.height));
path.arcTo(expandedRect, startAngle, stopAngle);
path.lineTo(center);
drawPath(path, strokeWidth:1, 'fill);
} else {
drawArc(rect, stroke_width, 'stroke');
}
if (strokeWidth > min(rect.height, rect.width)) {
This isn't quite going to work for ovals where strokeWidth > min(rect.height, rect.width) && strokeWidth < max(rect.height, rect.width). We'll probably need to perform a clip.
We can special case this to make it work if stroke_width > max(width, height). There is still some oddities happening though in the arc code when stroke_width < max(width, height)
https://github.com/user-attachments/assets/94978310-88f4-4aaa-a01b-50e1fec07935
https://github.com/user-attachments/assets/0c93723c-6156-4efb-9cf0-1183d1847ab7
diff --git a/engine/src/flutter/impeller/display_list/dl_dispatcher.cc b/engine/src/flutter/impeller/display_list/dl_dispatcher.cc
index 487d2a8551..fa2795e935 100644
--- a/engine/src/flutter/impeller/display_list/dl_dispatcher.cc
+++ b/engine/src/flutter/impeller/display_list/dl_dispatcher.cc
@@ -642,10 +642,23 @@ void DlDispatcherBase::drawArc(const DlRect& oval_bounds,
bool use_center) {
AUTO_DEPTH_WATCHER(1u);
- PathBuilder builder;
- builder.AddArc(oval_bounds, Degrees(start_degrees), Degrees(sweep_degrees),
- use_center);
- GetCanvas().DrawPath(builder.TakePath(), paint_);
+ if (paint_.stroke_width >
+ std::max(oval_bounds.GetWidth(), oval_bounds.GetHeight())) {
+ DlRect expanded_rect = oval_bounds.Expand(Size(paint_.stroke_width / 2));
+ PathBuilder builder;
+ Paint fill_paint = paint_;
+ fill_paint.style = Paint::Style::kFill;
+ fill_paint.stroke_width = 1;
+ builder.AddArc(expanded_rect, Degrees(start_degrees),
+ Degrees(sweep_degrees),
+ /*use_center=*/true);
+ GetCanvas().DrawPath(builder.TakePath(), fill_paint);
+ } else {
+ PathBuilder builder;
+ builder.AddArc(oval_bounds, Degrees(start_degrees), Degrees(sweep_degrees),
+ use_center);
+ GetCanvas().DrawPath(builder.TakePath(), paint_);
+ }
}
This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.