Last active
August 5, 2021 18:22
-
-
Save tranductam2802/6f6a5fc73803bdfaf2a493a35c258fee to your computer and use it in GitHub Desktop.
A TabBar supported for bubble view style and wheel scroll
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'; | |
import 'package:flutter/rendering.dart'; | |
void main() async => | |
runApp(MaterialApp(debugShowCheckedModeBanner: false, home: DemoScreen())); | |
class DemoScreen extends StatefulWidget { | |
@override | |
_DemoScreenState createState() => _DemoScreenState(); | |
} | |
class _DemoScreenState extends State<DemoScreen> with TickerProviderStateMixin { | |
static const LIST_TAB = <String>[ | |
'First', | |
'Second', | |
'Third', | |
'Fourth', | |
'Fifth', | |
'Sixth', | |
'Seventh', | |
'Eighth', | |
'Ninth', | |
'Tenth', | |
'Eleventh', | |
'Twelfth', | |
'Thirteenth', | |
]; | |
TabController _tabController; | |
@override | |
void initState() { | |
super.initState(); | |
_tabController = TabController(length: LIST_TAB.length, vsync: this); | |
} | |
@override | |
void dispose() { | |
_tabController?.dispose(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: PreferredSize( | |
preferredSize: Size.fromHeight(BubbleTabBar.DEFAULT_TAB_HEIGHT), | |
child: AppBar( | |
bottom: BubbleTabBar( | |
tabController: _tabController, | |
onTabSelected: _tabController.animateTo, | |
selectColor: Colors.white, | |
selectBackground: Colors.red, | |
unselectColor: Colors.black, | |
unselectBackground: Colors.grey[200], | |
texts: LIST_TAB, | |
), | |
backgroundColor: Colors.white, | |
), | |
), | |
); | |
} | |
} | |
/// A TabBar supported for bubble view style and wheel scroll. | |
/// | |
/// This class auto calculate size of all sub widget. The wheel scroll feature | |
/// only enable unless the sum of size of all sub widgets bigger than this | |
/// widget it self. | |
class BubbleTabBar extends AnimatedWidget implements PreferredSizeWidget { | |
static const AxisDirection AXIS_DIRECTION = AxisDirection.right; | |
static const double DEFAULT_TAB_HEIGHT = 50.0; | |
static const double MAIN_SPACING = 10.0; | |
static const double TOP_SPACING = 10.0; | |
static const double BOTTOM_SPACING = 10.0; | |
final List<String> texts; | |
final TabController tabController; | |
final WheelScrollController scrollController; | |
final ValueChanged<int> onTabSelected; | |
final double height; | |
final double width; | |
final double mainSpacing; | |
final double topSpacing; | |
final double bottomSpacing; | |
final double anchor; | |
final TextStyle style; | |
final Color selectColor; | |
final Color selectBackground; | |
final Color unselectColor; | |
final Color unselectBackground; | |
final ScrollPhysics scrollPhysics; | |
final double _tabHeight; | |
final EdgeInsets _tabMargin; | |
final EdgeInsets _tabPadding; | |
EdgeInsets get tabMargin => _tabMargin; | |
EdgeInsets get tabPadding => _tabPadding; | |
Radius get radius => Radius.circular(_tabHeight); | |
BorderRadius get borderRadius => BorderRadius.all(radius); | |
factory BubbleTabBar({ | |
@required List<String> texts, | |
@required TabController tabController, | |
WheelScrollController scrollController, | |
ValueChanged<int> onTabSelected, | |
double height, | |
double width, | |
double mainSpacing, | |
double topSpacing, | |
double bottomSpacing, | |
double anchor, | |
TextStyle style, | |
Color selectColor, | |
Color selectBackground, | |
Color unselectColor, | |
Color unselectBackground, | |
ScrollPhysics scrollPhysics, | |
}) { | |
scrollController = scrollController ?? WheelScrollController(); | |
height = height ?? DEFAULT_TAB_HEIGHT; | |
width = width ?? double.infinity; | |
mainSpacing = mainSpacing ?? MAIN_SPACING; | |
topSpacing = topSpacing ?? TOP_SPACING; | |
bottomSpacing = bottomSpacing ?? BOTTOM_SPACING; | |
anchor = anchor ?? 0.0; | |
style = style ?? TextStyle(fontSize: 14.0); | |
scrollPhysics = scrollPhysics ?? BouncingScrollPhysics(); | |
double tabHeight = height - topSpacing - bottomSpacing; | |
EdgeInsets tabMargin = EdgeInsets.symmetric(horizontal: mainSpacing / 2) | |
.copyWith(top: topSpacing, bottom: bottomSpacing); | |
EdgeInsets tabPadding = EdgeInsets.symmetric(horizontal: tabHeight / 2); | |
return BubbleTabBar._( | |
texts: texts, | |
tabController: tabController, | |
scrollController: scrollController, | |
onTabSelected: onTabSelected, | |
height: height, | |
width: width, | |
mainSpacing: mainSpacing, | |
topSpacing: topSpacing, | |
bottomSpacing: bottomSpacing, | |
anchor: anchor, | |
style: style, | |
selectColor: selectColor, | |
selectBackground: selectBackground, | |
unselectColor: unselectColor, | |
unselectBackground: unselectBackground, | |
scrollPhysics: scrollPhysics, | |
tabHeight: tabHeight, | |
tabMargin: tabMargin, | |
tabPadding: tabPadding, | |
); | |
} | |
BubbleTabBar._({ | |
this.texts, | |
this.tabController, | |
this.scrollController, | |
this.onTabSelected, | |
this.height, | |
this.width, | |
this.mainSpacing, | |
this.topSpacing, | |
this.bottomSpacing, | |
this.anchor, | |
this.style, | |
this.selectColor, | |
this.selectBackground, | |
this.unselectColor, | |
this.unselectBackground, | |
this.scrollPhysics, | |
double tabHeight, | |
EdgeInsets tabMargin, | |
EdgeInsets tabPadding, | |
}) : _tabHeight = tabHeight, | |
_tabMargin = tabMargin, | |
_tabPadding = tabPadding, | |
super(listenable: tabController); | |
@override | |
Size get preferredSize => Size.fromHeight(height); | |
Widget buildTab(BuildContext context, int index) { | |
final themes = Theme.of(context); | |
final _selectColor = selectColor ?? themes.accentTextTheme.button.color; | |
final _selectBackground = selectBackground ?? themes.accentColor; | |
final _unselectColor = | |
unselectColor ?? themes.primaryTextTheme.button.color; | |
final _unselectBackground = unselectBackground ?? themes.primaryColorDark; | |
final selected = tabController.index == index; | |
final textColor = selected ? _selectColor : _unselectColor; | |
final textStyle = style.copyWith(color: textColor); | |
final backgroundColor = selected ? _selectBackground : _unselectBackground; | |
return Container( | |
padding: tabMargin, | |
child: Material( | |
color: backgroundColor, | |
borderRadius: borderRadius, | |
child: InkWell( | |
borderRadius: borderRadius, | |
onTap: () => onTabSelected(index), | |
child: Container( | |
padding: tabPadding, | |
child: Center( | |
child: Container( | |
child: Text(texts[index], style: textStyle, maxLines: 1), | |
), | |
), | |
), | |
), | |
), | |
); | |
} | |
Widget buildNegativeSliver(int count) => SliverList( | |
delegate: SliverChildBuilderDelegate( | |
(context, index) => buildTab(context, count - index % count - 1))); | |
Widget buildPositiveSliver(int count) => SliverList( | |
delegate: SliverChildBuilderDelegate( | |
(context, index) => buildTab(context, index % count))); | |
List<Widget> buildChildren(BuildContext context) => texts | |
.asMap() | |
.map((index, text) => MapEntry(index, buildTab(context, index))) | |
.values | |
.toList(); | |
Widget build(BuildContext context) { | |
final shouldSupportedWidth = texts.map((text) { | |
final RenderParagraph render = RenderParagraph( | |
TextSpan(text: text, style: style), | |
textDirection: Directionality.of(context), | |
textScaleFactor: MediaQuery.of(context).textScaleFactor, | |
maxLines: 1); | |
render.layout(BoxConstraints(maxWidth: double.infinity)); | |
final size = render.getMinIntrinsicWidth(style.fontSize).toDouble(); | |
return size + tabMargin.horizontal + tabPadding.horizontal; | |
}).reduce((current, next) => current + next); | |
return LayoutBuilder( | |
builder: (context, constraints) { | |
if (constraints.maxWidth < shouldSupportedWidth) { | |
final negativeSlivers = [buildNegativeSliver(texts.length)]; | |
final positiveSlivers = [buildPositiveSliver(texts.length)]; | |
final wrapper = AlwaysScrollableScrollPhysics(parent: scrollPhysics); | |
return Container( | |
height: height, | |
width: width, | |
child: Scrollable( | |
axisDirection: AXIS_DIRECTION, | |
controller: scrollController, | |
physics: wrapper, | |
viewportBuilder: (BuildContext context, ViewportOffset offset) { | |
return Builder(builder: (BuildContext context) { | |
final state = Scrollable.of(context); | |
final negativeOffset = WheelScrollPosition( | |
physics: wrapper, | |
context: state, | |
initialPixels: -offset.pixels, | |
keepScrollOffset: true, | |
negativeScroll: true, | |
); | |
offset.addListener(() { | |
negativeOffset._forceNegativePixels(offset.pixels); | |
}); | |
return Stack( | |
children: <Widget>[ | |
Viewport( | |
axisDirection: flipAxisDirection(AXIS_DIRECTION), | |
anchor: 1.0 - anchor, | |
offset: negativeOffset, | |
slivers: negativeSlivers, | |
), | |
Viewport( | |
axisDirection: AXIS_DIRECTION, | |
anchor: anchor, | |
offset: offset, | |
slivers: positiveSlivers, | |
), | |
], | |
); | |
}); | |
}, | |
), | |
); | |
} else { | |
return Container( | |
height: height, | |
width: width, | |
child: Row( | |
mainAxisSize: MainAxisSize.max, | |
mainAxisAlignment: MainAxisAlignment.start, | |
children: buildChildren(context), | |
), | |
); | |
} | |
}, | |
); | |
} | |
} | |
/// A scroll contronller which provide [WheelScrollPosition]. | |
class WheelScrollController extends ScrollController { | |
WheelScrollController({double initial = 0.0, bool keep = true, String debug}) | |
: super( | |
initialScrollOffset: initial, | |
keepScrollOffset: keep, | |
debugLabel: debug); | |
@override | |
ScrollPosition createScrollPosition(ScrollPhysics physics, | |
ScrollContext context, ScrollPosition oldPosition) => | |
// Translate the ScrollPosition to WheelScrollPosition | |
WheelScrollPosition( | |
physics: physics, | |
context: context, | |
oldPosition: oldPosition, | |
initialPixels: initialScrollOffset, | |
keepScrollOffset: keepScrollOffset, | |
debugLabel: debugLabel, | |
); | |
} | |
/// A scroll position which allow to manages scroll activities for both of | |
/// positive and negative axis direction. | |
/// | |
/// This class provided [_forceNegativePixels] method and force both of | |
/// [minScrollExtent] and [maxScrollExtent] become infinity value. | |
/// | |
/// This class done 2 task: | |
/// | |
/// * Allow scroll both of positive and negative axis direction. | |
/// * Provive negative offset pixel. | |
/// | |
/// {@tool sample} | |
/// | |
/// ```dart | |
/// final ViewportOffset offset; // Got data from some where | |
/// | |
/// // Create a new position with negative offset | |
/// final negativeOffset = WheelScrollPosition( | |
/// physics: wrapper, | |
/// context: state, | |
/// initialPixels: -offset.pixels, | |
/// keepScrollOffset: true, | |
/// negativeScroll: true, | |
/// ); | |
/// | |
/// // Force update this offset each times the main offset update | |
/// offset.addListener(() { | |
/// negativeOffset._forceNegativePixels(offset.pixels); | |
/// }); | |
/// ``` | |
/// {@end-tool} | |
/// | |
class WheelScrollPosition extends ScrollPositionWithSingleContext { | |
final bool negativeScroll; | |
WheelScrollPosition({ | |
@required ScrollPhysics physics, | |
@required ScrollContext context, | |
double initialPixels = 0.0, | |
bool keepScrollOffset = true, | |
ScrollPosition oldPosition, | |
String debugLabel, | |
this.negativeScroll = false, | |
}) : assert(negativeScroll != null), | |
super( | |
physics: physics, | |
context: context, | |
initialPixels: initialPixels, | |
keepScrollOffset: keepScrollOffset, | |
oldPosition: oldPosition, | |
debugLabel: debugLabel, | |
); | |
/// Basicaly, offset pixel alway positive. Force it for only negativeScroll. | |
void _forceNegativePixels(double value) { | |
if (negativeScroll) { | |
super.forcePixels(-value); | |
} | |
} | |
@override | |
void saveScrollOffset() { | |
// Dont work for negativeScroll which restore by _forceNegativePixels method. | |
if (!negativeScroll) { | |
// Save the scroll offset if possible. | |
super.saveScrollOffset(); | |
} | |
} | |
@override | |
void restoreScrollOffset() { | |
// Dont work for negativeScroll which restore by _forceNegativePixels method. | |
if (!negativeScroll) { | |
// Restore the scroll offset if possible. | |
super.restoreScrollOffset(); | |
} | |
} | |
@override | |
// Infinite scroll following the opposite of the main axis direction. | |
double get minScrollExtent => double.negativeInfinity; | |
@override | |
// Infinite scroll following the main axis direction. | |
double get maxScrollExtent => double.infinity; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment