flutterdartflutter-custompainter

Flutter atan2 Angle Calculation for Rotating Line - 90 Degree Offset Issue


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

Solution

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