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 itemSizes or an infinite itemSizeBuilder.
  • Snap Alignment: Snap items to the start, center, or end of 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 (default 0.08) and maxFlingItems to 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 onItemFocused callback 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!