flutter_circular_list 0.0.2
flutter_circular_list: ^0.0.2 copied to clipboard
A highly customizable, reusable circular/arc list widget for Flutter with infinite looping, smooth snapping, drag physics and a dedicated controller.
flutter_circular_list #
A reusable, highly customizable circular/arc list widget for Flutter — items lay out along an arc and support infinite looping, tap/drag/swipe and programmatic selection, all with deep visual customization and no third-party dependencies.
Requires Flutter >= 3.10, Dart >= 3.0.
![]() |
![]() |
|
![]() |
![]() |
![]() |
All captured from example/ — run it yourself to try every feature below live.
Install #
dependencies:
flutter_circular_list: <latest_version>
flutter pub get
Quick start #
import 'package:flutter_circular_list/flutter_circular_list.dart';
SizedBox(
height: 220,
child: CircularList<String>(
items: const ['A', 'B', 'C', 'D', 'E'],
radius: 120,
itemExtent: 80,
itemBuilder: (context, item, index, isSelected) {
return CircleAvatar(
radius: isSelected ? 32 : 24,
child: Text(item),
);
},
onItemSelected: (index, item) => debugPrint('Selected $item at $index'),
),
)
CircularList needs a bounded size on both axes — wrap it in a SizedBox (or place it inside an
already-constrained parent) as shown above.
Features #
- Infinite looping or bounded scrolling (
loop: true/false). - Horizontal or vertical axis.
- Tap, drag, swipe and programmatic selection, all backed by the same underlying scroll position.
CircularListController—next(),previous(),animateToIndex(),jumpToIndex().- Deep visual customization via
CircularListStyle— scale, opacity, rotation, arc direction, elevation/shadow. - Generic —
CircularList<T>works with any data type via anitemBuilder. - Callbacks —
onItemSelected,onItemChanged,onScroll. - Works with light and dark themes — no imposed colors/styling.
- Zero third-party dependencies — only
flutter. - Basic accessibility (
Semantics) and keyboard (arrow key) support. - Built to avoid unnecessary rebuilds — only on-screen items are built, and
itemBuilderonly re-runs when an item's selection state actually changes, not on every animation frame.
Controller usage #
Use CircularListController for programmatic control — next/previous, jump straight to an
index, or animate to one (taking the shortest path around the loop):
final controller = CircularListController(initialIndex: 2);
CircularList<String>(
controller: controller,
items: items,
itemBuilder: (context, item, index, isSelected) => MyItem(item: item),
);
controller.next();
controller.previous();
controller.animateToIndex(5);
controller.jumpToIndex(0); // no animation
// The controller is also a Listenable, so you can react to selection
// changes that originate from drags/taps, not just your own calls:
controller.addListener(() => print(controller.selectedIndex));
// Dispose it like any other controller.
controller.dispose();
Customization #
Fine-tune the look with CircularListStyle:
CircularList<String>(
items: items,
itemBuilder: (context, item, index, isSelected) => MyItem(item: item),
axis: Axis.vertical,
loop: false,
radius: 100,
itemExtent: 70,
spacing: 16,
animationDuration: const Duration(milliseconds: 400),
animationCurve: Curves.easeOutBack,
style: const CircularListStyle(
selectedScale: 1.2,
minScale: 0.7,
selectedOpacity: 1.0,
minOpacity: 0.4,
rotationFactor: 0.4, // tilt items as they move away
curveDirection: CircularListCurveDirection.outward,
elevation: 16, // soft drop shadow
),
)
| Option | Notes |
|---|---|
radius: 0 |
Collapses the arc into a straight line (a classic coverflow). |
curveDirection |
Bows items towards or away from the main axis, or stays flat with .none. |
dragSensitivity |
Scales how far the list moves per pixel dragged. |
clipBehavior |
Defaults to Clip.none so a scaled-up selected item is never cut off. |
maxSweepRadians |
Caps how far (in radians) an item may sweep around the arc — defaults to just short of a half turn, which keeps a handful of wide items (cards, avatars) fanning outward instead of folding back past 90° and overlapping their neighbor. Raise it towards 2 * pi for a dense wheel/dial of many small items where you want a fully closed circle; pair it with a larger extraRenderSlots so enough off-screen slots exist to reach the far side of the loop. |
extraRenderSlots |
How many extra items render beyond the visible viewport on each side (default 2). |
API #
CircularList<T>(
items: items,
controller: controller,
itemBuilder: (context, item, index, isSelected) => MyItem(item: item),
initialIndex: 0,
axis: Axis.horizontal,
radius: 120,
itemExtent: 80,
spacing: 12,
loop: true,
animationDuration: const Duration(milliseconds: 350),
animationCurve: Curves.easeOutCubic,
style: const CircularListStyle(),
onItemSelected: (index, item) {}, // fires when the selection settles
onItemChanged: (index, item) {}, // fires live, as the nearest item changes
onScroll: (position) {}, // fires continuously with the raw position
emptyBuilder: (context) => const Text('No items'),
)
| Member | Description |
|---|---|
CircularList<T> |
The widget. |
CircularListController |
next(), previous(), animateToIndex(int), jumpToIndex(int), selectedIndex. |
CircularListStyle |
Scale/opacity/rotation/arc/elevation tuning. |
CircularListCurveDirection |
inward / outward / none. |
Example project #
Full working app: example/lib/main.dart, covering:
- A basic circular list.
- Custom Material cards.
- An avatar/profile switcher.
- Infinite scrolling over hundreds of items.
- Controller-driven navigation (buttons, no gestures).
- A vertical-axis list with live-tunable radius/rotation/curve.
- Light/dark theme toggling.
Run it with:
cd example
flutter run
Performance notes #
- Only the items within (and just beyond) the visible viewport are ever built — dragging through a list of thousands of items does not build thousands of widgets.
itemBuilderis isolated from the per-frame animation: it's only re-invoked when an item'sisSelectedflag actually flips, not on every tick of a drag or animation.- Each item is wrapped in a
RepaintBoundaryso animating the list doesn't force sibling/ancestor repaints. - No
setStateis triggered on every drag pixel — only when the rendered "window" of items needs to grow, which happens roughly once per item crossed, not once per frame.
Limitations #
- Requires bounded constraints on both axes (an explicit
width/height, or a bounded parent) — same as most non-sliver scrollables in Flutter. - Not a
Sliver; cannot currently be embedded directly inside aCustomScrollView. - Very large
itemExtent/radiuscombinations withrotationFactorcan cause visual overlap at the edges of the viewport; tunevisibleItemFractiononCircularListStyleif you see this. - Semantics/keyboard support covers the common cases (selection announced, arrow-key navigation) but hasn't been exhaustively tested with every screen reader.
Bugs & Credits #
Report bugs and ask questions on GitHub Issues. Maintained by Dashstack Infotech, Surat.
License #
MIT — see LICENSE.




