flutter_magnetic_scroll
A custom ScrollPhysics package designed for "TikTok/Reels" style feeds, horizontal carousels, and custom pickers. It allows developers to define "magnetic snap points" based on item size. The user can freely scroll, but when they let go, the UI aggressively and smoothly snaps to the nearest logical item boundary.
Features
- MagneticScrollView Wrapper: A clean, out-of-the-box widget so you don't have to wire up controllers and physics manually.
- TikTok/Reels Style Scrolling: Easily snap to full screen or fixed size items.
- Variable Item Sizes & Infinite Feeds: Supports lists with dynamically sized items via
itemSizesor an infiniteitemSizeBuilder. - Snap Alignment: Snap items to the
start,center, orendof the viewport (perfect for App Store style horizontal carousels). - Strict Single Item Snapping: Enforce a "one item per swipe" rule so users can't accidentally skip past content when scrolling fast.
- Controlled Multi-Item Flinging: Customize
flingFriction(default0.08) andmaxFlingItemsto decide exactly how many items can be skipped in a single swipe without flying away. - MagneticScrollController: A specialized controller to programmatically jump or animate to specific items, and effortlessly track the currently focused item.
- Early Event Triggers: A built-in
onItemFocusedcallback that fires exactly when the physics engine predicts its target, allowing you to prep/play videos or animations early!
MagneticScrollView requires exactly one sizing strategy: itemSize,
itemSizes, or itemSizeBuilder. It constrains every child to that main-axis
extent, so snap calculations and rendered layout stay synchronized. All sizes
must be positive and finite.
Usage
1. The Easiest Way: MagneticScrollView
The package provides a MagneticScrollView widget which acts as a wrapper around ListView.builder. This handles the heavy lifting for you!
import 'package:flutter/material.dart';
import 'package:flutter_magnetic_scroll/flutter_magnetic_scroll.dart';
class SimpleFeed extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MagneticScrollView(
itemCount: 10,
itemSize: MediaQuery.of(context).size.height, // Fixed size
strictSingleItemSnapping: true,
onItemFocused: (index) => debugPrint("Snapped to $index"),
itemBuilder: (context, index) {
return Container(
color: index.isEven ? Colors.red : Colors.blue,
child: Center(child: Text('Video \$index')),
);
},
);
}
}
2. Horizontal Carousels (Center Alignment)
If you are building a horizontal carousel where cards are smaller than the screen, you usually want the focused card to snap to the center of the screen. Just use MagneticSnapAlignment.center!
MagneticScrollView(
scrollDirection: Axis.horizontal,
itemCount: 10,
itemSize: 300,
alignment: MagneticSnapAlignment.center, // Snaps to the middle!
itemBuilder: (context, index) => MyCard(index),
)
3. Infinite Variable-Size Feeds (itemSizeBuilder)
If you have an infinite list where every item has a different height (like a social media text feed), use the itemSizeBuilder. It calculates and caches boundaries on the fly without lagging.
MagneticScrollView(
itemCount: myPosts.length,
itemSizeBuilder: (index) {
// Dynamically return the height of the item at this index
return myPosts[index].hasImage ? 400.0 : 150.0;
},
itemBuilder: (context, index) => MyPostWidget(index),
)
Omit itemCount only when the builder can produce a genuinely unbounded feed.
Sizes returned by itemSizeBuilder use a bounded sliding cache. When underlying
size data changes without replacing the callback, increment
itemSizeCacheVersion:
MagneticScrollView(
itemCount: posts.length,
itemSizeBuilder: (index) => posts[index].height,
itemSizeCacheVersion: layoutRevision,
itemBuilder: (context, index) => PostCard(posts[index]),
)
For center and end alignment, the wrapper automatically adds enough edge
padding for the first and last items to reach their requested alignment.
Custom padding is included in snap-offset calculations.
4. Programmatic Navigation with MagneticScrollController
If you need to animate to specific items or listen to the focused state manually, initialize a MagneticScrollController.
class MyStatefulFeed extends StatefulWidget {
@override
State<MyStatefulFeed> createState() => _MyStatefulFeedState();
}
class _MyStatefulFeedState extends State<MyStatefulFeed> {
late MagneticScrollController _controller;
@override
void initState() {
super.initState();
_controller = MagneticScrollController(itemSize: 200.0, itemCount: 20);
_controller.currentIndex.addListener(() {
debugPrint('Focused on: \${_controller.currentIndex.value}');
});
}
void skipToNext() {
_controller.animateToItem(
index: _controller.currentIndex.value + 1,
duration: Duration(milliseconds: 300),
);
}
@override
Widget build(BuildContext context) {
return MagneticScrollView(
controller: _controller,
itemSize: 200.0,
itemCount: 20,
itemBuilder: (context, index) => Text('Item \$index'),
);
}
}
Check the example/ folder for a fully interactive implementation!