I think your problem is how you compute your endingX.
var endingX = middleX + cos(radians) * radius;
It seems that your endingX should be the distance between the side of the Canvas and the perimeter of the Circles in their initial position. It's, therefore, the same for both directions:
var endingX = middleX - radius;
Then, a few simplifications on your code:
negativeOrPositiveOrZero
You have a getter for that in dart:math: sign
Trigonometry
I suppose the sample you posted is much simpler than your real code and the simplifications hereafter are probably not meaningful.
However, pay attention that near zero calculus on computers is quite messy!
import 'dart:math';
void main() {
print(' sin(0).sign: ${sin(0).sign}');
print(' sin(0).sign: ${sin(0).sign}');
print('------------------------------------------');
print(' sin(180*pi/180): ${sin(180*pi/180)}');
print('!! sin(180*pi/180).sign: ${sin(180*pi/180).sign}');
}
sin(0).sign: 0
sin(0).sign: 0
------------------------------------------
sin(180*pi/180): 1.2246467991473532e-16
!! sin(180*pi/180).sign: 1
Full example after correction and simplification

import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Rolling balls',
home: Scaffold(
body: Center(
child: Container(
width: 300,
height: 200,
color: Colors.amber.shade300,
child: const MyCustomPaint(),
),
),
),
);
}
}
class MyCustomPaint extends StatefulWidget {
const MyCustomPaint({Key? key}) : super(key: key);
@override
_MyCustomPaintState createState() => _MyCustomPaintState();
}
class _MyCustomPaintState extends State<MyCustomPaint>
with SingleTickerProviderStateMixin {
late Animation<double> _animation;
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 4),
);
Tween<double> _animationTween = Tween(begin: 0.0, end: 1.0);
_animation = _animationTween.animate(_controller)
..addListener(() => setState(() {}))
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_controller.reverse();
} else if (status == AnimationStatus.dismissed) {
_controller.forward();
}
});
_controller.forward();
}
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: MyCustomPainter(_animation.value),
);
}
}
class MyCustomPainter extends CustomPainter {
final double percentFinished;
MyCustomPainter(this.percentFinished);
@override
void paint(Canvas canvas, Size size) {
double middleY = size.height / 2;
double middleX = size.width / 2;
double radius = size.width / 20;
Paint paint = Paint()..color = Colors.black;
for (int direction in [1, -1]) {
var endingX = middleX - radius;
var addToX = direction * percentFinished;
canvas.drawCircle(
Offset(endingX * addToX + middleX, middleY),
radius,
paint,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}