Created
April 24, 2025 18:54
-
-
Save EsinShadrach/2582eab81d8b6829e4c5186ab357283a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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