I'm building a Flutter app that allows users to rotate a line within a circle using a GestureDetector. The angle of the line is calculated based on the user's touch position. However, I'm encountering a persistent 90-degree offset issue.
Expected Behavior:
Actual Behavior:
What I've Tried:
I've tried various approaches to adjust the angle calculation, including:
The code:
import 'package:flutter/material.dart';
import 'dart:math';
import 'package:vibration/vibration.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primaryColor: Colors.black,
useMaterial3: true,
),
home: const MyHomePage(title: 'Angle Inputter'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
double _angle = 0.0;
bool _dotPressed = false;
bool _isPointInsideDot(Offset point, Offset dotCenter, double radius) {
return (point - dotCenter).distance <= radius;
}
Rect _getDotTouchTarget(Offset dotCenter, double dotRadius) {
const double touchTargetPadding = 24.0;
return Rect.fromCenter(
center: dotCenter,
width: 2 * (dotRadius + touchTargetPadding),
height: 2 * (dotRadius + touchTargetPadding),
);
}
void _updateAngle(Offset position) {
final Offset center = Offset(100, 100); // Circle center
final double radius = 100; // Circle radius
// Calculate angle
double newAngle = (atan2(position.dy - center.dy, position.dx - center.dx) * 180 / pi) % 360;
// Constrain line endpoint to the circle
final double dx = radius * cos(newAngle * pi / 180);
final double dy = radius * sin(newAngle * pi / 180);
final Offset constrainedPosition = center + Offset(dx, dy);
// NOW adjust for Flutter's coordinate system (after constraining)
newAngle = (newAngle + 90) % 360;
setState(() {
_angle = newAngle;
});
}
@override
Widget build(BuildContext context) {
double displayAngle = _angle <= 180 ? _angle : 360 - _angle;
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: Text(widget.title),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Angle: ${displayAngle.toInt()}°',
style: Theme.of(context).textTheme.titleLarge,
),
),
Expanded(
child: Center(
child: SizedBox(
width: 200,
height: 200,
child: GestureDetector(
onPanStart: (details) {
final dotRadius = _dotPressed ? 12.0 : 6.0;
final Offset center = Offset(100, 100);
final Offset dotCenter = Offset(
center.dx + 100 * cos(_angle * pi / 180),
center.dy + 100 * sin(_angle * pi / 180),
);
if (_getDotTouchTarget(dotCenter, dotRadius).contains(details.localPosition)) {
setState(() {
_dotPressed = true;
Vibration.vibrate();
});
}
},
onPanUpdate: (details) {
if (_dotPressed) {
_updateAngle(details.localPosition);
}
},
onPanEnd: (details) {
setState(() {
_dotPressed = false;
});
},
child: CustomPaint(
size: const Size(200, 200),
painter: AnglePainter(_angle, _dotPressed),
),
),
),
),
),
],
),
);
}
}
class AnglePainter extends CustomPainter {
final double angle;
final bool dotPressed;
AnglePainter(this.angle, this.dotPressed);
@override
void paint(Canvas canvas, Size size) {
final double radius = size.width / 2;
final Offset center = Offset(size.width / 2, size.height / 2);
// Draw the blue circle
final circlePaint = Paint()
..color = Colors.blue
..strokeWidth = 4.0
..style = PaintingStyle.stroke;
canvas.drawCircle(center, radius, circlePaint);
// Draw the green reference line
final referenceLinePaint = Paint()
..color = Colors.green
..strokeWidth = 2.0;
canvas.drawLine(center, Offset(center.dx + radius, center.dy), referenceLinePaint);
// Calculate the end point of the red line based on the angle
final double radian = angle * (pi / 180);
final Offset endPoint = Offset(
center.dx + radius * cos(radian),
center.dy + radius * sin(radian),
);
// Draw the red line that rotates
final linePaint = Paint()
..color = Colors.red
..strokeWidth = 4.0;
canvas.drawLine(center, endPoint, linePaint);
// Draw the blue dot at the end of the red line
final dotPaint = Paint()
..color = Colors.blue
..style = PaintingStyle.fill;
canvas.drawCircle(endPoint, dotPressed ? 12.0 : 6.0, dotPaint);
// Draw the angle text on top
final textSpan = TextSpan(
text: '${(angle <= 180 ? angle : 360 - angle).toStringAsFixed(1)}°',
style: const TextStyle(color: Colors.black, fontSize: 18),
);
final textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr,
);
textPainter.layout(
minWidth: 0,
maxWidth: size.width,
);
final textOffset = Offset(center.dx - textPainter.width / 2, center.dy - radius - textPainter.height - 10);
textPainter.paint(canvas, textOffset);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
As you said this happens because the angle calculation used to determine the line's rotation is consistently off by 90 degrees due to a misalignment with Flutter's coordinate system.
_updateAngle
method needs to be updated like this:
void _updateAngle(Offset position) {
final Offset center = Offset(100, 100); // Circle center
final double radius = 100; // Circle radius
// Calculate angle using atan2
double newAngle =
atan2(position.dy - center.dy, position.dx - center.dx) * 180 / pi;
// Adjust for Flutter's coordinate system
newAngle = (newAngle + 360) % 360; // Normalize to 0-360 degrees
setState(() {
_angle = newAngle;
});
}
Here is the fixed code:
import 'package:flutter/material.dart';
import 'dart:math';
import 'package:vibration/vibration.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primaryColor: Colors.black,
useMaterial3: true,
),
home: const MyHomePage(title: 'Angle Inputter'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
double _angle = 0.0;
bool _dotPressed = false;
bool _isPointInsideDot(Offset point, Offset dotCenter, double radius) {
return (point - dotCenter).distance <= radius;
}
Rect _getDotTouchTarget(Offset dotCenter, double dotRadius) {
const double touchTargetPadding = 24.0;
return Rect.fromCenter(
center: dotCenter,
width: 2 * (dotRadius + touchTargetPadding),
height: 2 * (dotRadius + touchTargetPadding),
);
}
void _updateAngle(Offset position) {
final Offset center = Offset(100, 100); // Circle center
final double radius = 100; // Circle radius
// Calculate angle using atan2
double newAngle = atan2(position.dy - center.dy, position.dx - center.dx) * 180 / pi;
// Adjust for Flutter's coordinate system
newAngle = (newAngle + 360) % 360; // Normalize to 0-360 degrees
setState(() {
_angle = newAngle;
});
}
@override
Widget build(BuildContext context) {
double displayAngle = _angle <= 180 ? _angle : 360 - _angle;
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
title: Text(widget.title),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Angle: ${displayAngle.toInt()}°',
style: Theme.of(context).textTheme.titleLarge,
),
),
Expanded(
child: Center(
child: SizedBox(
width: 200,
height: 200,
child: GestureDetector(
onPanStart: (details) {
final dotRadius = _dotPressed ? 12.0 : 6.0;
final Offset center = Offset(100, 100);
final Offset dotCenter = Offset(
center.dx + 100 * cos(_angle * pi / 180),
center.dy + 100 * sin(_angle * pi / 180),
);
if (_getDotTouchTarget(dotCenter, dotRadius).contains(details.localPosition)) {
setState(() {
_dotPressed = true;
Vibration.vibrate();
});
}
},
onPanUpdate: (details) {
if (_dotPressed) {
_updateAngle(details.localPosition);
}
},
onPanEnd: (details) {
setState(() {
_dotPressed = false;
});
},
child: CustomPaint(
size: const Size(200, 200),
painter: AnglePainter(_angle, _dotPressed),
),
),
),
),
),
],
),
);
}
}
class AnglePainter extends CustomPainter {
final double angle;
final bool dotPressed;
AnglePainter(this.angle, this.dotPressed);
@override
void paint(Canvas canvas, Size size) {
final double radius = size.width / 2;
final Offset center = Offset(size.width / 2, size.height / 2);
// Draw the blue circle
final circlePaint = Paint()
..color = Colors.blue
..strokeWidth = 4.0
..style = PaintingStyle.stroke;
canvas.drawCircle(center, radius, circlePaint);
// Draw the green reference line
final referenceLinePaint = Paint()
..color = Colors.green
..strokeWidth = 2.0;
canvas.drawLine(center, Offset(center.dx + radius, center.dy), referenceLinePaint);
// Calculate the end point of the red line based on the angle
final double radian = angle * (pi / 180);
final Offset endPoint = Offset(
center.dx + radius * cos(radian),
center.dy + radius * sin(radian),
);
// Draw the red line that rotates
final linePaint = Paint()
..color = Colors.red
..strokeWidth = 4.0;
canvas.drawLine(center, endPoint, linePaint);
// Draw the blue dot at the end of the red line
final dotPaint = Paint()
..color = Colors.blue
..style = PaintingStyle.fill;
canvas.drawCircle(endPoint, dotPressed ? 12.0 : 6.0, dotPaint);
// Draw the angle text on top
final textSpan = TextSpan(
text: '${(angle <= 180 ? angle : 360 - angle).toStringAsFixed(1)}°',
style: const TextStyle(color: Colors.black, fontSize: 18),
);
final textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr,
);
textPainter.layout(
minWidth: 0,
maxWidth: size.width,
);
final textOffset = Offset(center.dx - textPainter.width / 2, center.dy - radius - textPainter.height - 10);
textPainter.paint(canvas, textOffset);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}