Last active
September 13, 2024 19:29
-
-
Save hectorAguero/8a04f97954de6e4b387bd06187d1e82d to your computer and use it in GitHub Desktop.
Scrollable Page Dots
This file contains 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 'package:flutter/material.dart'; | |
void main() { | |
runApp(const MyApp()); | |
} | |
class MyApp extends StatefulWidget { | |
const MyApp({super.key}); | |
@override | |
State<MyApp> createState() => _MyAppState(); | |
} | |
class _MyAppState extends State<MyApp> { | |
int _currentIndex = 0; | |
final upperLimit = 14; | |
final maxVisibleDots = 7; | |
@override | |
Widget build(BuildContext context) => MaterialApp( | |
debugShowCheckedModeBanner: false, | |
home: Scaffold( | |
body: Center( | |
child: ScrollableDotIndicator( | |
itemCount: upperLimit, | |
currentIndex: _currentIndex, | |
maxVisibleDots: maxVisibleDots, | |
), | |
), | |
floatingActionButton: Builder( | |
builder: (context) => Row( | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: [ | |
FloatingActionButton( | |
onPressed: () { | |
if (_currentIndex == 0) { | |
ScaffoldMessenger.of(context).hideCurrentSnackBar(); | |
ScaffoldMessenger.of(context).showSnackBar( | |
const SnackBar( | |
content: Text( | |
"You can't go forward to the first page", | |
), | |
), | |
); | |
return; | |
} | |
setState(() { | |
_currentIndex = | |
(_currentIndex - 1).clamp(0, upperLimit); | |
}); | |
}, | |
child: const Icon(Icons.remove), | |
), | |
const SizedBox(width: 8), | |
FloatingActionButton.extended( | |
onPressed: () {}, | |
label: Text( | |
'Current index: $_currentIndex / ${upperLimit - 1}')), | |
const SizedBox(width: 8), | |
FloatingActionButton( | |
onPressed: () { | |
if (_currentIndex == upperLimit - 1) { | |
ScaffoldMessenger.of(context).hideCurrentSnackBar(); | |
ScaffoldMessenger.of(context).showSnackBar( | |
const SnackBar( | |
content: | |
Text("You can't go back to the last page"), | |
), | |
); | |
return; | |
} | |
setState(() { | |
_currentIndex = | |
(_currentIndex + 1).clamp(0, upperLimit); | |
}); | |
}, | |
child: const Icon(Icons.add), | |
), | |
], | |
)), | |
), | |
); | |
} | |
class ScrollableDotIndicator extends StatefulWidget { | |
const ScrollableDotIndicator({ | |
required this.itemCount, | |
required this.currentIndex, | |
super.key, | |
this.maxVisibleDots, | |
this.activeColor = Colors.blue, | |
this.inactiveColor = Colors.grey, | |
this.dotSize = 8.0, | |
this.spacing = 8.0, | |
this.activeDotWidth = 24.0, | |
this.smallDotSize = 4.0, | |
this.mediumDotSize = 6.0, | |
}) : assert(maxVisibleDots == null || maxVisibleDots % 2 != 0, | |
'maxVisibleDots must be null or an odd number'), | |
assert(maxVisibleDots == null || maxVisibleDots <= itemCount, | |
'maxVisibleDots must be less than or equal to itemCount'); | |
final int itemCount; | |
final int currentIndex; | |
final int? maxVisibleDots; | |
final Color activeColor; | |
final Color inactiveColor; | |
final double dotSize; | |
final double spacing; | |
final double activeDotWidth; | |
final double smallDotSize; | |
final double mediumDotSize; | |
@override | |
_ScrollableDotIndicatorState createState() => _ScrollableDotIndicatorState(); | |
} | |
class _ScrollableDotIndicatorState extends State<ScrollableDotIndicator> { | |
late ScrollController _scrollController; | |
int get effectiveMaxVisibleDots => widget.maxVisibleDots ?? widget.itemCount; | |
@override | |
void initState() { | |
super.initState(); | |
_scrollController = ScrollController(); | |
} | |
@override | |
void dispose() { | |
_scrollController.dispose(); | |
super.dispose(); | |
} | |
@override | |
void didUpdateWidget(ScrollableDotIndicator oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (oldWidget.currentIndex != widget.currentIndex) { | |
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToCenter()); | |
} | |
} | |
void _scrollToCenter() { | |
if (!_scrollController.hasClients || | |
widget.itemCount <= effectiveMaxVisibleDots) return; | |
final halfVisibleDots = effectiveMaxVisibleDots ~/ 2; | |
final maxScrollExtent = _scrollController.position.maxScrollExtent; | |
var scrollOffset = 0.0; | |
if (widget.currentIndex < (widget.itemCount - halfVisibleDots)) { | |
for (var i = 0; i < widget.currentIndex - halfVisibleDots; i++) { | |
scrollOffset += _getDotWidth(i) + widget.spacing; | |
} | |
} else { | |
scrollOffset = maxScrollExtent; | |
print('else'); | |
} | |
scrollOffset = scrollOffset.clamp(0.0, maxScrollExtent); | |
_scrollController.animateTo( | |
scrollOffset, | |
duration: const Duration(milliseconds: 50), | |
curve: Curves.easeInOut, | |
); | |
} | |
bool _isMiddleDot(int index) { | |
final middleStart = effectiveMaxVisibleDots ~/ 2; | |
final middleEnd = widget.itemCount - middleStart; | |
return index >= middleStart && index < middleEnd; | |
} | |
double _getDotWidth(int index) { | |
final isCurrent = index == widget.currentIndex; | |
final halfVisibleDots = effectiveMaxVisibleDots ~/ 2; | |
final isMoreThanHalf = widget.currentIndex > halfVisibleDots; | |
final first = (widget.currentIndex - halfVisibleDots) | |
.clamp(0, widget.itemCount - effectiveMaxVisibleDots); | |
final second = (first + 1); | |
final last = | |
(first + effectiveMaxVisibleDots - 1).clamp(0, widget.itemCount); | |
if (isCurrent) return widget.activeDotWidth; | |
// Si hay 6 o menos dots, todos son grandes | |
if (effectiveMaxVisibleDots <= 6) return widget.dotSize; | |
if ((index == first && isMoreThanHalf) || | |
(index == last && !isMoreThanHalf) || | |
(_isMiddleDot(widget.currentIndex) && | |
(index == first || index == last))) { | |
return widget.smallDotSize; | |
} else if ((index == second && isMoreThanHalf) || | |
(index == last - 1 && !isMoreThanHalf) || | |
(_isMiddleDot(widget.currentIndex) && | |
(index == second || index == last - 1))) { | |
return widget.mediumDotSize; | |
} | |
return widget.dotSize; | |
} | |
double _calculateWidgetWidth() { | |
var totalWidth = 0.0; | |
final halfVisibleDots = effectiveMaxVisibleDots ~/ 2; | |
final first = (widget.currentIndex - halfVisibleDots) | |
.clamp(0, widget.itemCount - effectiveMaxVisibleDots); | |
final last = | |
(first + effectiveMaxVisibleDots - 1).clamp(0, widget.itemCount); | |
for (var i = first; i <= last; i++) { | |
totalWidth += _getDotWidth(i); | |
totalWidth += widget.spacing; | |
} | |
return totalWidth; | |
} | |
@override | |
Widget build(BuildContext context) => SizedBox( | |
width: _calculateWidgetWidth(), | |
height: widget.dotSize, | |
child: SingleChildScrollView( | |
physics: const NeverScrollableScrollPhysics(), | |
controller: _scrollController, | |
scrollDirection: Axis.horizontal, | |
child: ValueListenableBuilder<int>( | |
valueListenable: ValueNotifier<int>(widget.currentIndex), | |
builder: (context, currentIndex, child) => Row( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
...Iterable.generate( | |
widget.itemCount, | |
(index) { | |
final isCurrent = index == widget.currentIndex; | |
final dotWidth = _getDotWidth(index); | |
return Padding( | |
padding: EdgeInsetsDirectional.only( | |
start: widget.spacing / 2, end: widget.spacing / 2), | |
child: AnimatedContainer( | |
duration: const Duration(milliseconds: 50), | |
width: dotWidth, | |
height: dotWidth, | |
decoration: BoxDecoration( | |
borderRadius: BorderRadius.circular(dotWidth / 2), | |
color: isCurrent | |
? widget.activeColor | |
: widget.inactiveColor, | |
), | |
), | |
); | |
}, | |
), | |
SizedBox(width: 2), | |
], | |
), | |
), | |
), | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment