Skip to content

Instantly share code, notes, and snippets.

@tranductam2802
Last active August 5, 2021 18:22
Show Gist options
  • Save tranductam2802/6f6a5fc73803bdfaf2a493a35c258fee to your computer and use it in GitHub Desktop.
Save tranductam2802/6f6a5fc73803bdfaf2a493a35c258fee to your computer and use it in GitHub Desktop.
A TabBar supported for bubble view style and wheel scroll
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