Skip to content

Instantly share code, notes, and snippets.

@EsinShadrach
Created April 24, 2025 18:54
Show Gist options
  • Select an option

  • Save EsinShadrach/2582eab81d8b6829e4c5186ab357283a to your computer and use it in GitHub Desktop.

Select an option

Save EsinShadrach/2582eab81d8b6829e4c5186ab357283a to your computer and use it in GitHub Desktop.
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/services.dart';
class CircularMotion extends StatefulWidget {
final bool useBuilder;
final IndexedWidgetBuilder? builder;
final List<Widget>? children;
final Widget? centerWidget;
final int? itemCount;
final HitTestBehavior? behavior;
final bool speedRunEnabled;
final bool snapEnabled;
final ValueChanged<int?>? onSelectedIndexChanged;
final ValueChanged<double>? onAngleChanged;
final double? minAngle;
final double? maxAngle;
const CircularMotion.builder({
super.key,
required this.builder,
required this.itemCount,
this.centerWidget,
this.behavior,
this.speedRunEnabled = true,
this.snapEnabled = true,
this.onSelectedIndexChanged,
this.onAngleChanged,
this.minAngle,
this.maxAngle,
}) : useBuilder = true,
children = null;
const CircularMotion({
super.key,
required this.children,
this.centerWidget,
this.behavior,
this.speedRunEnabled = true,
this.snapEnabled = true,
this.onSelectedIndexChanged,
this.onAngleChanged,
this.minAngle,
this.maxAngle,
}) : useBuilder = false,
builder = null,
itemCount = null;
@override
State<CircularMotion> createState() => _CircularMotionState();
}
class _CircularMotionState extends State<CircularMotion>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
double angle = 0;
int prevPosInt = 1;
int? _selectedIndex = 0;
String? prevPos;
@override
void initState() {
super.initState();
_animationController = AnimationController.unbounded(vsync: this);
_animationController.addListener(() {
setState(() {
angle = normalizeAngle(_animationController.value);
});
widget.onAngleChanged?.call(angle);
});
_animationController.addStatusListener((status) {
if (status == AnimationStatus.completed ||
status == AnimationStatus.dismissed) {
final index = getSelectedIndex();
if (_selectedIndex != index) {
_selectedIndex = index;
widget.onSelectedIndexChanged?.call(_selectedIndex);
}
}
});
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
BuildContext? _findContextByKey(Key key) {
BuildContext? result;
void visit(Element element) {
if (element.widget.key == key) {
result = element;
} else {
element.visitChildElements(visit);
}
}
context.visitChildElements(visit);
return result;
}
void updateSelectedIndexFromVisualPosition() {
final total = widget.itemCount ?? widget.children?.length ?? 0;
double minDy = double.infinity;
int? topItem;
for (int i = 0; i < total; i++) {
final ctx = _findContextByKey(ValueKey(i));
if (ctx == null) continue;
final renderBox = ctx.findRenderObject() as RenderBox?;
if (renderBox == null || !renderBox.hasSize) continue;
final offset = renderBox.localToGlobal(Offset.zero);
if (offset.dy < minDy) {
minDy = offset.dy;
topItem = i;
}
}
if (topItem != null && _selectedIndex != topItem) {
setState(() {
_selectedIndex = topItem!;
});
debugPrint('Selected Index: $topItem');
widget.onSelectedIndexChanged?.call(_selectedIndex);
}
HapticFeedback.heavyImpact();
}
int getSelectedIndex() {
final totalItems = widget.itemCount ?? widget.children?.length ?? 0;
if (totalItems == 0) return 0;
final double distanceAngle = getDistanceAngle(
widget.itemCount,
widget.children?.length,
);
// This formula finds the item exactly at 12 o'clock
int indexAtTop = ((90 - angle) / distanceAngle).round() % totalItems;
// Ensure it's positive
if (indexAtTop < 0) indexAtTop += totalItems;
return indexAtTop;
}
void runDeceleration(double velocity) {
final simulation = FrictionSimulation(.08, angle + 1, velocity);
_animationController.animateWith(simulation);
}
void snapToNearestItem() {
final totalItems = widget.itemCount ?? widget.children?.length ?? 0;
if (totalItems == 0) return;
final double distanceAngle = getDistanceAngle(
widget.itemCount,
widget.children?.length,
);
final double currentAngle = normalizeAngle(angle);
int nearestIndex = 0;
double minDiff = double.infinity;
for (int i = 0; i < totalItems; i++) {
final itemAngle = normalizeAngle(-90 + i * distanceAngle);
final diff = ((itemAngle - currentAngle + 540) % 360) - 180;
if (diff.abs() < minDiff.abs()) {
minDiff = diff;
nearestIndex = i;
}
}
final targetAngle = normalizeAngle(-90 + nearestIndex * distanceAngle);
final start = _animationController.value;
final shortestRotation = ((targetAngle - start + 540) % 360) - 180;
final finalTarget = start + shortestRotation;
_animationController
.animateTo(
finalTarget,
duration: snapDurationFromDistance(shortestRotation),
curve: Curves.easeOutCubic,
)
.whenComplete(() {
updateSelectedIndexFromVisualPosition();
});
if (_selectedIndex != nearestIndex) {
_selectedIndex = nearestIndex;
widget.onSelectedIndexChanged?.call(_selectedIndex);
}
}
@override
Widget build(BuildContext context) {
final distanceAngle = getDistanceAngle(
widget.itemCount,
widget.children?.length,
);
return LayoutBuilder(
builder: (context, constraints) {
final size = constraints.biggest;
final halfWidth = size.width / 2;
final halfHeight = size.height / 2;
return GestureDetector(
behavior: widget.behavior,
onVerticalDragStart: (_) => _animationController.stop(),
onVerticalDragUpdate: (details) {
var pos = getAngle(halfWidth, halfHeight, details.localPosition);
var direction = updateScrollDirection(pos, false);
setState(() {
angle += (details.primaryDelta ?? 0) * direction;
_selectedIndex = null;
});
widget.onSelectedIndexChanged?.call(null);
widget.onAngleChanged?.call(normalizeAngle(angle));
},
onVerticalDragEnd: (details) {
prevPos = null;
handleDragEnd(details.primaryVelocity ?? 0);
},
onHorizontalDragStart: (_) => _animationController.stop(),
onHorizontalDragUpdate: (details) {
var pos = getAngle(halfWidth, halfHeight, details.localPosition);
var x = updateScrollDirection(pos, true);
setState(() {
angle += ((details.primaryDelta ?? 0) * x);
_selectedIndex = null;
});
widget.onSelectedIndexChanged?.call(null);
widget.onAngleChanged?.call(normalizeAngle(angle));
},
onHorizontalDragEnd: (details) {
prevPos = null;
handleDragEnd(details.primaryVelocity ?? 0);
},
child: Stack(
children: [
for (
int i = 0;
i < (widget.itemCount ?? widget.children?.length ?? 0);
i++
)
Align(
alignment: _getAlignmentForIndex(i, distanceAngle),
child:
widget.useBuilder
? widget.builder!(context, i)
: widget.children![i],
),
if (widget.centerWidget != null)
const Align(alignment: Alignment.center),
Align(alignment: Alignment.center, child: widget.centerWidget),
],
),
);
},
);
}
Alignment _getAlignmentForIndex(int index, double distanceAngle) {
int total = widget.itemCount ?? widget.children?.length ?? 0;
final selected = _selectedIndex ?? 0;
double extraSpacing = 10;
double modifiedAngle = angle - 90 + distanceAngle * index;
if (index == (selected - 1 + total) % total) {
modifiedAngle -= extraSpacing;
} else if (index == (selected + 1) % total) {
modifiedAngle += extraSpacing;
}
final x = cos(modifiedAngle.radians);
final y = sin(modifiedAngle.radians);
return Alignment(x, y);
}
int updateScrollDirection(double angle, bool isHorizontal) {
if (angle > 0 && angle < 90 && prevPos == null) {
prevPos = 'dnB';
prevPosInt = isHorizontal ? -1 : 1;
} else if (angle > 90 && angle < 180 && prevPos == null) {
prevPos = 'dnA';
prevPosInt = -1;
} else if (angle < 0 && angle > -90 && prevPos == null) {
prevPos = 'upB';
prevPosInt = 1;
} else if (angle < -90 && angle > -180 && prevPos == null) {
prevPos = 'upA';
prevPosInt = isHorizontal ? 1 : -1;
}
return prevPosInt;
}
void handleDragEnd(double velocity) {
if (widget.speedRunEnabled && !widget.snapEnabled) {
runDeceleration(velocity * prevPosInt);
} else if (widget.snapEnabled) {
snapToNearestItem();
}
widget.onAngleChanged?.call(0);
}
}
double getDistanceAngle(int? count, int? children) {
return count == null || count == 0 ? (360 / (children ?? 1)) : (360 / count);
}
double getAngle(double halfWidth, double halfHeight, Offset position) {
final x = position.dx - halfWidth;
final y = position.dy - halfHeight;
return atan2(y, x) * 180 / pi;
}
Duration snapDurationFromDistance(double angleDelta) {
final ms = (angleDelta.abs() * 3).clamp(100, 600);
return Duration(milliseconds: ms.toInt());
}
double normalizeAngle(double angle) {
return (angle % 360 + 360) % 360;
}
extension CircularExt on double {
double get radians => this * (pi / 180);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment