Flutter: Override 'PageView' scroll gesture with child's GestureDetector - flutter

I am using a combination of "BottomNavigationBar" and "PageView" for navigation in my app. The user can either swipe to the next page or use the navigation bar.
On one of my pages, I would like to use a gesture detector that handles pan gestures, both vertically and horizontally.
I can't find a way to override the PageView's gesture detection with the nested GestureDetector. This means only vertical pan gestures are handled, as the horizontal ones are occupied by the PageView.
How can I disable / override the PageViews gesture detection for only that page or only the widget, without completely disabling the PageViews scroll physics?
I have created a simplified version of my App to isolate the issue, and attached a video of the problem below.
Any help would be greatly appreciated!
Here is the code inside my main.dart:
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: GestureIssueExample(),
);
}
}
class GestureIssueExample extends StatefulWidget {
GestureIssueExample({Key key}) : super(key: key);
#override
_GestureIssueExampleState createState() => _GestureIssueExampleState();
}
class _GestureIssueExampleState extends State<GestureIssueExample> {
int _navigationIndex;
double _xLocalValue;
double _yLocalValue;
PageController _pageController;
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: null,
bottomNavigationBar: _buildBottomNavigationBar(),
backgroundColor: Colors.white,
body: PageView(
controller: _pageController,
onPageChanged: _onNavigationPageChanged,
children: [
//Just a placeholder to represent a page to the left of the "swipe cards" widget
_buildSamplePage("Home"),
//Center child of 'PageView', contains a GestureDetector that handles Pan Gestures
//Thanks to the page view however, only vertical pan gestures are detected, while both horizontal and vertical gestures
//need to be handled...
Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"Local X: ${_xLocalValue.toString()}\nLocal Y: ${_yLocalValue.toString()}"),
GestureDetector(
onPanStart: (details) => setState(
() {
this._xLocalValue = details.localPosition.dx;
this._yLocalValue = details.localPosition.dy;
},
),
onPanUpdate: (details) => setState(
() {
this._xLocalValue = details.localPosition.dx;
this._yLocalValue = details.localPosition.dy;
},
),
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
height: 100.0,
color: Colors.red,
alignment: Alignment.center,
child: Text("Slidable Surface",
style: TextStyle(color: Colors.white)),
),
),
],
),
),
//Just a placeholder to represent a page to the right of the "swipe cards" widget
_buildSamplePage("Settings"),
],
),
);
}
#override
void initState() {
super.initState();
this._navigationIndex = 0;
this._pageController = PageController(
initialPage: _navigationIndex,
);
}
Widget _buildSamplePage(String text) {
// This simply returns a container that fills the page,
// with a text widget in its center.
return Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
color: Colors.grey[900],
alignment: Alignment.center,
child: Text(
text,
style: TextStyle(
color: Colors.white, fontSize: 30.0, fontWeight: FontWeight.bold),
),
);
}
Widget _buildBottomNavigationBar() {
//Returns the bottom navigation bar for the scaffold
return BottomNavigationBar(
backgroundColor: Colors.grey[900],
selectedItemColor: Colors.redAccent,
unselectedItemColor: Colors.white,
items: [
BottomNavigationBarItem(icon: Icon(Icons.home_outlined), label: "Home"),
BottomNavigationBarItem(
icon: Icon(Icons.check_box_outline_blank), label: "Cards"),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined), label: "Settings"),
],
currentIndex: _navigationIndex,
onTap: _onNavigationPageChanged,
);
}
void _onNavigationPageChanged(int newIndex) {
//Set the new navigation index for the nav bar
setState(() => this._navigationIndex = newIndex);
//Animate to the selected page
_pageController.animateToPage(
newIndex,
curve: Curves.easeInOut,
duration: Duration(microseconds: 100),
);
}
}

Can you try something like this:
Add this line to your PageView:
PageView(
...
physics: _navigationIndex == 1 ? NeverScrollableScrollPhysics() : AlwaysScrollableScrollPhysics(),
...
)
Note: the number 1 is because the page with the GestureDetector is on index 1.

Related

How to make Column scrollable when overflowed but use expanded otherwise

I am trying to achieve an effect where there is expandable content on the top end of a sidebar, and other links on the bottom of the sidebar. When the content on the top expands to the point it needs to scroll, the bottom links should scroll in the same view.
Here is an example of what I am trying to do, except that it does not scroll. If I wrap a scrollable view around the column, that won't work with the spacer or expanded that is needed to keep the bottom links on bottom:
import 'package:flutter/material.dart';
const Color darkBlue = Color.fromARGB(255, 18, 32, 47);
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: darkBlue,
),
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(
child: MyWidget(),
),
),
);
}
}
class MyWidget extends StatefulWidget {
#override
State<MyWidget> createState() {
return MyWidgetState();
}
}
class MyWidgetState extends State<MyWidget> {
List<int> items = [1];
#override
Widget build(BuildContext context) {
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
setState(() {
items.add(items.last + 1);
});
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
setState(() {
if (items.length != 1) items.removeLast();
});
},
),
],
),
for (final item in items)
MyAnimatedWidget(
child: SizedBox(
height: 200,
child: Center(
child: Text('Top content item $item'),
),
),
),
Spacer(),
Container(
alignment: Alignment.center,
decoration: BoxDecoration(border: Border.all()),
height: 200,
child: Text('Bottom content'),
)
],
);
}
}
class MyAnimatedWidget extends StatefulWidget {
final Widget? child;
const MyAnimatedWidget({this.child, Key? key}) : super(key: key);
#override
State<MyAnimatedWidget> createState() {
return MyAnimatedWidgetState();
}
}
class MyAnimatedWidgetState extends State<MyAnimatedWidget>
with SingleTickerProviderStateMixin {
late AnimationController controller;
#override
initState() {
controller = AnimationController(
value: 0, duration: const Duration(seconds: 1), vsync: this);
controller.animateTo(1, curve: Curves.linear);
super.initState();
}
#override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, child) {
return SizedBox(height: 200 * controller.value, child: widget.child);
});
}
}
I have tried using a global key to get the size of the spacer and detect after rebuilds whether the spacer has been sized to 0, and if so, re-build the entire widget as a list view (without the spacer) instead of a column. You also need to listen in that case for if the size shrinks and it needs to become a column again, it seemed to make the performance noticeably worse, it was tricky to save the state when switching between column/listview, and it seemed not the best way to solve the problem.
Any ideas?
Try implementing this solution I've just created without the animation you have. Is a scrollable area at the top and a persistent footer.
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: darkBlue,
),
home: SafeArea(
child: Scaffold(
appBar: AppBar(
title: Text("My AppBar"),
),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
// Your scrollable widgets here
Container(
height: 100,
color: Colors.green,
),
Container(
height: 100,
color: Colors.blue,
),
Container(
height: 100,
color: Colors.red,
),
],
),
),
),
Container(
child: Text(
'Your footer',
),
color: Colors.blueGrey,
height: 200,
width: double.infinity,
)
],
),
),
),
);
}
}

Use onPan GestureDetector inside a SingleChildScrollView

Problem
So I have a GestureDetector widget inside SingleChildScrollView (The scroll view is to accommodate smaller screens). The GestureDetector is listening for pan updates.
When the SingleChildScrollView is "in use" the GestureDetector cannot receive pan updates as the "dragging" input from the user is forwarded to the SingleChildScrollView.
What I want
Make the child GestureDetector have priority over the SingleChildScrollView when dragging on top of the GestureDetector -- but still have functionality of scrolling SingleChildScrollView outside GestureDetector.
Example
If you copy/paste this code into dart pad you can see what I mean. When the gradient container is large the SingleChildScrollView is not active -- you are able to drag the blue box and see the updates in the console. However, once you press the switch button the container becomes smaller and the SingleChildScrollView becomes active. You are now no longer able to get pan updates in the console only able to scroll the container.
Sidenote: It seems that if you drag on the blue box quickly you are able to get drag updates but slowly dragging it just scrolls the container. I'm not sure if that's a bug or a feature but I'm not able to reproduce the same result in my production app.
import 'package:flutter/material.dart';
final Color darkBlue = Color.fromARGB(255, 18, 32, 47);
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(
child: MyWidget(),
),
),
);
}
}
class MyWidget extends StatefulWidget {
const MyWidget({Key? key}) : super(key: key);
#override
_MyWidgetState createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
bool enabled = false;
#override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: enabled ? 200 : 400,
child: SingleChildScrollView(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment(0.8, 0.0),
colors: <Color>[Color(0xffee0000), Color(0xffeeee00)],
tileMode: TileMode.repeated,
),
),
height: 400,
width: 200,
child: Center(
child: GestureDetector(
onPanUpdate: (details) => print(details),
child: Container(
height: 100,
width: 100,
color: Colors.blue,
child: Center(
child: Text(
"Drag\nme",
textAlign: TextAlign.center,
),
),
),
),
),
),
),
),
ElevatedButton(
onPressed: () => setState(() {
enabled = !enabled;
}),
child: Text("Switch"))
],
);
}
}
As #PatrickMahomes said this answer (by #Chris) will solve the problem. However, it will only check if the drag is in line with the GestureDetector. So a full solution would be this:
bool _dragOverMap = false;
GlobalKey _pointerKey = new GlobalKey();
_checkDrag(Offset position, bool up) {
if (!up) {
// find your widget
RenderBox box = _pointerKey.currentContext.findRenderObject();
//get offset
Offset boxOffset = box.localToGlobal(Offset.zero);
// check if your pointerdown event is inside the widget (you could do the same for the width, in this case I just used the height)
if (position.dy > boxOffset.dy &&
position.dy < boxOffset.dy + box.size.height) {
// check x dimension aswell
if (position.dx > boxOffset.dx &&
position.dx < boxOffset.dx + box.size.width) {
setState(() {
_dragOverMap = true;
});
}
}
} else {
setState(() {
_dragOverMap = false;
});
}
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Scroll Test"),
),
body: new Listener(
onPointerUp: (ev) {
_checkDrag(ev.position, true);
},
onPointerDown: (ev) {
_checkDrag(ev.position, false);
},
child: ListView(
// if dragging over your widget, disable scroll, otherwise allow scrolling
physics:
_dragOverMap ? NeverScrollableScrollPhysics() : ScrollPhysics(),
children: [
ListTile(title: Text("Tile to scroll")),
Divider(),
ListTile(title: Text("Tile to scroll")),
Divider(),
ListTile(title: Text("Tile to scroll")),
Divider(),
// Your widget that you want to prevent to scroll the Listview
Container(
key: _pointerKey, // key for finding the widget
height: 300,
width: double.infinity,
child: FlutterMap(
// ... just as example, could be anything, in your case use the color picker widget
),
),
],
),
),
);
}
}

How to get rid of overflow-error on AnimatedContainer?

I implemented a MaterialBanner. I created a slide-up-effect once the user pushes the dismiss-button. Everything works ok, except for the overflow-error 'bottom overflowed by .. pixels', which appears when you click the dismiss button. The number of pixels in the error message counts down to zero as the bottom slides up. How can I solve this last issue? I expected the MaterialBanner to respect the maxHeight of the BoxConstraint instead of overflowing.
AnimatedContainer buildAnimatedBanner(AuthViewModel vm) {
return AnimatedContainer(
constraints: BoxConstraints(maxHeight: _heightBanner),
duration: Duration(seconds: 3),
child: MaterialBanner(
backgroundColor: Colors.green,
leading: Icon(EvilIcons.bell,
size: 28, color: AppTheme.appTheme().colorScheme.onBackground),
content: Text('Please check your inbox to verify email ${vm.email}'),
actions: <Widget>[
FlatButton(
child: Text(
"Send again",
style: TextStyle(
color: AppTheme.appTheme().colorScheme.onBackground),
),
onPressed: () {},
),
FlatButton(
child: Text(
"Dismiss",
style: TextStyle(
color: AppTheme.appTheme().colorScheme.onBackground),
),
onPressed: () => setState(() => _heightBanner = 0),
),
],
),
);
}
It's overflowing because while the container is reducing in height, the content of the banner is still being rendered in full. You'll still see this error for as long as the material banner is still in view.
Looking at your code, I'm thinking the purpose of the AnimatedContainer is to make a smooth transition when the height of the child (MaterialBanner) changes.
You can use an AnimatedSize instead. It'll automatically handle the size change transitions for you and you don't have to worry about Overflow error.
It also provides an alignment param you can use to determine the direction of the transition.
Below is a code. Demo can be found in this codepen.
import 'package:flutter/material.dart';
void main() {
runApp(
MaterialApp(
home: MyApp(),
),
);
}
class MyApp extends StatefulWidget {
MyWidget createState() => MyWidget();
}
class MyWidget extends State<MyApp> with SingleTickerProviderStateMixin {
int itemCount = 2;
bool pressed = false;
#override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: AnimatedSize(
vsync: this,
duration: Duration(milliseconds: 500),
alignment: Alignment.topCenter,
child: Container(
color: Colors.red,
// height: 300,
width: 300,
child: ListView.builder(
itemCount: itemCount,
shrinkWrap: true,
itemBuilder: (context, index) {
return ListTile(
title: Text("$index"),
);
}
),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState((){
itemCount = pressed ? 6 : 4;
pressed = !pressed;
});
}
),
);
}
}

Customizing floating action button

I am using sliver list and I want to use both floating action button and sliding_up_panel from pub with the following behaviour: when I scroll down my list, the floating action button disappears; when I scroll up the fab appears. Moreover, the fab should disappear when I slide up (open) the menu.
As you can see above, the floating action button is all on the sliding element, but I want it to be between the sliding element and the scrolling item list.
Also in above picture, the problem is that the floating button is actually visible but I want to hide it with a nice animation when I slide up the sliding menu.
I hope my question is clear!!!
Edit please do it with scroll controller
full code
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
// This widget is the root of your application.
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: SliverExample(
bodyWidgets: Text('Hello Body'),
backgroundWidget: Text('Hello Background'),
),
);
}
}
Widget listItem(Color color, String title) => Container(
height: 100.0,
color: color,
child: Center(
child: Text(
"$title",
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 14.0,
fontWeight: FontWeight.bold),
),
),
);
class SliverExample extends StatefulWidget {
final Widget backgroundWidget;
final Widget bodyWidgets;
SliverExample({
this.backgroundWidget,
this.bodyWidgets,
});
#override
_SliverExampleState createState() => _SliverExampleState();
}
class _SliverExampleState extends State<SliverExample> {
// I need something like this
// To determine if SliverAppBar is expanded or not.
ScrollController _scrollController;
bool isAppBarExpanded = false;
#override
void initState() {
super.initState();
_scrollController = ScrollController()
..addListener(() => setState(() {
print('Scroll view Listener is called offset ${_scrollController.offset}');
}));
}
bool get _changecolor {
return _scrollController.hasClients
&& _scrollController.offset > (200-kToolbarHeight);
}
bool get _hideFAB {
return _scrollController.hasClients
&& _scrollController.offset > (200-kToolbarHeight);
}
#override
Widget build(BuildContext context) {
// To change the item's color accordingly
// To be used in multiple places in code
//Color itemColor = isAppBarExpanded ? Colors.white : Colors.black;
// In my case PrimaryColor is white,
// and the background widget is dark
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: <Widget>[
SliverAppBar(
pinned: true,
leading: BackButton(
color: _changecolor? Colors.white: Colors.black, // Here
),
actions: <Widget>[
IconButton(
icon: Icon(
Icons.shopping_cart,
color: _changecolor? Colors.white: Colors.black, // Here
),
onPressed: () {},
),
],
expandedHeight: 200.0,
flexibleSpace: FlexibleSpaceBar(
centerTitle: true,
title: Text(
'title',
style: TextStyle(
fontSize: 18.0,
color: _changecolor? Colors.white: Colors.black, // Here
),
),
// Not affecting the question.
background: widget.backgroundWidget,
),
),
SliverList(
///Use SliverChildListDelegate and provide a list
///of widgets if the count is limited
///
///Lazy building of list
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
/// To convert this infinite list to a list with "n" no of items,
/// uncomment the following line:
/// if (index > n) return null;
return listItem(Colors.grey, "Sliver List item: $index");
},
/// Set childCount to limit no.of items
/// childCount: 100,
),
),
// Not affecting the question.
SliverToBoxAdapter(child: widget.bodyWidgets),
],
),
floatingActionButton: _hideFAB? Container() : FloatingActionButton(
onPressed: () {},
child: Icon(Icons.add,),
),
);
}
}

Flutter - Different floating action button in TabBar

I'm trying to get a different floatting button in a TabBar in flutter. But I will try a lot of option, but I don't know how.
Sorry, I add more details:
I want to do a app with a TabBar, like this flutter example.
If you see this is a tabBarDemo application, I can change between tabs,
but I don't know how to change the floating button between tabs. Thanks
Like this gif: https://i.stack.imgur.com/bxtN4.gif
class TabBarDemo extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
home: DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
bottom: TabBar(
tabs: [
Tab(icon: Icon(Icons.directions_car)),
Tab(icon: Icon(Icons.directions_transit)),
Tab(icon: Icon(Icons.directions_bike)),
],
),
title: Text('Tabs Demo'),
),
body: TabBarView(
children: [
Icon(Icons.directions_car),
Icon(Icons.directions_transit),
Icon(Icons.directions_bike),
],
),
floatingActionButton: FloatingActionButton.extended
(onPressed: null,
icon: Icon(Icons.add, color: Colors.white,),
label: new Text('FLOATING TO CHANGE'),
),
floatingActionButtonLocation:FloatingActionButtonLocation.centerFloat,
),
),
);
}
}
A Minimal Example of what you want:
class TabsDemo extends StatefulWidget {
#override
_TabsDemoState createState() => _TabsDemoState();
}
class _TabsDemoState extends State<TabsDemo>
with SingleTickerProviderStateMixin {
TabController _tabController;
#override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this, initialIndex: 0);
_tabController.addListener(_handleTabIndex);
}
#override
void dispose() {
_tabController.removeListener(_handleTabIndex);
_tabController.dispose();
super.dispose();
}
void _handleTabIndex() {
setState(() {});
}
#override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Scaffold(
appBar: AppBar(
title: Text('Demo'),
bottom: TabBar(
controller: _tabController,
tabs: [
Tab(
text: "Tab1",
),
Tab(
text: "Tab2",
),
],
),
), // floatingActionButton: _buildFloatingActionButton(context),
body: TabBarView(controller: _tabController, children: [
Center(
child: Container(
child: Text('Tab 1'),
),
),
Center(
child: Container(
child: Text('Tab 2'),
),
),
]),
floatingActionButton: _bottomButtons(),
),
);
}
Widget _bottomButtons() {
return _tabController.index == 0
? FloatingActionButton(
shape: StadiumBorder(),
onPressed: null,
backgroundColor: Colors.redAccent,
child: Icon(
Icons.message,
size: 20.0,
))
: FloatingActionButton(
shape: StadiumBorder(),
onPressed: null,
backgroundColor: Colors.redAccent,
child: Icon(
Icons.edit,
size: 20.0,
),
);
}
}
you can achieve this by TabController
Declaration: TabController _tabController;
Initialization: in initState()
_tabController = TabController(length: 2, vsync: this, initialIndex: 0);
_tabController.addListener(_handleTabChange);
and just pass setState((){}) in method _handleTabChange to reflect ontime like
_handleTabChange(){
setState((){});
}
Now Bind or Inject in both of widget TabBar and TabBarView in their controller property.
TabBarView(
controller: _tabController,
children: [
Widget(),
Widget()
],
),
TabBar(
controller: _tabController,
tabs:[
Tab(...),
Tab(...),
]
)
Now place your different FAB button to different Tabs by according to _tabController index
floatingActionButton: _tabController.index == 0
? FloatingActionButton(
backgroundColor: Colors.blue,
onPressed: () {},
)
: FloatingActionButton(
backgroundColor: Colors.red,
onPressed: () {},
),
Keep coding ;)
Check this
import 'package:flutter/material.dart';
class Lista extends StatefulWidget {
#override
_ListaState createState() => _ListaState();
}
class _ListaState extends State<Lista> {
int indexTab=0;
#override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
initialIndex: 0,
child: Scaffold(
appBar: AppBar (
title: Text("Test"),
bottom: TabBar(
onTap: (index){
setState(() {
indexTab = index;
});
},
tabs: <Widget>[
Tab(icon: Icon(Icons.calendar_today)),
Tab(icon: Icon(Icons.whatshot)),
],
),
),
floatingActionButton: indexTab==0? FloatingActionButton (
onPressed: () {},
child: Icon(Icons.add),
):FloatingActionButton (
onPressed: () {},
child: Text('test'),
),
body: TabBarView(
children: <Widget>[
Text('1'),
Text('2'),
],
)
),
);
}
}
I found that the accepted answer was not providing a good enough solution for me. The problem is that animation feels laggy and untimely.
The main point of change is listening to Animation of TabController instead of TabController state.
There is my approach to create a more or less reusable solution:
class MultipleHidableFabs extends StatefulWidget {
#override
State<MultipleHidableFabs> createState() => _MultipleHidableFabsState();
}
class _MultipleHidableFabsState extends State<MultipleHidableFabs>
with SingleTickerProviderStateMixin {
// Index of initially opened tab
static const initialIndex = 0;
// Number of tabs
static const tabsCount = 3;
// List with current scales for each tab's fab
// Initialize with 1.0 for initial opened tab, 0.0 for others
final tabScales =
List.generate(tabsCount, (index) => index == initialIndex ? 1.0 : 0.0);
late TabController tabController;
#override
void initState() {
super.initState();
tabController = TabController(
length: tabsCount,
initialIndex: initialIndex,
vsync: this,
);
// Adding listener to animation gives us opportunity to track changes more
// frequently compared to listener of TabController itself
tabController.animation!.addListener(() {
setState(() {
// Current animation value. It ranges from 0 to (tabsCount - 1)
final animationValue = tabController.animation!.value;
// Simple rounding gives us understanding of what tab is showing
final currentTabIndex = animationValue.round();
// currentOffset equals 0 when tabs are not swiped
// currentOffset ranges from -0.5 to 0.5
final currentOffset = currentTabIndex - animationValue;
for (int i = 0; i < tabsCount; i++) {
if (i == currentTabIndex) {
// For current tab bringing currentOffset to range from 0.0 to 1.0
tabScales[i] = (0.5 - currentOffset.abs()) / 0.5;
} else {
// For other tabs setting scale to 0.0
tabScales[i] = 0.0;
}
}
});
});
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
bottom: TabBar(
controller: tabController,
tabs: [
Tab(icon: Icon(Icons.one_k)),
Tab(icon: Icon(Icons.two_k)),
Tab(icon: Icon(Icons.three_k)),
],
),
),
body: SafeArea(
child: TabBarView(
controller: tabController,
children: [Icon(Icons.one_k), Icon(Icons.two_k), Icon(Icons.three_k)],
),
),
floatingActionButton: createScaledFab(),
);
}
Widget? createScaledFab() {
// Searching for index of a tab with not 0.0 scale
final indexOfCurrentFab = tabScales.indexWhere((fabScale) => fabScale != 0);
// If there are no fabs with non-zero opacity return nothing
if (indexOfCurrentFab == -1) {
return null;
}
// Creating fab for current index
final fab = createFab(indexOfCurrentFab);
// If no fab created return nothing
if (fab == null) {
return null;
}
final currentFabScale = tabScales[indexOfCurrentFab];
// Scale created fab with
// You can use different Widgets to create different effects of switching
// fabs. E.g. you can use Opacity widget or Transform.translate to create
// custom animation effects
return Transform.scale(scale: currentFabScale, child: fab);
}
// Create fab for provided index
// You can skip creating fab for any indexes you want
Widget? createFab(final int index) {
if (index == 0) {
return FloatingActionButton(
onPressed: () => print("On first fab clicked"),
child: Icon(Icons.one_k),
);
}
// Not created fab for 1 index deliberately
if (index == 2) {
return FloatingActionButton(
onPressed: () => print("On third fab clicked"),
child: Icon(Icons.three_k),
);
}
}
}
Advantages of this approach:
Synchronized animation between swiping and showing fabs
Tapping on tabs also animates in a right manner
Ability to easily skip creating fabs for selected indexes
See an example in action:
Based on this answer from Ilia Kurtov, here's a reusable component for tab-dependent FABs.
Implementation
import 'package:flutter/material.dart';
typedef FabBuilder = Widget? Function(int tabIndex);
typedef TransformBuilder = Widget Function(
BuildContext context, Widget child, double t);
The basic idea is to transform the animation from the tab controller to an index and distance (TabFocus class) using a custom subclass of Animatable.
/// Represent a tab index with a distance metric.
class TabFocus {
/// Distance to the tab
///
/// from 0.0 (on tab) to 1.0 (half way to next or previous tab)
final double distance;
/// Index of the tab that closest to the current `t`.
final int index;
const TabFocus._({required this.distance, required this.index});
/// Get the tab focus at a tab position
factory TabFocus.at(double t) {
final index = t.round();
final t0 = index.toDouble();
final distance = (t - t0).abs() * 2;
return TabFocus._(distance: distance, index: index);
}
}
/// Subclass of [Animatable] that transforms a `double t` tab position into a [TabFocus].
class TabFocusAnimatable extends Animatable<TabFocus> {
#override
TabFocus transform(double t) => TabFocus.at(t);
const TabFocusAnimatable();
}
When we create our widget, we turn this Animatable<TabFocus> into an Animation<TabFocus> by attaching it to the TabController.animation
/// A tab-dependent FAB based on <https://stackoverflow.com/a/71123870/4087068>
class TabbedFab extends StatefulWidget {
TabbedFab(
{Key? key,
required TabController tabController,
FabBuilder? builder,
Animatable<TabFocus> focusAnimatable = const TabFocusAnimatable(),
TransformBuilder? transformBuilder})
: this._(
key: key,
builder: builder,
tabController: tabController,
fabAnimation: focusAnimatable.animate(tabController.animation!));
const TabbedFab._(
{Key? key,
required this.tabController,
required this.fabAnimation,
this.transform = _defaultTransform,
this.builder})
: super(key: key);
final TransformBuilder transform;
final Animation<TabFocus> fabAnimation;
final TabController tabController;
final FabBuilder? builder;
#override
State<TabbedFab> createState() => _TabbedFabState();
}
We also define a default transformation, that just scales a widget (the FAB) based on a t from 0.0 to 1.0.
/// By default, scale the current floating action button, so that it is full
/// size when the tab is selected
Widget _defaultTransform(BuildContext context, Widget child, double t) {
return Transform.scale(scale: t, child: child);
}
In the widget state class, we listen to the fabAnimation and call setState only when the index changes.
class _TabbedFabState extends State<TabbedFab> {
int currentTab = 0;
_onTabAnimation() {
final animationIndex = widget.fabAnimation.value.index;
if (animationIndex != currentTab) {
setState(() {
currentTab = animationIndex;
});
}
}
#override
void dispose() {
widget.fabAnimation.removeListener(_onTabAnimation);
super.dispose();
}
#override
void initState() {
currentTab = widget.tabController.index;
widget.fabAnimation.addListener(_onTabAnimation);
super.initState();
}
/* build method, see below */
}
Finally, we use an AnimatedBuilder with our transform to scale the widget while the animation is running.
#override
Widget build(BuildContext context) {
// Creating fab for current index
final fab = widget.builder?.call(currentTab);
// If no fab created return nothing
if (fab == null) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: widget.fabAnimation,
builder: (context, child) {
// fall back to 0.0 if the animation rolled over, but we're still calling the old builder
final t = (currentTab == widget.fabAnimation.value.index)
? 1.0 - widget.fabAnimation.value.distance
: 0.0;
return widget.transform(context, child!, t);
},
child: fab,
);
}
Usage
To use it, add it to your Scaffold like so:
Widget? _createFab(index) {
return (index == 0)
? FloatingActionButton(
onPressed: () => print("Click!"),
child: const Icon(Icons.add),
)
: null;
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
bottom: TabBar(
controller: _tabController,
tabs: const <Tab>[
Tab(text: "Tab 1"),
Tab(text: "Tab 2")
],
)),
body: TabBarView(
controller: _tabController,
children: const <Widget>[
Center(child: Text("Tab 1")),
Center(child: Text("Tab 2")),
]),
floatingActionButton:
TabbedFab(tabController: _tabController, builder: _createFab));
}
you can use this code :
floatingActionButton: new Container(
height: 140.0,
child: new Stack(
children: <Widget>[
Align(
alignment: Alignment.bottomRight,
child: new Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Container(
height: 60.0,
child: new FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: new Icon(Icons.add),
),
),
new Container(
height: 20.0,
), // a space
Container(
height: 60.0,
child: new FloatingActionButton(
onPressed: _decremenrCounter,
backgroundColor: Colors.red,
tooltip: 'Increment',
child: new Icon(Icons.remove),
),
),
],
),
)
],
),
)
screenshot :
here is all the code if you want it : main.dart
import 'package:flutter/material.dart';
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'Flutter Demo',
theme: new ThemeData(
primarySwatch: Colors.blue,
),
home: new MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
#override
_MyHomePageState createState() => new _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
void _decremenrCounter() {
setState(() {
_counter--;
});
}
#override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text(widget.title),
),
body: new Center(
child: new Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
new Text(
'You have pushed the button this many times:',
),
new Text(
'$_counter',
style: Theme.of(context).textTheme.display1,
),
],
),
),
floatingActionButton: new Container(
height: 140.0,
child: new Stack(
children: <Widget>[
Align(
alignment: Alignment.bottomRight,
child: new Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Container(
height: 60.0,
child: new FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: new Icon(Icons.add),
),
),
new Container(
height: 20.0,
), // a space
Container(
height: 60.0,
child: new FloatingActionButton(
onPressed: _decremenrCounter,
backgroundColor: Colors.red,
tooltip: 'Increment',
child: new Icon(Icons.remove),
),
),
],
),
)
],
),
) // This trailing comma makes auto-formatting nicer for build methods.
);
}
}