I'm trying to get ExpansionTile to collapse after I choose an item, but it does not close the list that was opened.
I tried to use the onExpansionChanged property but I did not succeed
How could you solve this problem?
Insert a gif demonstrating that ExpansionTile does not collapse after choosing an item, and below is also the code used.
import 'package:flutter/material.dart';
void main() {
runApp(new ExpansionTileSample());
}
class ExpansionTileSample extends StatefulWidget {
#override
ExpansionTileSampleState createState() => new ExpansionTileSampleState();
}
class ExpansionTileSampleState extends State<ExpansionTileSample> {
String foos = 'One';
#override
Widget build(BuildContext context) {
return new MaterialApp(
home: new Scaffold(
appBar: new AppBar(
title: const Text('ExpansionTile'),
),
body: new ExpansionTile(
title: new Text(this.foos),
backgroundColor: Theme.of(context).accentColor.withOpacity(0.025),
children: <Widget>[
new ListTile(
title: const Text('One'),
onTap: () {
setState(() {
this.foos = 'One';
});
},
),
new ListTile(
title: const Text('Two'),
onTap: () {
setState(() {
this.foos = 'Two';
});
},
),
new ListTile(
title: const Text('Three'),
onTap: () {
setState(() {
this.foos = 'Three';
});
},
),
]
),
),
);
}
}
Here is a workaround. Just add a global key (or a value key that changes after selecting an item) and it will force ExpansionTile to rebuild. The downside is losing animation for collapsing.
ExpansionTile(
key: GlobalKey(),
title: Text(title),
children: listTiles,
...
)
Here is a solution. We just add a expand, collapse and toggle functionality to ExpansionTile.
import 'package:flutter/material.dart';
import 'package:meta/meta.dart';
void main() {
runApp(new ExpansionTileSample());
}
class ExpansionTileSample extends StatefulWidget {
#override
ExpansionTileSampleState createState() => new ExpansionTileSampleState();
}
class ExpansionTileSampleState extends State<ExpansionTileSample> {
final GlobalKey<AppExpansionTileState> expansionTile = new GlobalKey();
String foos = 'One';
#override
Widget build(BuildContext context) {
return new MaterialApp(
home: new Scaffold(
appBar: new AppBar(
title: const Text('ExpansionTile'),
),
body: new AppExpansionTile(
key: expansionTile,
title: new Text(this.foos),
backgroundColor: Theme
.of(context)
.accentColor
.withOpacity(0.025),
children: <Widget>[
new ListTile(
title: const Text('One'),
onTap: () {
setState(() {
this.foos = 'One';
expansionTile.currentState.collapse();
});
},
),
new ListTile(
title: const Text('Two'),
onTap: () {
setState(() {
this.foos = 'Two';
expansionTile.currentState.collapse();
});
},
),
new ListTile(
title: const Text('Three'),
onTap: () {
setState(() {
this.foos = 'Three';
expansionTile.currentState.collapse();
});
},
),
]
),
),
);
}
}
// --- Copied and slightly modified version of the ExpansionTile.
const Duration _kExpand = const Duration(milliseconds: 200);
class AppExpansionTile extends StatefulWidget {
const AppExpansionTile({
Key key,
this.leading,
#required this.title,
this.backgroundColor,
this.onExpansionChanged,
this.children: const <Widget>[],
this.trailing,
this.initiallyExpanded: false,
})
: assert(initiallyExpanded != null),
super(key: key);
final Widget leading;
final Widget title;
final ValueChanged<bool> onExpansionChanged;
final List<Widget> children;
final Color backgroundColor;
final Widget trailing;
final bool initiallyExpanded;
#override
AppExpansionTileState createState() => new AppExpansionTileState();
}
class AppExpansionTileState extends State<AppExpansionTile> with SingleTickerProviderStateMixin {
AnimationController _controller;
CurvedAnimation _easeOutAnimation;
CurvedAnimation _easeInAnimation;
ColorTween _borderColor;
ColorTween _headerColor;
ColorTween _iconColor;
ColorTween _backgroundColor;
Animation<double> _iconTurns;
bool _isExpanded = false;
#override
void initState() {
super.initState();
_controller = new AnimationController(duration: _kExpand, vsync: this);
_easeOutAnimation = new CurvedAnimation(parent: _controller, curve: Curves.easeOut);
_easeInAnimation = new CurvedAnimation(parent: _controller, curve: Curves.easeIn);
_borderColor = new ColorTween();
_headerColor = new ColorTween();
_iconColor = new ColorTween();
_iconTurns = new Tween<double>(begin: 0.0, end: 0.5).animate(_easeInAnimation);
_backgroundColor = new ColorTween();
_isExpanded = PageStorage.of(context)?.readState(context) ?? widget.initiallyExpanded;
if (_isExpanded)
_controller.value = 1.0;
}
#override
void dispose() {
_controller.dispose();
super.dispose();
}
void expand() {
_setExpanded(true);
}
void collapse() {
_setExpanded(false);
}
void toggle() {
_setExpanded(!_isExpanded);
}
void _setExpanded(bool isExpanded) {
if (_isExpanded != isExpanded) {
setState(() {
_isExpanded = isExpanded;
if (_isExpanded)
_controller.forward();
else
_controller.reverse().then<void>((Null value) {
setState(() {
// Rebuild without widget.children.
});
});
PageStorage.of(context)?.writeState(context, _isExpanded);
});
if (widget.onExpansionChanged != null) {
widget.onExpansionChanged(_isExpanded);
}
}
}
Widget _buildChildren(BuildContext context, Widget child) {
final Color borderSideColor = _borderColor.evaluate(_easeOutAnimation) ?? Colors.transparent;
final Color titleColor = _headerColor.evaluate(_easeInAnimation);
return new Container(
decoration: new BoxDecoration(
color: _backgroundColor.evaluate(_easeOutAnimation) ?? Colors.transparent,
border: new Border(
top: new BorderSide(color: borderSideColor),
bottom: new BorderSide(color: borderSideColor),
)
),
child: new Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
IconTheme.merge(
data: new IconThemeData(color: _iconColor.evaluate(_easeInAnimation)),
child: new ListTile(
onTap: toggle,
leading: widget.leading,
title: new DefaultTextStyle(
style: Theme
.of(context)
.textTheme
.subhead
.copyWith(color: titleColor),
child: widget.title,
),
trailing: widget.trailing ?? new RotationTransition(
turns: _iconTurns,
child: const Icon(Icons.expand_more),
),
),
),
new ClipRect(
child: new Align(
heightFactor: _easeInAnimation.value,
child: child,
),
),
],
),
);
}
#override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
_borderColor.end = theme.dividerColor;
_headerColor
..begin = theme.textTheme.subhead.color
..end = theme.accentColor;
_iconColor
..begin = theme.unselectedWidgetColor
..end = theme.accentColor;
_backgroundColor.end = widget.backgroundColor;
final bool closed = !_isExpanded && _controller.isDismissed;
return new AnimatedBuilder(
animation: _controller.view,
builder: _buildChildren,
child: closed ? null : new Column(children: widget.children),
);
}
}
solution below would work, but it is quite hacky and might not be the best one:
import 'package:flutter/material.dart';
import 'dart:math';
void main() {
runApp(new ExpansionTileSample());
}
class ExpansionTileSample extends StatefulWidget {
#override
ExpansionTileSampleState createState() => new ExpansionTileSampleState();
}
class ExpansionTileSampleState extends State {
String foos = 'One';
int _key;
_collapse() {
int newKey;
do {
_key = new Random().nextInt(10000);
} while(newKey == _key);
}
#override
void initState() {
super.initState();
_collapse();
}
#override
Widget build(BuildContext context) {
return new MaterialApp(
home: new Scaffold(
appBar: new AppBar(
title: const Text('ExpansionTile'),
),
body: new ExpansionTile(
key: new Key(_key.toString()),
initiallyExpanded: false,
title: new Text(this.foos),
backgroundColor: Theme
.of(context)
.accentColor
.withOpacity(0.025),
children: [
new ListTile(
title: const Text('One'),
onTap: () {
setState(() {
this.foos = 'One';
_collapse();
});
},
),
new ListTile(
title: const Text('Two'),
onTap: () {
setState(() {
this.foos = 'Two';
_collapse();
});
},
),
new ListTile(
title: const Text('Three'),
onTap: () {
setState(() {
this.foos = 'Three';
_collapse();
});
},
),
]
),
),
);
}
}
I found that ExpansionTile has initiallyExpanded property, which is the only way to make it collapsed. As property works only initially you want to make ExpansionTile to be recreated everytime build is called. To force it you just assign different key everytime you build it. This might not be best solution performance wise, but ExpansionTile is quite simple, so this should not be a problem.
None of the provided solutions pleased me.
I ended up creating a custom ExpandableListTile. As you can see below, its code is very brief and easy to customize.
I also had to create two supporting classes (that only handle the required animations) to build my widget:
ExpandableSection: a widget that can be easily controlled by one parameter "expanded".
RotatableSection: a widget to rotate the "Expand More" icon based on one parameter.
The main class:
class ExpandableListTile extends StatelessWidget {
const ExpandableListTile({Key key, this.title, this.expanded, this.onExpandPressed, this.child}) : super(key: key);
final Widget title;
final bool expanded;
final Widget child;
final Function onExpandPressed;
#override
Widget build(BuildContext context) {
return Column(children: <Widget>[
ListTile(
title: title,
onTap: onExpandPressed,
trailing: IconButton(
onPressed: onExpandPressed,
// icon: Icon(Icons.expand_more),
icon: RotatableSection(
rotated: expanded,
child: SizedBox(height: 30, width: 30, child: Icon(Icons.expand_more),)
),
),
),
ExpandableSection(child: child, expand: expanded,)
]);
}
}
Usage (simplified):
//...
return ExpandableListTile(
onExpandPressed: (){ setState((){ _expandedItem = 0;}) },
title: Text('Item'),
expanded: _expandedItem==0,
child: Padding(
padding: const EdgeInsets.fromLTRB(8,0,0,0),
child: Container(
color: Color.fromRGBO(0, 0, 0, .2),
child: Column(children: <Widget>[
ListTile(title: Text('Item 1')),
ListTile(title: Text('Item 2')),
ListTile(title: Text('Item 3')),
ListTile(title: Text('Item 4'))
],),
),
),
),
//...
The ExpandableSection class:
class ExpandableSection extends StatefulWidget {
final Widget child;
final bool expand;
ExpandableSection({this.expand = false, this.child});
#override
_ExpandableSectionState createState() => _ExpandableSectionState();
}
class _ExpandableSectionState extends State<ExpandableSection> with SingleTickerProviderStateMixin {
AnimationController animationController;
Animation<double> sizeAnimation;
Animation<double> opacityAnimation;
#override
void initState() {
super.initState();
prepareAnimations();
_runExpandCheck();
}
///Setting up the animation
void prepareAnimations() {
animationController = AnimationController(vsync: this, duration: Duration(milliseconds: 300),);
sizeAnimation = CurvedAnimation(parent: animationController, curve: Curves.fastOutSlowIn,);
opacityAnimation = CurvedAnimation(parent: animationController, curve: Curves.slowMiddle,);
}
void _runExpandCheck() {
if(widget.expand) { animationController.forward(); }
else { animationController.reverse(); }
}
#override
void didUpdateWidget(ExpandableSection oldWidget) {
super.didUpdateWidget(oldWidget);
_runExpandCheck();
}
#override
void dispose() {
animationController.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return FadeTransition(
opacity: opacityAnimation,
child: SizeTransition(
axisAlignment: 1.0,
sizeFactor: sizeAnimation,
child: widget.child
)
);
}
}
The RotatableSection class:
class RotatableSection extends StatefulWidget {
final Widget child;
final bool rotated;
final double initialSpin;
final double endingSpin;
RotatableSection({this.rotated = false, this.child, this.initialSpin=0, this.endingSpin=0.5});
#override
_RotatableSectionState createState() => _RotatableSectionState();
}
class _RotatableSectionState extends State<RotatableSection> with SingleTickerProviderStateMixin {
AnimationController animationController;
Animation<double> animation;
#override
void initState() {
super.initState();
prepareAnimations();
_runCheck();
}
final double _oneSpin = 6.283184;
///Setting up the animation
void prepareAnimations() {
animationController = AnimationController(vsync: this, duration: Duration(milliseconds: 300),
lowerBound: _oneSpin * widget.initialSpin, upperBound: _oneSpin * widget.endingSpin, );
animation = CurvedAnimation( parent: animationController, curve: Curves.linear, );
}
void _runCheck() {
if(widget.rotated) { animationController.forward(); }
else { animationController.reverse(); }
}
#override
void didUpdateWidget(RotatableSection oldWidget) {
super.didUpdateWidget(oldWidget);
_runCheck();
}
#override
void dispose() {
animationController.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animationController,
child: widget.child,
builder: (BuildContext context, Widget _widget) {
return new Transform.rotate(
angle: animationController.value,
child: _widget,
);
},
);
}
}
Use this package and follow my code. Hope this will help you :). Easy to use. https://pub.dev/packages/expansion_tile_card/example
final List<GlobalKey<ExpansionTileCardState>> cardKeyList = [];
... ListView.builder(
itemCount: 10,
itemBuilder: (BuildContext context, int index) {
cardKeyList.add(GlobalKey(debugLabel: "index :$index"));
return ExpansionTileCard(
title: Text('title'),
key: cardKeyList[index],
onExpansionChanged: (value) {
if (value) {
Future.delayed(const Duration(milliseconds: 500), () {
for (var i = 0; i < cardKeyList.length; i++) {
if (index != i) {
cardKeyList[i].currentState?.collapse();
}
}
});
}
},
);
}),
Use UniqueKey:
ExpansionTile(
key: UniqueKey(),
// Other properties
)
I've made a TreeView widget.
It uses ExpansionTile to simulate the hierarchy.
Each ExpansionTile could host a collection of ExpansionTile which can host ...etc.
Everything worked fine until I wanted to add 2 features : expand all / collapse all.
What helped me to overcame this problem is the GlobalKey.
My TreeView widget, is hosted in a page and is used with a global key.
I expose a VoidCallback. The implementation sets a new key in the setState method.
// TreeView host page
GlobalKey<TreeViewState> _key = GlobalKey();
void redrawWidgetCallback() {
setState(() {
// Triggers a rebuild of the whole TreeView.
_key = GlobalKey();
});
}
[...]
// In the Scaffold body :
TreeView(
key: _key,
treeViewItems: widget.treeViewItems,
redrawWidgetCallback: redrawWidgetCallback,
)
Then in my collapse/expand method in the widget, at the end, I call widget.redrawWidgetCallback.
No need to deal with a key for each level of the treeView : the root element widget is enough.
It may have perf issues / not the right way to go. But since my TreeView won't be used with more than 50 nodes, it's ok for me until I found a better solution which doesn't involve to create an ExpandableTile because I believe this behavior will be available oneday on the ExpansionTile itself.
PS : notice that this workaround doesn't run the expand animation.
Create a clone from ExpansionTile class and replace build method code by the following:
#override
Widget build(BuildContext context) {
final bool closed = !_isExpanded && _controller.isDismissed;
return AnimatedBuilder(
animation: _controller.view,
builder: _buildChildren,
child: closed ? null : GestureDetector(
child: Column(children: widget.children),
onTap: _handleTap,
),
);
}
and then ExpansionTile will collapse after click on each item.
Note:
if one of children has onTap call back, this solution doesn't work.
in this case you must provide onChildTap handler to pass index of tapped child in use case.(contact me for complete code)
I have modified the custom code, And its works fine for me.
Here is the solution.
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
const Duration _kExpand = Duration(milliseconds: 200);
/// A single-line [ListTile] with a trailing button that expands or collapses
/// the tile to reveal or hide the [children].
///
/// This widget is typically used with [ListView] to create an
/// "expand / collapse" list entry. When used with scrolling widgets like
/// [ListView], a unique [PageStorageKey] must be specified to enable the
/// [AppExpansionTile] to save and restore its expanded state when it is scrolled
/// in and out of view.
///
/// This class overrides the [ListTileTheme.iconColor] and [ListTileTheme.textColor]
/// theme properties for its [ListTile]. These colors animate between values when
/// the tile is expanded and collapsed: between [iconColor], [collapsedIconColor] and
/// between [textColor] and [collapsedTextColor].
///
/// See also:
///
/// * [ListTile], useful for creating expansion tile [children] when the
/// expansion tile represents a sublist.
/// * The "Expand and collapse" section of
/// <https://material.io/components/lists#types>
class AppExpansionTile extends StatefulWidget {
/// Creates a single-line [ListTile] with a trailing button that expands or collapses
/// the tile to reveal or hide the [children]. The [initiallyExpanded] property must
/// be non-null.
const AppExpansionTile({
GlobalKey<AppExpansionTileState>? key,
this.leading,
required this.title,
this.subtitle,
this.onExpansionChanged,
this.children = const <Widget>[],
this.trailing,
this.initiallyExpanded = false,
this.maintainState = false,
this.tilePadding,
this.expandedCrossAxisAlignment,
this.expandedAlignment,
this.childrenPadding,
this.backgroundColor,
this.collapsedBackgroundColor,
this.textColor,
this.collapsedTextColor,
this.iconColor,
this.collapsedIconColor,
}) : assert(initiallyExpanded != null),
assert(maintainState != null),
assert(
expandedCrossAxisAlignment != CrossAxisAlignment.baseline,
'CrossAxisAlignment.baseline is not supported since the expanded children '
'are aligned in a column, not a row. Try to use another constant.',
),
super(key: key);
/// A widget to display before the title.
///
/// Typically a [CircleAvatar] widget.
final Widget? leading;
/// The primary content of the list item.
///
/// Typically a [Text] widget.
final Widget title;
/// Additional content displayed below the title.
///
/// Typically a [Text] widget.
final Widget? subtitle;
/// Called when the tile expands or collapses.
///
/// When the tile starts expanding, this function is called with the value
/// true. When the tile starts collapsing, this function is called with
/// the value false.
final ValueChanged<bool>? onExpansionChanged;
/// The widgets that are displayed when the tile expands.
///
/// Typically [ListTile] widgets.
final List<Widget> children;
/// The color to display behind the sublist when expanded.
final Color? backgroundColor;
/// When not null, defines the background color of tile when the sublist is collapsed.
final Color? collapsedBackgroundColor;
/// A widget to display instead of a rotating arrow icon.
final Widget? trailing;
/// Specifies if the list tile is initially expanded (true) or collapsed (false, the default).
final bool initiallyExpanded;
/// Specifies whether the state of the children is maintained when the tile expands and collapses.
///
/// When true, the children are kept in the tree while the tile is collapsed.
/// When false (default), the children are removed from the tree when the tile is
/// collapsed and recreated upon expansion.
final bool maintainState;
/// Specifies padding for the [ListTile].
///
/// Analogous to [ListTile.contentPadding], this property defines the insets for
/// the [leading], [title], [subtitle] and [trailing] widgets. It does not inset
/// the expanded [children] widgets.
///
/// When the value is null, the tile's padding is `EdgeInsets.symmetric(horizontal: 16.0)`.
final EdgeInsetsGeometry? tilePadding;
/// Specifies the alignment of [children], which are arranged in a column when
/// the tile is expanded.
///
/// The internals of the expanded tile make use of a [Column] widget for
/// [children], and [Align] widget to align the column. The `expandedAlignment`
/// parameter is passed directly into the [Align].
///
/// Modifying this property controls the alignment of the column within the
/// expanded tile, not the alignment of [children] widgets within the column.
/// To align each child within [children], see [expandedCrossAxisAlignment].
///
/// The width of the column is the width of the widest child widget in [children].
///
/// When the value is null, the value of `expandedAlignment` is [Alignment.center].
final Alignment? expandedAlignment;
/// Specifies the alignment of each child within [children] when the tile is expanded.
///
/// The internals of the expanded tile make use of a [Column] widget for
/// [children], and the `crossAxisAlignment` parameter is passed directly into the [Column].
///
/// Modifying this property controls the cross axis alignment of each child
/// within its [Column]. Note that the width of the [Column] that houses
/// [children] will be the same as the widest child widget in [children]. It is
/// not necessarily the width of [Column] is equal to the width of expanded tile.
///
/// To align the [Column] along the expanded tile, use the [expandedAlignment] property
/// instead.
///
/// When the value is null, the value of `expandedCrossAxisAlignment` is [CrossAxisAlignment.center].
final CrossAxisAlignment? expandedCrossAxisAlignment;
/// Specifies padding for [children].
///
/// When the value is null, the value of `childrenPadding` is [EdgeInsets.zero].
final EdgeInsetsGeometry? childrenPadding;
/// The icon color of tile's [trailing] expansion icon when the
/// sublist is expanded.
///
/// Used to override to the [ListTileTheme.iconColor].
final Color? iconColor;
/// The icon color of tile's [trailing] expansion icon when the
/// sublist is collapsed.
///
/// Used to override to the [ListTileTheme.iconColor].
final Color? collapsedIconColor;
/// The color of the tile's titles when the sublist is expanded.
///
/// Used to override to the [ListTileTheme.textColor].
final Color? textColor;
/// The color of the tile's titles when the sublist is collapsed.
///
/// Used to override to the [ListTileTheme.textColor].
final Color? collapsedTextColor;
#override
AppExpansionTileState createState() => AppExpansionTileState();
}
class AppExpansionTileState extends State<AppExpansionTile>
with SingleTickerProviderStateMixin {
static final Animatable<double> _easeOutTween =
CurveTween(curve: Curves.easeOut);
static final Animatable<double> _easeInTween =
CurveTween(curve: Curves.easeIn);
static final Animatable<double> _halfTween =
Tween<double>(begin: 0.0, end: 0.5);
final ColorTween _borderColorTween = ColorTween();
final ColorTween _headerColorTween = ColorTween();
final ColorTween _iconColorTween = ColorTween();
final ColorTween _backgroundColorTween = ColorTween();
late AnimationController _controller;
late Animation<double> _iconTurns;
late Animation<double> _heightFactor;
late Animation<Color?> _borderColor;
late Animation<Color?> _headerColor;
late Animation<Color?> _iconColor;
late Animation<Color?> _backgroundColor;
bool _isExpanded = false;
#override
void initState() {
super.initState();
_controller = AnimationController(duration: _kExpand, vsync: this);
_heightFactor = _controller.drive(_easeInTween);
_iconTurns = _controller.drive(_halfTween.chain(_easeInTween));
_borderColor = _controller.drive(_borderColorTween.chain(_easeOutTween));
_headerColor = _controller.drive(_headerColorTween.chain(_easeInTween));
_iconColor = _controller.drive(_iconColorTween.chain(_easeInTween));
_backgroundColor =
_controller.drive(_backgroundColorTween.chain(_easeOutTween));
_isExpanded = PageStorage.of(context)?.readState(context) as bool? ??
widget.initiallyExpanded;
if (_isExpanded) _controller.value = 1.0;
}
#override
void dispose() {
_controller.dispose();
super.dispose();
}
void expand() {
_isExpanded = true;
handleTap();
}
void collapse() {
_isExpanded = false;
handleTap();
}
#override
void didUpdateWidget(covariant AppExpansionTile oldWidget) {
if (widget.initiallyExpanded) {
expand();
} else {
collapse();
}
super.didUpdateWidget(oldWidget);
}
void handleTap() {
setState(() {
if (_isExpanded) {
_controller.forward();
} else {
_controller.reverse().then<void>((void value) {
if (!mounted) return;
setState(() {
// Rebuild without widget.children.
});
});
}
PageStorage.of(context)?.writeState(context, _isExpanded);
});
// if (widget.onExpansionChanged != null)
// widget.onExpansionChanged!(_isExpanded);
}
Widget _buildChildren(BuildContext context, Widget? child) {
final Color borderSideColor = _borderColor.value ?? Colors.transparent;
return Container(
decoration: BoxDecoration(
color: _backgroundColor.value ?? Colors.transparent,
border: Border(
top: BorderSide(color: borderSideColor),
bottom: BorderSide(color: borderSideColor),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTileTheme.merge(
iconColor: _iconColor.value,
textColor: _headerColor.value,
child: ListTile(
onTap: () {
if (widget.onExpansionChanged != null) {
widget.onExpansionChanged!(_isExpanded);
}
},
contentPadding: widget.tilePadding,
leading: widget.leading,
title: widget.title,
subtitle: widget.subtitle,
trailing: widget.trailing ??
RotationTransition(
turns: _iconTurns,
child: const Icon(Icons.expand_more),
),
),
),
ClipRect(
child: Align(
alignment: widget.expandedAlignment ?? Alignment.center,
heightFactor: _heightFactor.value,
child: child,
),
),
],
),
);
}
#override
void didChangeDependencies() {
final ThemeData theme = Theme.of(context);
final ColorScheme colorScheme = theme.colorScheme;
_borderColorTween.end = theme.dividerColor;
_headerColorTween
..begin = widget.collapsedTextColor ?? theme.textTheme.subtitle1!.color
..end = widget.textColor ?? colorScheme.secondary;
_iconColorTween
..begin = widget.collapsedIconColor ?? theme.unselectedWidgetColor
..end = widget.iconColor ?? colorScheme.secondary;
_backgroundColorTween
..begin = widget.collapsedBackgroundColor
..end = widget.backgroundColor;
super.didChangeDependencies();
}
#override
Widget build(BuildContext context) {
final bool closed = !_isExpanded && _controller.isDismissed;
final bool shouldRemoveChildren = closed && !widget.maintainState;
final Widget result = Offstage(
child: TickerMode(
child: Padding(
padding: widget.childrenPadding ?? EdgeInsets.zero,
child: Column(
crossAxisAlignment:
widget.expandedCrossAxisAlignment ?? CrossAxisAlignment.center,
children: widget.children,
),
),
enabled: !closed,
),
offstage: closed,
);
return AnimatedBuilder(
animation: _controller.view,
builder: _buildChildren,
child: shouldRemoveChildren ? null : result,
);
}
}
Usage
late int _tileIndex=-1;
return AppExpansionTile(
title: Text(
'Tile $index',
tilePadding: const EdgeInsets.symmetric(horizontal: 24),
initiallyExpanded: _tileIndex == index,
onExpansionChanged: (s) {
if (_tileIndex == index) {
_tileIndex = -1;
setState(() {});
} else {
setState(() {
_tileIndex = index!;
});
}
},
);
I think it is impossible with expansion tile but, there's a package named accordion and has much more comfortabilities.
Link:https://pub.dev/packages/accordion
For List of items using #simon solution
List<GlobalKey<AppExpansionTileState> > expansionTile;
instantiate your expansionTile
expansionTile=List<GlobalKey<AppExpansionTileState>>.generate(listItems.length, (index) => GlobalKey());
and use like so inside a ListView.builder()
key: expansionTile[index],
onExpansionChanged: (value) {
if (value) {
for (var tileKey in expansionTile) {
if (tileKey.currentState !=
expansionTile[index]
.currentState) {
tileKey.currentState.collapse();
} else {
tileKey.currentState.expand();
}
}
}
},
Related
Clicked Button multiple times same time, open pages multiple times. How to fix this issue? I also uploaded the gif file on my application(double click on the image).
Container(
padding: EdgeInsets.all(10.0),
child: ButtonTheme(
minWidth: 10.0,
height: 40.0,
child: RaisedButton(
child: Text(
AppTranslations.of(context)
.text("loginpage_button"),
style: TextStyle(
color: Colors.white, fontSize: 15.0),
),
onPressed: () async{
(isOffline)
? _showSnackBar()
: checking2(usernameController, context, _url);
},
color: Colors.blue,
padding: EdgeInsets.all(20.0),
),
),
margin: EdgeInsets.only(top: 0.0),
)
I used this code, it's working, but user types username incorrectly, user cant click button second type. this is my code.
onPressed: () async {
if (_firstClick) {
_firstClick = false;
(isOffline)
? _showSnackBar()
: checking2(usernameController, context, _url);
}
Solved this in my application based on calculating time difference.
First, declare a DateTime variable and define the function as follows:
DateTime loginClickTime;
bool isRedundentClick(DateTime currentTime) {
if (loginClickTime == null) {
loginClickTime = currentTime;
print("first click");
return false;
}
print('diff is ${currentTime.difference(loginClickTime).inSeconds}');
if (currentTime.difference(loginClickTime).inSeconds < 10) {
// set this difference time in seconds
return true;
}
loginClickTime = currentTime;
return false;
}
In the login button call the function as follows to check for redundancy:
RaisedButton(
child: Text('Login'),
onPressed: () {
if (isRedundentClick(DateTime.now())) {
print('hold on, processing');
return;
}
print('run process');
},
),
Create a bool variable which will be true when the button is pressed, (hence, initial value is set to false).
bool _clicked = false;
#override
Widget build(BuildContext context) {
return Scaffold(
body: RaisedButton(
child: Text('Button'),
onPressed: _clicked
? null
: () {
setState(() => _clicked = true); // set it to true now
},
),
);
}
You can use a bool variable to save the state of your RaisedButton:
First create the variable a set its initial value :
var _firstPress = true;
Then add _firstPress inside your onPressed function :
Container(
padding: EdgeInsets.all(10.0),
child: ButtonTheme(
minWidth: 10.0,
height: 40.0,
child: RaisedButton(
child: Text(
AppTranslations.of(context).text("loginpage_button"),
style: TextStyle(color: Colors.white, fontSize: 15.0),
),
onPressed: () async {
// This is what you should add in your code
if (_firstPress) {
_firstPress = false;
(isOffline) ? _showSnackBar() : checking2(usernameController, context, _url);
}
},
color: Colors.blue,
padding: EdgeInsets.all(20.0),
),
),
margin: EdgeInsets.only(top: 0.0),
),
This way your onPressed function will only respond to the RaisedButton's first click.
I've written two classes for myself that may be helpful for others. They encapsulate the answer given by others in this thread so that you don't have a bunch of bools and assignment statements floating everywhere.
You pass your function to the class, and use the class' "invoke" method in place of the function. This currently does not support functions that need parameters, but is useful for the void case.
typedef void CallOnceFunction();
class CallOnce {
bool _inFunction = false;
final CallOnceFunction function;
CallOnce(CallOnceFunction function) :
assert(function != null),
function = function
;
void invoke() {
if (_inFunction)
return;
_inFunction = true;
function();
_inFunction = false;
}
}
typedef Future<void> CallOnceFuture();
class CallFutureOnce {
bool _inFunction = false;
final CallOnceFuture future;
CallFutureOnce(CallOnceFuture future) :
assert(future != null),
future = future
;
Future<void> invoke() async {
if (_inFunction)
return;
_inFunction = true;
await this.future();
_inFunction = false;
}
}
Update: Here's an example of both of these classes in action
/*Example*/
import 'package:flutter/material.dart';
class MyWidget extends StatefulWidget {
#override
State<StatefulWidget> createState() {
return new MyWidgetState();
}
}
class MyWidgetState extends State<MyWidget> {
CallOnce _callOnce;
CallFutureOnce _callFutureOnce;
void myFunction() {
/*Custom Code*/
}
Future<void> myFutureFunction() async {
/*Custom Code*/
//await something()
}
#override
void initState() {
super.initState();
this._callOnce = CallOnce(this.myFunction);
this._callFutureOnce = CallFutureOnce(this.myFutureFunction);
}
#override
Widget build(BuildContext context) {
return Scaffold (
body: Center (
child: RaisedButton (
child: Text('Try Me'),
onPressed: this._callOnce.invoke,
),
),
floatingActionButton: FloatingActionButton (
child: Icon(Icons.save),
onPressed: this._callFutureOnce.invoke,
),
);
}
}
Some of the other solutions do not work for me, and some of them are not isolated in their own state and I implemented my solution to encapsulate the functionality in my custom widget. I implemented it for IconButton but you could modify it with any tappable widget. Cheers:
import 'package:flutter/material.dart';
class AppIconButton extends StatefulWidget {
const AppIconButton({
Key? key,
required this.onPressed,
required this.icon,
this.disableAfterClick = const Duration(milliseconds: 500),
}) : super(key: key);
final Function onPressed;
final Widget icon;
final Duration disableAfterClick;
#override
State<AppIconButton> createState() => _AppIconButtonState();
}
class _AppIconButtonState extends State<AppIconButton> {
bool _acceptsClicks = true;
#override
Widget build(BuildContext context) {
return IconButton(
onPressed: () {
if (_acceptsClicks) {
//if you want to disable the button
//use the variable with setState method
//but it's not my case
_acceptsClicks = false;
widget.onPressed();
Future.delayed(widget.disableAfterClick, () {
if (mounted) {
_acceptsClicks = true;
}
});
}
// else {
// debugPrint("Click ignored");
// }
},
icon: widget.icon,
);
}
}
Disabling multiple click events in a flutter with StatelessWidget.
Using as a shareable widget.
Simple example:
class SingleTapEvent extends StatelessWidget {
final Widget child;
final Function() onTap;
bool singleTap = false;
SingleTapEvent(
{Key? key, required this.child, required this.onTap, singleTap = false})
: super(key: key);
#override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
if (!singleTap) {
Function.apply(onTap, []);
singleTap = true;
Future.delayed(const Duration(seconds: 3)).then((value) => singleTap = false);
}
},
child: child);
}
}
Usage:
SingleTapEvent(
onTap: () {
print("Clicked");
},
child: Text("Click me"),
);
This question is answered here How do I disable a Button in Flutter?
All you need to use statefulWidget and create a variable to hold your condition, And change it according to your event. Your button will be enable or disable according to your variable's value.
Suppose initial state of your variable, isDisable = false,that means - your button is enable by default. And after first clicking change the value of your state variable isDisable = true.
Instead of using RaisedButton directly, you can turn it into a StatefulWidget. Then use the ChangeNotifier to change it state from enable to disable and control button press function.It will also help you to reuse it in different places. Here is an example how can you do that
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
#override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final ValueNotifier<MyButtonState> _myButtonStateChangeNotifier =
ValueNotifier(MyButtonState.enable);
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: MyButton(
buttonStateChangeNotifier: _myButtonStateChangeNotifier,
onPressed: _onButtonPressed,
text: "Click Me",
),
),
);
}
_onButtonPressed() {
print("Button Pressed");
_myButtonStateChangeNotifier.value = MyButtonState.disable;
}
}
enum MyButtonState {enable, disable}
class MyButton extends StatefulWidget {
final VoidCallback onPressed;
final String text;
final TextStyle textStyle;
final ValueNotifier<MyButtonState> buttonStateChangeNotifier;
MyButton({
#required this.onPressed,
this.text = "",
this.textStyle,
this.buttonStateChangeNotifier,
});
#override
_MyButtonState createState() => _MyButtonState();
}
class _MyButtonState extends State<MyButton> {
MyButtonState _myButtonState = MyButtonState.enable;
#override
void initState() {
super.initState();
if (widget.buttonStateChangeNotifier != null) {
widget.buttonStateChangeNotifier.addListener(_handleButtonStateChange);
_myButtonState = widget.buttonStateChangeNotifier.value;
}
}
#override
Widget build(BuildContext context) {
return RaisedButton(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(4)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(widget.text)
],
),
onPressed: _myButtonState == MyButtonState.enable
? _handleOnPress
: null,
);
}
_handleButtonStateChange() {
setState(() {
_myButtonState = widget.buttonStateChangeNotifier.value;
});
}
_handleOnPress() {
if (_myButtonState == MyButtonState.enable) {
widget.onPressed();
}
}
}
Thanks to #Mazin Ibrahim's suggestion above, setting a basic bool toggle flag works fine.
This implementation is based on handling the enable/disable logic at the callback level, independent of the widget layout details.
bool _isButtonEnabled = true;
MessageSql _messageSql = new MessageSql(); // DB helper class
final TextEditingController eCtrl = new TextEditingController();
_onSendMessage(String message) {
if (! _isButtonEnabled) {
return;
}
_isButtonEnabled = false;
_messageSql.insert(message).then((resultId) {
// only update all if save is successful
eCtrl.clear();
AppUi.dismissKeyboard();
_isButtonEnabled = true;
Future.delayed(const Duration(milliseconds: 400), () {
setState(() {});
})
.catchError((error, stackTrace) {
print("outer: $error");
});
}
Similar to what PreciseSpeech and Sharman implemented, I made a few changes and it works.
DateTime loginClickTime = DateTime.now();
#override
void initState() {
loginClickTime;
super.initState();
}
bool isRedundentClick(DateTime currentTime) {
if (loginClickTime == '') {
loginClickTime = currentTime;
print("first click");
return false;
}
print('diff is ${currentTime.difference(loginClickTime).inSeconds}');
if (currentTime.difference(loginClickTime).inSeconds < 10) {
//set this difference time in seconds
return true;
}
loginClickTime = currentTime;
return false;
}
Then in the OnPressed function
MaterialButton(
child:Text('Login'),
onPressed: (){
if(isRedundentClick(DateTime.now())){
print('hold on, processing');
return;
}
print('run process');
},
)
I'm trying to create a carousel with items of variable heights. When using PageView or ListView with horizontal scrolling, I need to give it a constant height, like this:
class CarouselVariableHightState extends State<CarouselVariableHight> {
double height = 200;
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Carousel')),
body: ListView(
children: <Widget>[
Text('My Carousel'),
Container(
height: height,
child: PageView(
children: <Widget>[
_buildCarouselItem(),
_buildCarouselItem(),
_buildCarouselItem(),
_buildCarouselItem(),
],
),
),
Text('end of List')
],
),
);
}
Widget _buildCarouselItem() {
return Column(
children: [
Container(
color: Colors.red,
height: Random().nextInt(200).toDouble(),
child: Text('Text with random length')
)
]
);
}
}
But my items contain text and I don't know the length of it. I don't wan't to limit the height of a CarouselItem because it might cut off the text. How could I have something like a PageView, that adjust its own size based on the currently shown item?
I thought a solution could be to get the height of the CarouselItem and then set it to the height of the PageView. However I couldn't figure out a way to get the height my current Carousel item.
Any Ideas?
Below you can find an example of a PageView that adapts its height to currently displayed child. With animation:
The idea is:
We wrap each child of PageView with a custom widget that can report widget size after each frame. Here I called it SizeReportingWidget.
Each height is saved in a list, called _heights on an index that corresponds to the child's index.
Attach a PageController that will let you determine the currently displayed page's index. Here we save it to a field called _currentPage
Wrap PageView in a SizedBox and give it the height of the currently displayed child. I've added a convenient getter called _currentHeight to retrieve the height.
(Optional) Wrap the SizedBox in TweenAnimationBuilder, so that the height changes are smooth and animated rather than clanky jumps.
ExpandablePageView
class ExpandablePageView extends StatefulWidget {
final List<Widget> children;
const ExpandablePageView({
Key? key,
required this.children,
}) : super(key: key);
#override
State<ExpandablePageView> createState() => _ExpandablePageViewState();
}
class _ExpandablePageViewState extends State<ExpandablePageView>
with TickerProviderStateMixin {
late PageController _pageController;
late List<double> _heights;
int _currentPage = 0;
double get _currentHeight => _heights[_currentPage];
#override
void initState() {
_heights = widget.children.map((e) => 0.0).toList();
super.initState();
_pageController = PageController()
..addListener(() {
final newPage = _pageController.page?.round() ?? 0;
if (_currentPage != newPage) {
setState(() => _currentPage = newPage);
}
});
}
#override
void dispose() {
_pageController.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
curve: Curves.easeInOutCubic,
duration: const Duration(milliseconds: 100),
tween: Tween<double>(begin: _heights[0], end: _currentHeight),
builder: (context, value, child) => SizedBox(height: value, child: child),
child: PageView(
controller: _pageController,
children: _sizeReportingChildren
.asMap() //
.map((index, child) => MapEntry(index, child))
.values
.toList(),
),
);
}
List<Widget> get _sizeReportingChildren => widget.children
.asMap() //
.map(
(index, child) => MapEntry(
index,
OverflowBox(
//needed, so that parent won't impose its constraints on the children, thus skewing the measurement results.
minHeight: 0,
maxHeight: double.infinity,
alignment: Alignment.topCenter,
child: SizeReportingWidget(
onSizeChange: (size) =>
setState(() => _heights[index] = size.height),
child: Align(child: child),
),
),
),
)
.values
.toList();
}
SizeReportingWidget
class SizeReportingWidget extends StatefulWidget {
final Widget child;
final ValueChanged<Size> onSizeChange;
const SizeReportingWidget({
Key? key,
required this.child,
required this.onSizeChange,
}) : super(key: key);
#override
State<SizeReportingWidget> createState() => _SizeReportingWidgetState();
}
class _SizeReportingWidgetState extends State<SizeReportingWidget> {
Size? _oldSize;
#override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySize());
return widget.child;
}
void _notifySize() {
if (!mounted) {
return;
}
final size = context.size;
if (_oldSize != size && size != null) {
_oldSize = size;
widget.onSizeChange(size);
}
}
}
There are 2 possible solutions: right one and hacky one.
The Right solution:
Because PageView is trying to get all posible height, we can't use it inside of any widget, that dont' have height. So if we don't have height we need to use another widget, for example StackWidget and re-create PageView behavior: swipes, animations etc. It won't be a big library, if you want to make just simple things, less than 200-300 lines I guess.
The hacky solution is to check the height of all elements before you build the PageView widget. You cannot change the height of the elements when PageView is build. So if you need to have for example a server request make it before you render the carusel.
To get the height, it easier to use IndexedStack, becouse it has the height of the highest element, but show only the first one (by default).
use keys to get the height of IndexedStack
use setState to save it.
use obtained height as the height of PageView.
class CarouselVariableHight extends StatefulWidget {
#override
CarouselVariableHightState createState() => CarouselVariableHightState();
}
final widgetHeights = <double>[
Random().nextInt(300).toDouble(),
Random().nextInt(300).toDouble(),
Random().nextInt(300).toDouble(),
Random().nextInt(300).toDouble(),
];
class CarouselVariableHightState extends State<CarouselVariableHight> {
double height;
GlobalKey stackKey = GlobalKey();
bool widgetHasHeigh;
#override
void initState() {
WidgetsBinding.instance.addPostFrameCallback(_afterLayout);
super.initState();
}
_afterLayout(_) {
final RenderBox renderBoxRed = stackKey.currentContext.findRenderObject();
final sizeRed = renderBoxRed.size;
setState(() {
height = sizeRed.height;
});
}
final caruselItems = widgetHeights
.map((height) => Column(children: [
Container(
color: Colors.red,
height: Random().nextInt(300).toDouble(),
child: Text('Text with random length'))
]))
.toList();
_buildStack() {
Widget firstElement;
if (height == null) {
firstElement = Container();
} else {
firstElement = Container(
height: height,
child: PageView(
children: caruselItems,
),
);
}
return IndexedStack(
key: stackKey,
children: <Widget>[
firstElement,
...caruselItems,
],
);
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Carousel')),
body: ListView(
children: <Widget>[
Text('My Carousel'),
_buildStack(),
Text('end of List'),
],
),
);
}
}
Just Copy the below code and use it as usual like a normal pageViewer.
class ExpandablePageView extends StatefulWidget {
final int itemCount;
final Widget Function(BuildContext, int) itemBuilder;
final PageController controller;
final ValueChanged<int> onPageChanged;
final bool reverse;
const ExpandablePageView({
#required this.itemCount,
#required this.itemBuilder,
this.controller,
this.onPageChanged,
this.reverse = false,
Key key,
}) : assert(itemCount != null),
assert(itemBuilder != null),
super(key: key);
#override
_ExpandablePageViewState createState() => _ExpandablePageViewState();
}
class _ExpandablePageViewState extends State<ExpandablePageView> {
PageController _pageController;
List<double> _heights;
int _currentPage = 0;
double get _currentHeight => _heights[_currentPage];
#override
void initState() {
super.initState();
_heights = List.filled(widget.itemCount, 0, growable: true);
_pageController = widget.controller ?? PageController();
_pageController.addListener(_updatePage);
}
#override
void dispose() {
_pageController?.removeListener(_updatePage);
_pageController?.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
curve: Curves.easeInOutCubic,
tween: Tween<double>(begin: _heights.first, end: _currentHeight),
duration: const Duration(milliseconds: 100),
builder: (context, value, child) => SizedBox(height: value, child: child),
child: PageView.builder(
controller: _pageController,
itemCount: widget.itemCount,
itemBuilder: _itemBuilder,
onPageChanged: widget.onPageChanged,
reverse: widget.reverse,
),
);
}
Widget _itemBuilder(BuildContext context, int index) {
final item = widget.itemBuilder(context, index);
return OverflowBox(
minHeight: 0,
maxHeight: double.infinity,
alignment: Alignment.topCenter,
child: SizeReportingWidget(
onSizeChange: (size) => setState(() => _heights[index] = size?.height ?? 0),
child: item,
),
);
}
void _updatePage() {
final newPage = _pageController.page.round();
if (_currentPage != newPage) {
setState(() {
_currentPage = newPage;
});
}
}
}
class SizeReportingWidget extends StatefulWidget {
final Widget child;
final ValueChanged<Size> onSizeChange;
const SizeReportingWidget({
#required this.child,
#required this.onSizeChange,
Key key,
}) : assert(child != null),
assert(onSizeChange != null),
super(key: key);
#override
_SizeReportingWidgetState createState() => _SizeReportingWidgetState();
}
class _SizeReportingWidgetState extends State<SizeReportingWidget> {
Size _oldSize;
#override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySize());
return widget.child;
}
void _notifySize() {
final size = context?.size;
if (_oldSize != size) {
_oldSize = size;
widget.onSizeChange(size);
}
}
}
You can use this with as same as like the PageView
expandable_page_view use the expandable_page_view pub for dynamic heights based on child.
I'm making an image gallery and I need the user to be able to long-press an image to show a popup menu which will let him delete the image.
My code, so far:
return GestureDetector(
onLongPress: () {
showMenu(
items: <PopupMenuEntry>[
PopupMenuItem(
value: this._index,
child: Row(
children: <Widget>[
Icon(Icons.delete),
Text("Delete"),
],
),
)
],
context: context,
);
},
child: Image.memory(
this._asset.thumbData.buffer.asUint8List(),
fit: BoxFit.cover,
gaplessPlayback: true,
),
);
Which produces:
But also, I couldn't find out how to completely remove the image's widget when the longPress function is called. How to do so?
The OP and the First Answerer bypassed the original problem using PopupMenuButton, which worked fine in their case. But I think the more general question of how to position one's own menu and how to receive the user's response without using PopupMenuButton is worth answering, because sometimes we want a popup menu on a custom widget, and we want it to appear on some gestures other than a simple tap (e.g. the OP's original intention was to long-press).
I set out to make a simple app demonstrating the following:
Use a GestureDetector to capture long-press
Use the function showMenu() to display a popup menu, and position it near the finger's touch
How to receive the user's selection
(Bonus) How to make a PopupMenuEntry that represents multiple values (the oft-used PopupMenuItem can only represent a single value)
The result is, when you long-press on a big yellow area, a popup menu appears on which you can select +1 or -1, and the big number would increment or decrement accordingly:
Skip to the end for the entire body of code. Comments are sprinkled in there to explain what I am doing. Here are a few things to note:
showMenu()'s position parameter takes some effort to understand. It's a RelativeRect, which represents how a smaller rect is positioned inside a bigger rect. In our case, the bigger rect is the entire screen, the smaller rect is the area of touch. Flutter positions the popup menu according to these rules (in plain English):
if the smaller rect leans toward the left half of the bigger rect, the popup menu would align with the smaller rect's left edge
if the smaller rect leans toward the right half of the bigger rect, the popup menu would align with the smaller rect's right edge
if the smaller rect is in the middle, which edge wins depends on the language's text direction. Left edge wins if using English and other left-to-right languages, right edge wins otherwise.
It's always useful to reference PopupMenuButton's official implementation to see how it uses showMenu() to display the menu.
showMenu() returns a Future. Use Future.then() to register a callback to handle user selection. Another option is to use await.
Remember that PopupMenuEntry is a (subclass of) StatefulWidget. You can layout any number of sub-widgets inside it. This is how you represent multiple values in a PopupMenuEntry. If you want it to represent two values, just make it contain two buttons, however you want to lay them out.
To close the popup menu, use Navigator.pop(). Flutter treats popup menus like a smaller "page". When we display a popup menu, we are actually pushing a "page" to the navigator's stack. To close a popup menu, we pop it from the stack, thus completing the aforementioned Future.
Here is the full code:
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Popup Menu Usage',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Popup Menu Usage'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
#override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
var _count = 0;
var _tapPosition;
void _showCustomMenu() {
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
showMenu(
context: context,
items: <PopupMenuEntry<int>>[PlusMinusEntry()],
position: RelativeRect.fromRect(
_tapPosition & const Size(40, 40), // smaller rect, the touch area
Offset.zero & overlay.size // Bigger rect, the entire screen
)
)
// This is how you handle user selection
.then<void>((int delta) {
// delta would be null if user taps on outside the popup menu
// (causing it to close without making selection)
if (delta == null) return;
setState(() {
_count = _count + delta;
});
});
// Another option:
//
// final delta = await showMenu(...);
//
// Then process `delta` however you want.
// Remember to make the surrounding function `async`, that is:
//
// void _showCustomMenu() async { ... }
}
void _storePosition(TapDownDetails details) {
_tapPosition = details.globalPosition;
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
GestureDetector(
// This does not give the tap position ...
onLongPress: _showCustomMenu,
// Have to remember it on tap-down.
onTapDown: _storePosition,
child: Container(
color: Colors.amberAccent,
padding: const EdgeInsets.all(100.0),
child: Text(
'$_count',
style: const TextStyle(
fontSize: 100, fontWeight: FontWeight.bold),
),
),
),
],
),
),
);
}
}
class PlusMinusEntry extends PopupMenuEntry<int> {
#override
double height = 100;
// height doesn't matter, as long as we are not giving
// initialValue to showMenu().
#override
bool represents(int n) => n == 1 || n == -1;
#override
PlusMinusEntryState createState() => PlusMinusEntryState();
}
class PlusMinusEntryState extends State<PlusMinusEntry> {
void _plus1() {
// This is how you close the popup menu and return user selection.
Navigator.pop<int>(context, 1);
}
void _minus1() {
Navigator.pop<int>(context, -1);
}
#override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Expanded(child: FlatButton(onPressed: _plus1, child: Text('+1'))),
Expanded(child: FlatButton(onPressed: _minus1, child: Text('-1'))),
],
);
}
}
If you are going to use a gridView or listview for laying out the images on the screen, you can wrap each item with a gesture detector then you should keep your images in a list somewhere, then simply remove the image from the list and call setState().
Something like the following. (This code will probably won't compile but it should give you the idea)
ListView.builder(
itemCount: imageList.length,
itemBuilder: (BuildContext context, int index) {
return GestureDetector(
onLongPress: () {
showMenu(
onSelected: () => setState(() => imageList.remove(index))}
items: <PopupMenuEntry>[
PopupMenuItem(
value: this._index,
child: Row(
children: <Widget>[
Icon(Icons.delete),
Text("Delete"),
],
),
)
],
context: context,
);
},
child: imageList[index],
);
}
)
Edit: You can use a popup menu too, like following
Container(
margin: EdgeInsets.symmetric(vertical: 10),
height: 100,
width: 100,
child: PopupMenuButton(
child: FlutterLogo(),
itemBuilder: (context) {
return <PopupMenuItem>[new PopupMenuItem(child: Text('Delete'))];
},
),
),
Building on the answers by Nick Lee and hacker1024, but instead of turning the solution into a mixin, you could simply just turn it into a widget:
class PopupMenuContainer<T> extends StatefulWidget {
final Widget child;
final List<PopupMenuEntry<T>> items;
final void Function(T) onItemSelected;
PopupMenuContainer({#required this.child, #required this.items, #required this.onItemSelected, Key key}) : super(key: key);
#override
State<StatefulWidget> createState() => PopupMenuContainerState<T>();
}
class PopupMenuContainerState<T> extends State<PopupMenuContainer<T>>{
Offset _tapDownPosition;
#override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (TapDownDetails details){
_tapDownPosition = details.globalPosition;
},
onLongPress: () async {
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
T value = await showMenu<T>(
context: context,
items: widget.items,
position: RelativeRect.fromLTRB(
_tapDownPosition.dx,
_tapDownPosition.dy,
overlay.size.width - _tapDownPosition.dx,
overlay.size.height - _tapDownPosition.dy,
),
);
widget.onItemSelected(value);
},
child: widget.child
);
}
}
And then you'd use it like this:
child: PopupMenuContainer<String>(
child: Image.asset('assets/image.png'),
items: [
PopupMenuItem(value: 'delete', child: Text('Delete'))
],
onItemSelected: (value) async {
if( value == 'delete' ){
await showDialog(context: context, child: AlertDialog(
title: Text('Delete image'),
content: Text('Are you sure you want to delete the image?'),
actions: [
uiFlatButton(child: Text('NO'), onTap: (){ Navigator.of(context).pop(false); }),
uiFlatButton(child: Text('YES'), onTap: (){ Navigator.of(context).pop(true); }),
],
));
}
},
),
Adjust the code to fit your needs.
Nick Lee's answer can be turned into a mixin quite easily, which can then be used anywhere you want to use a popup menu.
The mixin:
import 'package:flutter/material.dart' hide showMenu;
import 'package:flutter/material.dart' as material show showMenu;
/// A mixin to provide convenience methods to record a tap position and show a popup menu.
mixin CustomPopupMenu<T extends StatefulWidget> on State<T> {
Offset _tapPosition;
/// Pass this method to an onTapDown parameter to record the tap position.
void storePosition(TapDownDetails details) => _tapPosition = details.globalPosition;
/// Use this method to show the menu.
Future<T> showMenu<T>({
#required BuildContext context,
#required List<PopupMenuEntry<T>> items,
T initialValue,
double elevation,
String semanticLabel,
ShapeBorder shape,
Color color,
bool captureInheritedThemes = true,
bool useRootNavigator = false,
}) {
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
return material.showMenu<T>(
context: context,
position: RelativeRect.fromLTRB(
_tapPosition.dx,
_tapPosition.dy,
overlay.size.width - _tapPosition.dx,
overlay.size.height - _tapPosition.dy,
),
items: items,
initialValue: initialValue,
elevation: elevation,
semanticLabel: semanticLabel,
shape: shape,
color: color,
captureInheritedThemes: captureInheritedThemes,
useRootNavigator: useRootNavigator,
);
}
}
And then, to use it:
import 'package:flutter/material.dart';
import './custom_context_menu.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Popup Menu Usage',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Popup Menu Usage'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
#override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> with CustomPopupMenu {
var _count = 0;
void _showCustomMenu() {
this.showMenu(
context: context,
items: <PopupMenuEntry<int>>[PlusMinusEntry()],
)
// This is how you handle user selection
.then<void>((int delta) {
// delta would be null if user taps on outside the popup menu
// (causing it to close without making selection)
if (delta == null) return;
setState(() {
_count = _count + delta;
});
});
// Another option:
//
// final delta = await showMenu(...);
//
// Then process `delta` however you want.
// Remember to make the surrounding function `async`, that is:
//
// void _showCustomMenu() async { ... }
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
GestureDetector(
// This does not give the tap position ...
onLongPress: _showCustomMenu,
// Have to remember it on tap-down.
onTapDown: storePosition,
child: Container(
color: Colors.amberAccent,
padding: const EdgeInsets.all(100.0),
child: Text(
'$_count',
style: const TextStyle(fontSize: 100, fontWeight: FontWeight.bold),
),
),
),
],
),
),
);
}
}
class PlusMinusEntry extends PopupMenuEntry<int> {
#override
double height = 100;
// height doesn't matter, as long as we are not giving
// initialValue to showMenu().
#override
bool represents(int n) => n == 1 || n == -1;
#override
PlusMinusEntryState createState() => PlusMinusEntryState();
}
class PlusMinusEntryState extends State<PlusMinusEntry> {
void _plus1() {
// This is how you close the popup menu and return user selection.
Navigator.pop<int>(context, 1);
}
void _minus1() {
Navigator.pop<int>(context, -1);
}
#override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Expanded(child: FlatButton(onPressed: _plus1, child: Text('+1'))),
Expanded(child: FlatButton(onPressed: _minus1, child: Text('-1'))),
],
);
}
}
Answer for 2023
In Flutter 3.7 there is now a ContextMenuRegion widget that you can wrap around any existing widget. When the user long presses or right-clicks (depending on the platform), the menu you give it will appear.
return Scaffold(
body: Center(
child: ContextMenuRegion(
contextMenuBuilder: (context, offset) {
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: TextSelectionToolbarAnchors(
primaryAnchor: offset,
),
buttonItems: <ContextMenuButtonItem>[
ContextMenuButtonItem(
onPressed: () {
ContextMenuController.removeAny();
},
label: 'Save',
),
],
);
},
child: const SizedBox(
width: 200.0,
height: 200.0,
child: FlutterLogo(),
),
),
),
);
I'm learning Flutter and would like to make a Widget just like the built-in CircleAvatar. However, I would like the behaviour to be
specify both an Image (NetworkImage) and initials (ie, BB)
while the image isn't loaded, show the initials
if the image does load, show the image and remove the initials
The following code sort of works, but when used in the Chat demo it falls apart as multiple MyAvatars are added.
Breakpointing on initState shows that it is always called with the first message text that is entered - not what I expected.
It also flickers as images "reload". It appears that the widgets are being reused in a way I don't understand.
class MyAvatar extends StatefulWidget {
NetworkImage image;
MyAvatar({this.text}) {
debugPrint("MyAvatar " + this.text);
if (text.contains('fun')) {
this.image = new NetworkImage("https://cdn3.iconfinder.com/data/icons/minicons-for-web-sites/24/minicons2-14-512.png");
}
}
final String text;
#override
MyAvatarState createState() {
return new MyAvatarState();
}
}
class MyAvatarState extends State<MyAvatar> {
bool showImage = false;
#override
initState() {
super.initState();
if (widget.image != null) {
var completer = widget.image.load(widget.image);
completer.addListener((info, sync) {
setState(() {
showImage = true;
});
});
}
}
#override
Widget build(BuildContext context) {
return !showImage ? new CircleAvatar(radius: 40.0, child: new Text(widget.text[0]))
: new CircleAvatar(radius: 40.0, backgroundImage: widget.image);
}
}
I'm still having trouble - full code
import 'package:flutter/material.dart';
// Modify the ChatScreen class definition to extend StatefulWidget.
class ChatScreen extends StatefulWidget { //modified
ChatScreen() {
debugPrint("ChatScreen - called on hot reload");
}
#override //new
State createState() {
debugPrint("NOT on hot reload");
return new ChatScreenState();
} //new
}
// Add the ChatScreenState class definition in main.dart.
class ChatScreenState extends State<ChatScreen> {
final List<ChatMessage> _messages = <ChatMessage>[];
final TextEditingController _textController = new TextEditingController(); //new
ChatScreenState() {
debugPrint("ChatScreenState - not called on hot reload");
}
#override //new
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(title: new Text("Friendlychat")),
body: new Column( //modified
children: <Widget>[ //new
new Flexible( //new
child: new ListView.builder( //new
padding: new EdgeInsets.all(8.0), //new
reverse: true, //new
itemBuilder: (_, int index) => _messages[index], //new
itemCount: _messages.length, //new
) //new
), //new
new Divider(height: 1.0), //new
new Container( //new
decoration: new BoxDecoration(
color: Theme.of(context).cardColor), //new
child: _buildTextComposer(), //modified
), //new
] //new
), //new
);
}
Widget _buildTextComposer() {
return new IconTheme(
data: new IconThemeData(color: Theme
.of(context)
.accentColor),
child:
new Container(
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: new Row(
children: <Widget>[
new Container( //new
margin: new EdgeInsets.symmetric(horizontal: 4.0), //new
child: new IconButton( //new
icon: new Icon(Icons.send),
onPressed: () =>
_handleSubmitted(_textController.text)), //new
),
new Flexible(
child: new TextField(
controller: _textController,
onSubmitted: _handleSubmitted,
decoration: new InputDecoration.collapsed(
hintText: "Send a message"),
)
),
])
)
);
}
void _handleSubmitted(String text) {
_textController.clear();
ChatMessage message = new ChatMessage(text: text);
setState(() {
_messages.insert(0, message);
});
}
}
const String _name = "Hardcoded Name";
class ChatMessage extends StatelessWidget {
ChatMessage({this.text, this.image, this.useImage});
final String text;
final NetworkImage image;
final Map useImage;
#override
Widget build(BuildContext context) {
var use = true; //useImage != null && useImage['use'];
var image = new NetworkImage("https://cdn3.iconfinder.com/data/icons/minicons-for-web-sites/24/minicons2-14-512.png");
if (text.contains('bad')) {
image = new NetworkImage("https://cdn3.iconfinder.com/data/icons/minicons-for-web-sites/24/minicons2-14-512.pngz");
}
return new Container(
margin: const EdgeInsets.symmetric(vertical: 10.0),
child: new Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
new Container(
margin: const EdgeInsets.only(right: 16.0),
child : new CustomCircleAvatar(initials: text[0], myImage: image)
),
new Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
new Text(_name, style: Theme.of(context).textTheme.subhead),
new Container(
margin: const EdgeInsets.only(top: 5.0),
child: new Text(text),
),
],
),
],
),
);
}
}
class CustomCircleAvatar extends StatefulWidget {
NetworkImage myImage;
String initials;
CustomCircleAvatar({this.myImage, this.initials}) {
debugPrint(initials);
}
#override
_CustomCircleAvatarState createState() => new _CustomCircleAvatarState();
}
class _CustomCircleAvatarState extends State<CustomCircleAvatar>{
bool _checkLoading = true;
#override
void initState() {
if (widget.myImage != null) {
widget.myImage.resolve(new ImageConfiguration()).addListener((image, sync) {
if (mounted && image != null) {
setState(() {
_checkLoading = false;
});
}
});
}
}
#override
Widget build(BuildContext context) {
return _checkLoading == true ? new CircleAvatar(child: new Text(widget.initials))
: new CircleAvatar(backgroundImage: widget.myImage);
}
}
Enter 'fun' as a message, then 'bad' as the second -
image
The idea is that depending on what you enter, different images might load (or not). In the 'failed to load' case, the initials should remain.
You can achieve this functionality by adding a listener to ImageStream that you can obtain from ImageConfiguration,
Here, I am feeding the same data to my ListView you can of course customize this yourself by adding a List of images and initials as a field in any class and use ListView.builder instead to be able to loop on them by index.
class CustomCircleAvatar extends StatefulWidget {
NetworkImage myImage;
String initials;
CustomCircleAvatar({this.myImage, this.initials});
#override
_CustomCircleAvatarState createState() => new _CustomCircleAvatarState();
}
class _CustomCircleAvatarState extends State<CustomCircleAvatar>{
bool _checkLoading = true;
#override
void initState() {
widget.myImage.resolve(new ImageConfiguration()).addListener((_, __) {
if (mounted) {
setState(() {
_checkLoading = false;
});
}
});
}
#override
Widget build(BuildContext context) {
return _checkLoading == true ? new CircleAvatar(
child: new Text(widget.initials)) : new CircleAvatar(
backgroundImage: widget.myImage,);
}
}
Now you can use it like this:
void main() {
runApp(new MaterialApp (home: new MyApp()));
}
class MyApp extends StatefulWidget {
#override
_MyAppState createState() => new _MyAppState();
}
class _MyAppState extends State<MyApp> {
#override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(title: new Text("Custom Circle Avatar"),),
body: new ListView(children: new List.generate(20, (int index) {
return new Container(
height: 100.0,
width: 100.0,
child: new CustomCircleAvatar(myImage: new NetworkImage(
"https://www.doginni.cz/front_path/images/dog_circle.png"),
initials: "Dog",
),
);
}),),
);
}
}
This works really well and easy. Use the CachetNetworkImage and build the appropriate CircleAvatar.
return CachedNetworkImage(
httpHeaders: headers,
imageUrl: general.HOST + 'api/media/v2/' + id,
imageBuilder: (context, imageProvider) => new CircleAvatar(
radius: radius,
backgroundImage: imageProvider,
backgroundColor: backgroundColor),
errorWidget: (context, url, error) => CircleAvatar(
backgroundColor: backgroundColor,
radius: radius,
child: new Text(initials, style: textStyle,)),
);
The answer from #aziza was really the only one I could find on the topic for a while and it took me a while to read it and understand. I tried implementing it and there were some issues though I did get it to work eventually. I think I have a more readable (for me at least!)/up to date answer that might help someone stumbling upon this question:
class FallBackAvatar extends StatefulWidget {
final AssetImage image;
final String initials;
final TextStyle textStyle;
final Color circleBackground;
FallBackAvatar({#required this.image, #required this.initials, #required this.circleBackground, #required this.textStyle});
#override
_FallBackAvatarState createState() => _FallBackAvatarState();
}
class _FallBackAvatarState extends State<FallBackAvatar> {
bool _checkLoading = true;
#override
initState() {
super.initState();
// Add listeners to this class
ImageStreamListener listener = ImageStreamListener(_setImage, onError: _setError);
widget.image.resolve(ImageConfiguration()).addListener(listener);
}
void _setImage(ImageInfo image, bool sync) {
setState(() => _checkLoading = false);
//DO NOT DISPOSE IF IT WILL REBUILD (e.g. Sliver/Builder ListView)
dispose();
}
void _setError(dynamic dyn, StackTrace st) {
setState(() => _checkLoading = true);
dispose();
}
#override
Widget build(BuildContext context) {
return _checkLoading == true ? new CircleAvatar(
backgroundColor: widget.circleBackground,
child: new Text(widget.initials, style: widget.textStyle)) : new CircleAvatar(
backgroundImage: widget.image,
backgroundColor: widget.circleBackground,);
}
}
A couple of points, I'm manually disposing because I know after this there should be no more rebuilds (did you get the image? good! no more rebuilds unless you are part of a sliver or something OR did the image fail to load? well that's it then - no more rebuilds). This also handles the error case where the AssetImage (in my case, its Asset image but you could use any kind of image provider) is not there for whatever reason.
Second edit, because I have personal problems best left out of this answer. So I noticed that there was a slight delay in loading the profile images (like a second). But then the images came flooding in. Didn't like that transition so here is one with an AnimatedSwitcher:
class FallBackAvatar extends StatefulWidget {
final AssetImage image;
final String initials;
final TextStyle textStyle;
final Color circleBackground;
final double radius;
final int msAnimationDuration;
FallBackAvatar({#required this.image, #required this.initials, #required this.circleBackground, #required this.textStyle, #required this.radius, this.msAnimationDuration});
#override
_FallBackAvatarState createState() => _FallBackAvatarState();
}
class _FallBackAvatarState extends State<FallBackAvatar> {
bool _imgSuccess = false;
#override
initState() {
super.initState();
// Add listeners to this class
ImageStreamListener listener = ImageStreamListener(_setImage, onError: _setError);
widget.image.resolve(ImageConfiguration()).addListener(listener);
}
void _setImage(ImageInfo image, bool sync) {
setState(() => _imgSuccess = true);
}
void _setError(dynamic dyn, StackTrace st) {
setState(() => _imgSuccess = false);
dispose();
}
Widget _fallBackAvatar() {
return Container(
height: widget.radius*2,
width: widget.radius*2,
decoration: BoxDecoration(
color: widget.circleBackground,
borderRadius: BorderRadius.all(Radius.circular(widget.radius))
),
child: Center(child: Text(widget.initials, style: widget.textStyle))
);
}
Widget _avatarImage() {
return CircleAvatar(
backgroundImage: widget.image,
backgroundColor: widget.circleBackground
);
}
#override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: Duration(milliseconds: widget.msAnimationDuration ?? 500),
child: _imgSuccess ? _avatarImage() : _fallBackAvatar(),
);
}
}
Actually the code can be even simpler:
if you want to put a text when the image is unavailable you should simply use foregroundImage instead of backgroundImage.
The text will displayed by default, when the image is loaded it will cover the text without having to deal with image loading status etc.
If you need to know if the image had an error you can intercept it with onForegroundImageError.
Example function:
Widget CircleAvatarTest(
{String? imageUrl,
String? text,
double radius = 35,
Color? backgroundColor}) {
return CircleAvatar(
radius: radius,
child: (text != null)
? Center(
child: Text(text,
style: TextStyle(
color: Colors.white,
fontSize: radius * 2 / text.length - 10,
)),
)
: null,
foregroundImage: imageUrl == null ? null : NetworkImage(imageUrl),
backgroundColor: backgroundColor,
//onForegroundImageError: (e,trace){/*....*/},
);
}
Here is the sample with stacked architecture where fallback is person icon.
ViewBuilder and ViewModel are just extended widgets from stacked architecture alternatives. #swidget is functional widget. You can achieve the same functionality via StatefulWidget.
#swidget
Widget avatarView({String userId, double radius = 24}) =>
ViewBuilder<AvatarViewModel>(
viewModelBuilder: () => AvatarViewModel(),
builder: (model) => CircleAvatar(
radius: radius,
backgroundColor: CColors.blackThird,
backgroundImage: NetworkImage(
Config.photoUrl + userId ?? userService.id,
),
child: model.isFailed ? Icon(EvaIcons.person, size: radius) : null,
onBackgroundImageError: (e, _) => model.isFailed = e != null,
),
);
class AvatarViewModel extends ViewModel {
bool _isFailed = false;
bool get isFailed => _isFailed;
set isFailed(bool isFailed) {
_isFailed = isFailed;
notifyListeners();
}
}
I'd like to create a number counter that animates from a starting value to an end value. I've looked into using a Timer but can't seem to animate/update state properly. Including the decimal value would be great, but a simple integer animation is fine.
Number counter that needs to animate
double _mileCounter = 643.6;
_animateMileCounter() {
Duration duration = new Duration(milliseconds: 300);
return new Timer(duration, _updateMileCounter);
}
_updateMileCounter() {
setState(() {
_mileCounter += 1;
});
}
How would I increment the counter X number of times (with animation)? Similar to how a car's odometer increments.
For anyone still looking, you can use ImplicitlyAnimatedWidget.
Here is an example of an int counter. Works analogously for doubles.
class AnimatedCount extends ImplicitlyAnimatedWidget {
final int count;
AnimatedCount({
Key key,
#required this.count,
#required Duration duration,
Curve curve = Curves.linear
}) : super(duration: duration, curve: curve, key: key);
#override
ImplicitlyAnimatedWidgetState<ImplicitlyAnimatedWidget> createState() => _AnimatedCountState();
}
class _AnimatedCountState extends AnimatedWidgetBaseState<AnimatedCount> {
IntTween _count;
#override
Widget build(BuildContext context) {
return new Text(_count.evaluate(animation).toString());
}
#override
void forEachTween(TweenVisitor visitor) {
_count = visitor(_count, widget.count, (dynamic value) => new IntTween(begin: value));
}
}
Just rebuild the widget with a new value and it automatically animates there.
You should use an AnimationController with an AnimatedBuilder to rebuild your text when the controller changes. Here's an example that increments the miles when the floating action button is pressed (double.toStringAsFixed to get the decimal to show), with a curve on the animation speed:
import 'dart:math';
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.purple),
home: new MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
#override
createState() => new MyHomePageState();
}
class MyHomePageState extends State<MyHomePage> with TickerProviderStateMixin {
AnimationController _controller;
Animation<double> _animation;
double _miles = 0.0;
#override initState() {
_controller = new AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
_animation = _controller;
super.initState();
}
#override
Widget build(BuildContext context) {
TextTheme textTheme = Theme.of(context).textTheme;
return new Scaffold(
body: new Material(
color: const Color.fromRGBO(246, 251, 8, 1.0),
child: new Center(
child: new Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
new AnimatedBuilder(
animation: _animation,
builder: (BuildContext context, Widget child) {
return new Text(
_animation.value.toStringAsFixed(1),
style: textTheme.display4.copyWith(fontStyle: FontStyle.italic),
);
},
),
new Text(
"MILES",
style: textTheme.display1.copyWith(fontStyle: FontStyle.italic),
)
],
),
),
),
floatingActionButton: new FloatingActionButton(
child: new Icon(Icons.directions_run),
onPressed: () {
Random rng = new Random();
setState(() {
_miles += rng.nextInt(20) + 0.3;
_animation = new Tween<double>(
begin: _animation.value,
end: _miles,
).animate(new CurvedAnimation(
curve: Curves.fastOutSlowIn,
parent: _controller,
));
});
_controller.forward(from: 0.0);
}
),
);
}
#override
void dispose() {
_controller.dispose();
super.dispose();
}
}
You can use Countup package.
Countup(
begin: 0,
end: 7500,
duration: Duration(seconds: 3),
separator: ',',
style: TextStyle(
fontSize: 36,
),
)
https://pub.dev/packages/countup
You can simply use this plugin countup: ^0.1.3
import 'package:countup/countup.dart';
Countup(
begin: 100,
end: 8000,
duration: Duration(seconds: 3),
separator: ',',
style: TextStyle(
fontSize: 36,
fontweight : Fontweight.bold,
),
),