flutter_magnetic_scroll 0.3.0
flutter_magnetic_scroll: ^0.3.0 copied to clipboard
A Flutter custom ScrollPhysics package for TikTok/Reels style feeds and pickers. Features magnetic snapping to fixed or variable item boundaries with physics-based smoothness.
import 'package:flutter/material.dart';
import 'package:flutter_magnetic_scroll/flutter_magnetic_scroll.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Magnetic Scroll Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MagneticScrollExample(),
);
}
}
class MagneticScrollExample extends StatefulWidget {
const MagneticScrollExample({super.key});
@override
State<MagneticScrollExample> createState() => _MagneticScrollExampleState();
}
class _MagneticScrollExampleState extends State<MagneticScrollExample> {
int _tabIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Magnetic Scroll Features')),
body: IndexedStack(
index: _tabIndex,
children: [
const VerticalFeedExample(),
const HorizontalCarouselExample(),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _tabIndex,
onTap: (index) => setState(() => _tabIndex = index),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.list),
label: 'Vertical Feed',
),
BottomNavigationBarItem(
icon: Icon(Icons.view_carousel),
label: 'Horizontal Carousel',
),
],
),
);
}
}
class VerticalFeedExample extends StatefulWidget {
const VerticalFeedExample({super.key});
@override
State<VerticalFeedExample> createState() => _VerticalFeedExampleState();
}
class _VerticalFeedExampleState extends State<VerticalFeedExample> {
late MagneticScrollController _controller;
int _currentIndex = 0;
late List<double> _itemSizes;
@override
void initState() {
super.initState();
_itemSizes = List.generate(20, (index) {
if (index % 3 == 0) return 200.0;
if (index % 3 == 1) return 300.0;
return 400.0;
});
_controller = MagneticScrollController(itemSizes: _itemSizes);
_controller.currentIndex.addListener(() {
setState(() {
_currentIndex = _controller.currentIndex.value;
});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Current Focused Index: $_currentIndex',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
Expanded(
child: MagneticScrollView(
controller: _controller,
itemSizes: _itemSizes,
itemCount: _itemSizes.length,
maxFlingItems: 4,
onItemFocused: (index) {
debugPrint('Physics targeted index: $index');
},
itemBuilder: (context, index) {
final height = _itemSizes[index];
final isFocused = _currentIndex == index;
const verticalMargin = 8.0;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: height - (verticalMargin * 2),
margin: const EdgeInsets.symmetric(
vertical: verticalMargin,
horizontal: 16,
),
decoration: BoxDecoration(
color: Colors.primaries[index % Colors.primaries.length],
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isFocused ? Colors.white : Colors.transparent,
width: 4,
),
boxShadow: isFocused
? [const BoxShadow(color: Colors.black26, blurRadius: 10)]
: [],
),
child: Center(
child: Text(
'Item $index \nTotal Extent: $height',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
);
},
),
),
],
);
}
}
class HorizontalCarouselExample extends StatefulWidget {
const HorizontalCarouselExample({super.key});
@override
State<HorizontalCarouselExample> createState() =>
_HorizontalCarouselExampleState();
}
class _HorizontalCarouselExampleState extends State<HorizontalCarouselExample> {
late MagneticScrollController _controller;
int _currentIndex = 0;
final double _itemWidth = 250.0;
@override
void initState() {
super.initState();
// Use the MagneticScrollController to easily track the center-aligned item
_controller = MagneticScrollController(
itemSize: _itemWidth,
alignment: MagneticSnapAlignment.center,
);
_controller.currentIndex.addListener(() {
setState(() {
_currentIndex = _controller.currentIndex.value;
});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Center Aligned Item: $_currentIndex',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 20),
SizedBox(
height: 300,
child: MagneticScrollView(
strictSingleItemSnapping: false,
controller: _controller,
scrollDirection: Axis.horizontal,
itemSize: _itemWidth,
alignment: MagneticSnapAlignment.center,
itemCount: 20,
itemBuilder: (context, index) {
final isFocused = _currentIndex == index;
const horizontalMargin = 12.0;
final scale = isFocused ? 1.0 : 0.9;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: _itemWidth - (horizontalMargin * 2),
margin: const EdgeInsets.symmetric(
horizontal: horizontalMargin,
vertical: 16,
),
decoration: BoxDecoration(
color: Colors.primaries[index % Colors.primaries.length],
borderRadius: BorderRadius.circular(24),
// Make the focused item pop out slightly!
boxShadow: isFocused
? [
const BoxShadow(
color: Colors.black45,
blurRadius: 15,
spreadRadius: 2,
),
]
: [],
),
// Scale effect for the focused item
transform: Matrix4.diagonal3Values(scale, scale, 1.0),
transformAlignment: Alignment.center,
child: Center(
child: Text(
'Card $index',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
);
},
),
),
const SizedBox(height: 40),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton.icon(
icon: const Icon(Icons.arrow_back),
label: const Text('Previous'),
onPressed: () {
if (_currentIndex > 0) {
_controller.animateToItem(index: _currentIndex - 1);
}
},
),
const SizedBox(width: 16),
ElevatedButton.icon(
icon: const Icon(Icons.arrow_forward),
label: const Text('Next'),
onPressed: () {
if (_currentIndex < 19) {
_controller.animateToItem(index: _currentIndex + 1);
}
},
),
],
),
],
);
}
}