I am trying to implement Flutter's AnimatedList. What I'd like to achieve is to allready have a list of elements and then insert them one by one in the list with a total duration of 1 second.
For example: I have a list of 5 containers (a red one, a blue one, a green one, a pink one and a white one). I want each container to be slided in the list view.
I would now like that on startup, this list is displayed in the following timestamps:
0..200ms: red container
200..400ms: blue container
400..600ms: green container
600..800ms: pink container
800..1000ms: white container
Such that the entire list takes up 1 second to build and the amount of time 1 container should take for its animation is 1/nseconds and each container at index i in the list should start its animation at i*(1/n)seconds. Yet all documentation or examples I could find is simply displaying a button and then inserting a new item in the list, whilst I want an already created list to be displayed by the means of an animation.
Have you tried Timer.periodic.
You can simply use it and insert item after time time mentioned. like this :
void startTimer() {
const oneSec = const Duration(milliseconds: 1000);
_timer = new Timer.periodic(
oneSec,
(Timer timer) {
_insert();
if(_list.length == 10){
timer.cancel();
}
},
);
Complete code on Dart pad :
/// Flutter code sample for AnimatedList
// This sample application uses an [AnimatedList] to create an effect when
// items are removed or added to the list.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const AnimatedListSample());
}
class AnimatedListSample extends StatefulWidget {
const AnimatedListSample({Key? key}) : super(key: key);
#override
_AnimatedListSampleState createState() => _AnimatedListSampleState();
}
class _AnimatedListSampleState extends State<AnimatedListSample> {
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
late ListModel<int> _list;
int? _selectedItem;
late int
_nextItem; // The next item inserted when the user presses the '+' button.
Timer? _timer;
#override
void initState() {
super.initState();
_list = ListModel<int>(
listKey: _listKey,
initialItems: <int>[0, 1, 2],
removedItemBuilder: _buildRemovedItem,
);
_nextItem = 3;
startTimer();
}
void startTimer() {
const oneSec = const Duration(milliseconds: 1000);
_timer = new Timer.periodic(
oneSec,
(Timer timer) {
_insert();
if(_list.length == 10){
timer.cancel();
}
},
);
}
#override
void dispose() {
_timer!.cancel();
super.dispose();
}
// Used to build list items that haven't been removed.
Widget _buildItem(
BuildContext context, int index, Animation<double> animation) {
return CardItem(
animation: animation,
item: _list[index],
selected: _selectedItem == _list[index],
onTap: () {
setState(() {
_selectedItem = _selectedItem == _list[index] ? null : _list[index];
});
},
);
}
// Used to build an item after it has been removed from the list. This
// method is needed because a removed item remains visible until its
// animation has completed (even though it's gone as far this ListModel is
// concerned). The widget will be used by the
// [AnimatedListState.removeItem] method's
// [AnimatedListRemovedItemBuilder] parameter.
Widget _buildRemovedItem(
int item, BuildContext context, Animation<double> animation) {
return CardItem(
animation: animation,
item: item,
selected: false,
// No gesture detector here: we don't want removed items to be interactive.
);
}
// Insert the "next item" into the list model.
void _insert() {
final int index =
_selectedItem == null ? _list.length : _list.indexOf(_selectedItem!);
_list.insert(index, _nextItem++);
}
// Remove the selected item from the list model.
void _remove() {
if (_selectedItem != null) {
_list.removeAt(_list.indexOf(_selectedItem!));
setState(() {
_selectedItem = null;
});
}
}
#override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('AnimatedList'),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.add_circle),
onPressed: _insert,
tooltip: 'insert a new item',
),
IconButton(
icon: const Icon(Icons.remove_circle),
onPressed: _remove,
tooltip: 'remove the selected item',
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: AnimatedList(
key: _listKey,
initialItemCount: _list.length,
itemBuilder: _buildItem,
),
),
),
);
}
}
typedef RemovedItemBuilder = Widget Function(
int item, BuildContext context, Animation<double> animation);
/// Keeps a Dart [List] in sync with an [AnimatedList].
///
/// The [insert] and [removeAt] methods apply to both the internal list and
/// the animated list that belongs to [listKey].
///
/// This class only exposes as much of the Dart List API as is needed by the
/// sample app. More list methods are easily added, however methods that
/// mutate the list must make the same changes to the animated list in terms
/// of [AnimatedListState.insertItem] and [AnimatedList.removeItem].
class ListModel<E> {
ListModel({
required this.listKey,
required this.removedItemBuilder,
Iterable<E>? initialItems,
}) : _items = List<E>.from(initialItems ?? <E>[]);
final GlobalKey<AnimatedListState> listKey;
final RemovedItemBuilder removedItemBuilder;
final List<E> _items;
AnimatedListState? get _animatedList => listKey.currentState;
void insert(int index, E item) {
_items.insert(index, item);
_animatedList!.insertItem(index);
}
E removeAt(int index) {
final E removedItem = _items.removeAt(index);
if (removedItem != null) {
_animatedList!.removeItem(
index,
(BuildContext context, Animation<double> animation) {
return removedItemBuilder(index, context, animation);
},
);
}
return removedItem;
}
int get length => _items.length;
E operator [](int index) => _items[index];
int indexOf(E item) => _items.indexOf(item);
}
/// Displays its integer item as 'item N' on a Card whose color is based on
/// the item's value.
///
/// The text is displayed in bright green if [selected] is
/// true. This widget's height is based on the [animation] parameter, it
/// varies from 0 to 128 as the animation varies from 0.0 to 1.0.
class CardItem extends StatelessWidget {
const CardItem({
Key? key,
this.onTap,
this.selected = false,
required this.animation,
required this.item,
}) : assert(item >= 0),
super(key: key);
final Animation<double> animation;
final VoidCallback? onTap;
final int item;
final bool selected;
#override
Widget build(BuildContext context) {
TextStyle textStyle = Theme.of(context).textTheme.headline4!;
if (selected)
textStyle = textStyle.copyWith(color: Colors.lightGreenAccent[400]);
return Padding(
padding: const EdgeInsets.all(2.0),
child: SizeTransition(
axis: Axis.vertical,
sizeFactor: animation,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
height: 80.0,
child: Card(
color: Colors.primaries[item % Colors.primaries.length],
child: Center(
child: Text('Item $item', style: textStyle),
),
),
),
),
),
);
}
}
You can customise that duration in startTimer method using any formulae u like. Remember the duration should be 200 milliseconds as you mentioned.
Related
I would like to have a widget that takes any number of children widgets and displays whatever widget is selected. A widget is selected by index of the widget in the children list.
In order to select a widget, I create a stateful widget whose builder method returns the selected child. The ChildByIndexState allows some other widget to access and update the selected child index.
class ChildByIndex extends StatefulWidget {
const ChildByIndex({
Key? key,
required this.index,
required this.children,
}) : super(key: key);
final int index;
final List<Widget> children;
#override
State<ChildByIndex> createState() => ChildByIndexState();
}
class ChildByIndexState extends State<ChildByIndex> {
late int _index = widget.index;
int get index => _index;
/// Update the state if the index is different from the current index.
set index(int value) {
if (value != _index) setState(() => _index = value);
}
#override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: SizedBox(key: ValueKey(_index), child: widget.children[_index]),
);
}
}
So that takes care of switching widgets with a nice animation, next I need to tackle the dynamic resizing based on the selected widget's intrinsic size. The demonstration widget uses a AnimatedSize widget to animate size transitions and a GlobalKey<ChildByIndexState> to set the index.
class Resizer extends StatelessWidget {
const Resizer({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
final children = <Widget>[
Container(color: Colors.red, width: 100, height: 100),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
SizedBox(height: 100, child: Card(color: Colors.purple)),
SizedBox(height: 100, child: Card(color: Colors.pink)),
],
),
Container(color: Colors.green, width: 250, height: 150),
];
final childByIndexKey = GlobalKey<ChildByIndexState>();
return Scaffold(
body: SafeArea(
child: Stack(
children: [
Positioned.fill(
child: Center(
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
reverseDuration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
child: ChildByIndex(
key: childByIndexKey,
index: 0,
children: children,
),
),
),
),
/// Sets the index of [ChildByIndex] and wraps around to the first
/// child index when the index is past at the final child.
Positioned(
width: 64.0,
height: 64.0,
top: 8.0,
left: 8.0,
child: FloatingActionButton(
onPressed: () {
final childByIndexState = childByIndexKey.currentState;
if (childByIndexState == null) return;
childByIndexState.index =
(childByIndexState.index + 1) % children.length;
},
child: const Icon(Icons.ac_unit),
),
),
],
),
),
);
}
}
Issue
The AnimatedSize widget only works when going from a smaller size to a larger size. It skips (no animation) when going from a larger size to a smaller size. How can I fix this?
AnimatedContainer needs a width and height to animate towards, but I don't want to specify anything (the goal is intrinsic size) and the sizes are not available until the build method completes.
Solution:
A custom MultiChildRenderObjectWidget that uses a selectedIndex parameter to find a matching child from its children and then sets its size based on a SizeTween animation that begins from the size of the previously selected child (if any) and ends at the size of the newly selected child.
Output:
Considerations:
The following is an incomplete list of features that could be implemented. I leave them as an exercise to anyone who wishes to use the code.
This could be optimized by using a List<Widget> instead of the linked-list like child access provided by the ContainerRenderObjectMixin.
The child switching process could be done with a builder method, or a mix of previous constructed methods and builders for certain children.
Custom transitions can be parameterized through paint callbacks or objects that handle the painting of the widget.
Source: The source code is fairly well documented and can be read without further explanation.
The AnimatedSizeSwitcherParentData is not necessary for this current implementation. It will be useful for adding additional behavior, such as animating the position/offset of the currently selected child.
/// Every child of a [MultiChildRenderObject] has [ParentData].
/// Since this is a container-type of widget, we can used the predefined
/// [ContainerBoxParentData] which allows children to have access to their
/// previous and next sibling.
///
/// * See [ContainerBoxParentData].
/// * See [ContainerParentDataMixin].
class AnimatedSizeSwitcherParentData extends ContainerBoxParentData<RenderBox> {}
The IntrinsicSizeSwitcher and its related RenderIntrinsicSizeSwitcher provide the functionality.
/// A widget sizes itself according to the size of its selected child.
class IntrinsicSizeSwitcher extends MultiChildRenderObjectWidget {
IntrinsicSizeSwitcher({
Key? key,
// Since this is a [MultiChildRenderObjectWidget], we pass the children
// to the super constructor.
required AnimationController animationController,
required Curve curve,
required int selectedIndex,
required List<Widget> children,
}) : assert(selectedIndex >= 0),
_animationController = animationController,
_animation = CurvedAnimation(parent: animationController, curve: curve),
_selectedIndex = selectedIndex,
super(key: key, children: children);
final AnimationController _animationController;
final CurvedAnimation _animation;
final int _selectedIndex;
#override
RenderObject createRenderObject(BuildContext context) {
// The custom [RenderObject] that we define to create our custom widget.
return RenderIntrinsicSizeSwitcher(
animationController: _animationController,
animation: _animation,
selectedIndex: _selectedIndex,
);
}
#override
void updateRenderObject(
BuildContext context,
RenderIntrinsicSizeSwitcher renderObject,
) {
/// The [RenderObject] is updated when [selectedIndex] changes, so that
/// the old child can be replaced by the new child.
renderObject.selectedIndex = _selectedIndex;
}
}
class RenderIntrinsicSizeSwitcher extends RenderBox
with ContainerRenderObjectMixin<RenderBox, AnimatedSizeSwitcherParentData> {
RenderIntrinsicSizeSwitcher({
required AnimationController animationController,
required CurvedAnimation animation,
required int selectedIndex,
}) : _animationController = animationController,
_animation = animation,
_selectedIndex = selectedIndex {
_onLayout = _onFirstLayout;
/// Listen to animation changes so that the layout of this [RenderBox] can
/// be adjusted according to [animationController.value].
animationController.addListener(
() {
if (_lastValue != animationController.value) markNeedsLayout();
},
);
}
final AnimationController _animationController;
final CurvedAnimation _animation;
/// A [SizeTween] whose [begin] is the size of the previous child, and
/// whose [end] is the size of the next child.
final SizeTween _sizeTween = SizeTween();
/// Called by [performLayout]. This method is initialized to [_onFirstLayout]
/// so that the first child can be found by index and laid out.
///
/// After [_onFirstLayout] completes, additional layout passes have two
/// possibilities: the selected child changed or the current child is being
/// used to update the size of this [RenderBox].
late void Function() _onLayout;
int _selectedIndex;
int get selectedIndex => _selectedIndex;
set selectedIndex(int value) {
assert(selectedIndex >= 0);
/// Update [_selectedIndex] if it is different from [value], because this
/// method restarts [_animationController], which calls [markNeedsLayout].
if (_selectedIndex == value) return;
_selectedIndex = value;
/// No need to call [markNeedsLayout] because this [RenderBox] is a
/// listener of [_animationController].
///
/// The listener callback calls [markNeedsLayout] whenever [_lastValue]
/// differs from [_animationController.value] in order to use the new
/// animation value to update the layout.
_animationController.forward(from: .0);
}
#override
bool get sizedByParent => false;
Pair<RenderBox?, Size>? _oldSelection;
Pair<RenderBox?, Size>? _selection;
double? _lastValue;
#override
void setupParentData(covariant RenderObject child) {
if (child.parentData is AnimatedSizeSwitcherParentData) return;
child.parentData = AnimatedSizeSwitcherParentData();
}
Pair<RenderBox?, Size> _findSelectedChildAndDryLayout() {
var child = firstChild;
var index = 0;
/// Find the child matching [selectedIndex].
while (child != null) {
if (index == selectedIndex) {
return Pair(first: child, second: child.computeDryLayout(constraints));
}
var childParentData = child.parentData as AnimatedSizeSwitcherParentData;
child = childParentData.nextSibling;
++index;
}
/// No matching child was found.
return const Pair(first: null, second: Size.zero);
}
/// Find the child corresponding to [selectedIndex] and perform a wet layout.
Pair<RenderBox?, Size> _findSelectedChildAndWetLayout() {
var child = firstChild;
var index = 0;
while (child != null) {
if (index == selectedIndex) {
return Pair(
first: child,
second: wetLayoutSizeComputer(child, constraints),
);
}
var childParentData = child.parentData as AnimatedSizeSwitcherParentData;
child = childParentData.nextSibling;
++index;
}
return const Pair(first: null, second: Size.zero);
}
#override
void performLayout() {
_lastValue = _animationController.value;
_onLayout();
}
#override
Size computeDryLayout(BoxConstraints constraints) {
final child = _selection?.first;
if (child == null) return Size.zero;
return child.getDryLayout(constraints);
}
#override
double computeMaxIntrinsicWidth(double height) {
final child = _selection?.first;
if (child == null) return .0;
return child.getMaxIntrinsicWidth(height);
}
#override
double computeMaxIntrinsicHeight(double width) {
final child = _selection?.first;
if (child == null) return .0;
return child.getMaxIntrinsicHeight(width);
}
#override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
final child = _selection?.first;
/// If there is no current child, then there is nothing to hit test.
if (child == null) return false;
var childParentData = child.parentData as BoxParentData;
/// This [RenderBox] only displays one child at a time, so it only needs to
/// hit test the child being displayed.
final isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position - childParentData.offset);
return child.hitTest(result, position: transformed);
},
);
return isHit;
}
#override
void paint(PaintingContext context, Offset offset) {
final child = _selection?.first;
/// If there is no current child, then there is nothing to paint.
if (child == null) return;
/// Clip the painted area to [size], which is set to the value of
/// [animatedSize] during layout; this prevents having to resizing the old
/// child which can cause visual overflows.
context.pushClipRect(
true,
offset,
Offset.zero & size,
(PaintingContext context, Offset offset) {
/// The animation dependent alpha value is used to fade out the old
/// child and fade in the current child.
final alpha = (_animationController.value * 255).toInt();
final oldChild = _oldSelection?.first;
/// If there is an old child, paint it first, so that it is painted
/// below the current child.
///
/// We only want to paint the old child if the animation is running.
/// Once the animation is completed, the old child is fully transparent.
/// Subsequently, it is no longer necessary to paint it.
if (oldChild != null && _animationController.isAnimating) {
context.pushOpacity(
offset,
255 - alpha,
(PaintingContext context, Offset offset) {
final childOffset = (oldChild.parentData as BoxParentData).offset;
context.paintChild(oldChild, childOffset + offset);
},
);
}
context.pushOpacity(
offset,
alpha,
(PaintingContext context, Offset offset) {
final childOffset = (child.parentData as BoxParentData).offset;
context.paintChild(child, childOffset + offset);
},
);
},
);
}
#override
double? computeDistanceToActualBaseline(TextBaseline baseline) {
assert(!debugNeedsLayout);
final child = _selection?.first;
if (child == null) return null;
final result = child.getDistanceToActualBaseline(baseline);
if (result == null) return null;
return result + (child.parentData as BoxParentData).offset.dy;
}
/// Calculates the animated size of this [RenderBox].
void _performLayout(RenderBox child) {
final childParentData = child.parentData as AnimatedSizeSwitcherParentData;
final animatedSize = _sizeTween.evaluate(_animation)!;
final childSize = wetLayoutSizeComputer(child, constraints);
final oldChild = _oldSelection?.first;
if (oldChild != null) {
final oldChildSize = wetLayoutSizeComputer(oldChild, constraints);
final oldChildParentData = oldChild.parentData as BoxParentData;
/// Center the old child.
oldChildParentData.offset = Offset(
(animatedSize.width - oldChildSize.width) / 2.0,
(animatedSize.height - oldChildSize.height) / 2.0,
);
}
/// Center the new child.
childParentData.offset = Offset(
(animatedSize.width - childSize.width) / 2.0,
(animatedSize.height - childSize.height) / 2.0,
);
size = animatedSize;
}
void _onFirstLayout() {
/// The first layout pass must find the selected child by index and perform
/// a wet layout.
final selectedChild = _findSelectedChildAndWetLayout();
/// If [selection.first] is null, then the child list is empty, so there's
/// nothing to lay out, and [Size.zero] is returned.
if (selectedChild.first == null) {
size = Size.zero;
return;
}
/// Since this is the first pass, [_sizeTween.begin] and [_sizeTween.end]
/// will just be set to the size of the currently selected child.
_sizeTween.begin = _sizeTween.end = selectedChild.second;
/// The selection is updated to the child matching [selectedChildIndex].
_selection = selectedChild;
/// Subsequent layout passes are ensured to have a selected child.
_onLayout = _onUpdateLayout;
/// The size is set to the size of the selected child.
size = selectedChild.second;
}
void _onUpdateLayout() {
/// A dry layout is needed just to get the size of the selected child.
final newSelection = _findSelectedChildAndDryLayout();
/// After [_onFirstLayout], it is safe to assume that [_selection] is not
/// null and that the child that has gone through the wet layout process.
final child = _selection!.first!;
/// If the selection is the same, perform a layout pass.
if (child == newSelection.first) {
_performLayout(child);
} else {
/// The selected child index has changed.
/// The size animation will begin from the current child's size.
_sizeTween.begin = child.size;
/// The size animation will end at the new child's size.
_sizeTween.end = newSelection.second;
assert(newSelection.first != null);
_performLayout(newSelection.first!);
/// Update the old and new children selection state.
_oldSelection = _selection;
_selection = newSelection;
}
}
}
A convenience wrapper that makes using the IntrinsicSizeSwitcher widget simple:
/// Convenience wrapper around an [IntrinsicSizeSwitcher].
class AnimatedSizeSwitcher extends StatefulWidget {
const AnimatedSizeSwitcher({
Key? key,
this.duration = const Duration(milliseconds: 300),
this.curve = Curves.linear,
this.initialChildIndex = 0,
required this.children,
}) : super(key: key);
final Duration duration;
final Curve curve;
final int initialChildIndex;
final List<Widget> children;
#override
State<AnimatedSizeSwitcher> createState() => AnimatedSizeSwitcherState();
}
class AnimatedSizeSwitcherState extends State<AnimatedSizeSwitcher>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
late int _index;
#override
void initState() {
super.initState();
_animationController =
AnimationController(vsync: this, duration: widget.duration);
_index = widget.initialChildIndex;
/// The [_animationController] is started at 1.0 so that the first child is
/// visible, because [RenderIntrinsicSizeSwitcher.paint] uses
/// [_animationController.value] as an opacity factor.
SchedulerBinding.instance.addPostFrameCallback(
(timeStamp) => _animationController.forward(from: 1.0),
);
}
#override
void dispose() {
_animationController.dispose();
super.dispose();
}
#override
Widget build(BuildContext context) {
return IntrinsicSizeSwitcher(
animationController: _animationController,
curve: widget.curve,
selectedIndex: _index,
children: widget.children,
);
}
/// Rebuilds the widget by setting the selected index to whatever is next,
/// wrapping back to 0 when the end is reached.
void nextChild() {
setState(() => _index = (_index + 1) % widget.children.length);
}
}
Utility classes:
The Pair<T, E> holds two values.
class Pair<T, E> {
final T first;
final E second;
const Pair({required this.first, required this.second});
#override
String toString() => "first = $first, second = $second";
}
The SizeComputer<T> class is used to abstract a call to RenderBox.getDryLayout or RenderBox.layout.
abstract class SizeComputer<T> {
const SizeComputer();
Size call(T item, BoxConstraints constraints);
}
class DryLayoutSizeComputer extends SizeComputer<RenderBox> {
const DryLayoutSizeComputer();
#override
Size call(RenderBox item, BoxConstraints constraints) {
final size = item.getDryLayout(constraints);
assert(size.isFinite);
return size;
}
}
class WetLayoutSizeComputer extends SizeComputer<RenderBox> {
const WetLayoutSizeComputer();
#override
Size call(RenderBox item, BoxConstraints constraints) {
item.layout(constraints, parentUsesSize: true);
assert(item.hasSize);
return item.size;
}
}
const dryLayoutSizeComputer = DryLayoutSizeComputer();
const wetLayoutSizeComputer = WetLayoutSizeComputer();
The test application:
void main() => runApp(const MaterialApp(home: AnimatedSizeSwitcherApp()));
class AnimatedSizeSwitcherApp extends StatelessWidget {
const AnimatedSizeSwitcherApp({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
final key = GlobalKey<_AnimatedSizeTestState>();
return Scaffold(
body: GestureDetector(
onTap: () => key.currentState?.displayNextChild(),
child: Center(child: AnimatedSizeTest(key: key)),
),
);
}
}
class AnimatedSizeTest extends StatefulWidget {
const AnimatedSizeTest({Key? key}) : super(key: key);
#override
State<AnimatedSizeTest> createState() => _AnimatedSizeTestState();
}
class _AnimatedSizeTestState extends State<AnimatedSizeTest> {
static const _decoration = BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6.0)),
);
late final children = <Widget>[
Container(
decoration: _decoration.copyWith(color: Colors.red),
width: 110,
height: 100,
),
Container(
decoration: _decoration.copyWith(color: Colors.green),
width: 350,
height: 200,
),
Container(
decoration: _decoration.copyWith(color: Colors.blue),
width: 200,
height: 250,
),
Container(
decoration: _decoration.copyWith(color: Colors.amber),
width: 300,
height: 350,
),
];
final _switcherKey = GlobalKey<AnimatedSizeSwitcherState>();
#override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(4.0),
decoration: _decoration.copyWith(
boxShadow: [
const BoxShadow(
color: Colors.black38,
spreadRadius: 1.0,
blurRadius: 2.0,
),
],
),
child: AnimatedSizeSwitcher(
key: _switcherKey,
curve: Curves.easeOutQuad,
initialChildIndex: 0,
children: children,
),
);
}
void displayNextChild() {
final switcherState = _switcherKey.currentState;
if (switcherState == null) return;
switcherState.nextChild();
}
}
I am working on a simple ListView. I managed to update the list view with the correct data, see items which is <String>[].obs, when it got populated the data, I can see the list view is populated.
However, it seems after the list view items are built, they are not observing my selected change, which is 0.obs. From the debugging code, I can see the selected got updated, the title changes accordingly, but the list view items did not rebuild (and hence change color), and not reflecting if they are being selected.
Please help me to understand why selected change did not trigger list item rebuild, and how to fix. Thanks!
My home_controller.dart:
import 'package:get/get.dart';
class HomeController extends GetxController {
final loading = true.obs;
final items = <String>[].obs;
final selected = 0.obs;
final count = 0.obs;
#override
void onInit() {
fetchItems();
super.onInit();
}
Future<void> fetchItems() async {
loading.value = true;
Future.delayed(const Duration(seconds: 5), () {
final newItems = ['abc', 'def', 'ghij', 'klmnopq'];
items.assignAll(newItems);
loading.value = false;
});
}
void onHover(int index) {
selected.value = index;
print('onHover: $index');
}
}
And my home_view.dart:
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/home_controller.dart';
class HomeView extends GetView<HomeController> {
const HomeView({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title:
Obx(() => Text('HomeView: selected ${controller.selected.value}')),
centerTitle: true,
),
body: Obx(() => Center(
child: controller.loading.value
? const CircularProgressIndicator()
: ListView.builder(
itemCount: controller.items.length,
itemBuilder: (BuildContext context, int index) {
final color = controller.selected.value == index
? Colors.green
: Colors.grey;
return MouseRegion(
onHover: (event) {
controller.onHover(index);
},
onExit: ((event) {
final selected = controller.selected.value;
print(
'exiting: $index, current selected: ${selected}');
}),
child: ListTile(
leading:
Container(width: 40, height: 40, color: color),
title: Text(controller.items[index]),
),
);
},
),
)),
);
}
}
I believe wrapping your MouseRegion with another Obx would solve it for you. It being inside another builder will not make it able to be observed by the outer Obx
I have a listview that I want to enable shortcuts like Ctrl+c, Enter, etc this improves user experience.
The issue is after I click/tap on an item, it loses focus and the shortcut keys no longer work.
Is there a fix or a workaround for this?
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
void main() {
runApp(const MyApp());
}
class SomeIntent extends Intent {}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.orange,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return GetBuilder<Controller>(
init: Get.put(Controller()),
builder: (controller) {
final List<MyItemModel> myItemModelList = controller.myItemModelList;
return Scaffold(
appBar: AppBar(
title: RawKeyboardListener(
focusNode: FocusNode(),
onKey: (event) {
if (event.logicalKey.keyLabel == 'Arrow Down') {
FocusScope.of(context).nextFocus();
}
},
child: const TextField(
autofocus: true,
),
),
),
body: myItemModelList.isEmpty
? const Center(child: CircularProgressIndicator())
: ListView.builder(
itemBuilder: (context, index) {
final MyItemModel item = myItemModelList[index];
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.enter): SomeIntent(),
},
child: Actions(
actions: {
SomeIntent: CallbackAction<SomeIntent>(
// this will not launch if I manually focus on the item and press enter
onInvoke: (intent) => print(
'SomeIntent action was launched for item ${item.name}'),
)
},
child: InkWell(
focusColor: Colors.blue,
onTap: () {
print('clicked item $index');
controller.toggleIsSelected(item);
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
color: myItemModelList[index].isSelected
? Colors.green
: null,
height: 50,
child: ListTile(
title: Text(myItemModelList[index].name),
subtitle: Text(myItemModelList[index].detail),
),
),
),
),
),
);
},
itemCount: myItemModelList.length,
),
);
},
);
}
}
class Controller extends GetxController {
List<MyItemModel> myItemModelList = [];
#override
void onReady() {
myItemModelList = buildMyItemModelList(100);
update();
super.onReady();
}
List<MyItemModel> buildMyItemModelList(int count) {
return Iterable<MyItemModel>.generate(
count,
(index) {
return MyItemModel('$index - check debug console after pressing Enter.',
'$index - click me & press Enter... nothing happens\nfocus by pressing TAB/Arrow Keys and press Enter.');
},
).toList();
}
toggleIsSelected(MyItemModel item) {
for (var e in myItemModelList) {
if (e == item) {
e.isSelected = !e.isSelected;
}
}
update();
}
}
class MyItemModel {
final String name;
final String detail;
bool isSelected = false;
MyItemModel(this.name, this.detail);
}
Tested with Windows 10 and flutter 3.0.1
Using Get State manager.
In Flutter, a ListView or GridView containing a number of ListTile widgets, you may notice that the selection and the focus are separate. We also have the issue of tap() which ideally sets both the selection and the focus - but by default tap does nothing to affect focus or selection.
The the official demo of ListTile selected property https://api.flutter.dev/flutter/material/ListTile/selected.html
shows how we can manually implement a selected ListTile and get tap() to change the selected ListTile. But this does nothing for us in terms of synchronising focus.
Note: As that demo shows, tracking the selected ListTile needs to
be done manualy, by having e.g. a selectedIndex variable, then setting the
selected property of a ListTile to true if the index matches the
selectedIndex.
Here are a couple of solutions to the problem of to the syncronising focus, selected and tap in a listview.
Solution 1 (deprecated, not recommended):
The main problem is accessing focus behaviour - by default we have no access
to each ListTile's FocusNode.
UPDATE: Actually it turns out that there is a way to access a focusnode, and thus allocating our own focusnodes is not necessary - see Solution 2 below. You use the Focus widget with a child: Builder(builder: (BuildContext context) then you can access the focusnode with FocusScope.of(context).focusedChild. I am leaving this first solution here for study, but recommend solution 2 instead.
But by allocating a focus node for each ListTile item in the
ListView, we then do. You see, normally a ListTile item allocates its own focus
node, but that's bad for us because we want to access each focus node from
the outside. So we allocate the focus nodes ourselves and pass them to the
ListTile items as we build them, which means a ListTile no longer has to
allocate a FocusNode itself - note: this is not a hack - supplying custom
FocusNodes is supported in the ListTile API. We now get access to the
FocusNode object for each ListTile item, and
invoke its requestFocus()
method whenever selection changes.
we also listen in the FocusNode
objects for changes in focus, and update the selection whenever focus
changes.
The benefits of custom focus node which we supply ourselves to each ListTile
are:
We can access the focus node from outside the ListTile widget.
We can use the focus node to request focus.
We can listen to changes in focus.
BONUS: We can wire shortcuts directly into the focus node without the usual Flutter shortcut complexity.
This code synchronises selection, focus and tap behaviour, as well as supporting up and down arrow changing the selection.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// Enhancements to the official ListTile 'selection' demo
// https://api.flutter.dev/flutter/material/ListTile/selected.html to
// incorporate Andy's enhancements to sync tap, focus and selected.
// This version includes up/down arrow key support.
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
static const String _title =
'Synchronising ListTile selection, focus and tap - with up/down arrow key support';
#override
Widget build(BuildContext context) {
return MaterialApp(
title: _title,
home: Scaffold(
appBar: AppBar(title: const Text(_title)),
body: const MyStatefulWidget(),
),
);
}
}
class MyStatefulWidget extends StatefulWidget {
const MyStatefulWidget({super.key});
#override
State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}
class _MyStatefulWidgetState extends State<MyStatefulWidget> {
int _selectedIndex = 0;
late List _focusNodes; // our custom focus nodes
void changeSelected(int index) {
setState(() {
_selectedIndex = index;
});
}
void changeFocus(int index) {
_focusNodes[index].requestFocus(); // this works!
}
// initstate
#override
void initState() {
super.initState();
_focusNodes = List.generate(
10,
(index) => FocusNode(onKeyEvent: (node, event) {
print(
'focusnode detected: ${event.logicalKey.keyLabel} ${event.runtimeType} $index ');
// The focus change that happens when the user presses TAB,
// SHIFT+TAB, UP and DOWN arrow keys happens on KeyDownEvent (not
// on the KeyUpEvent), so we ignore the KeyDownEvent and let
// Flutter do the focus change. That way we don't need to worry
// about programming manual focus change ourselves, say, via
// methods on the focus nodes, which would be an unecessary
// duplication.
//
// Once the focus change has happened naturally, all we need to do
// is to change our selected state variable (which we are manually
// managing) to the new item position (where the focus is now) -
// we can do this in the KeyUpEvent. The index of the KeyUpEvent
// event will be item we just moved focus to (the KeyDownEvent
// supplies the old item index and luckily the corresponding
// KeyUpEvent supplies the new item index - where the focus has
// just moved to), so we simply set the selected state value to
// that index.
if (event.runtimeType == KeyUpEvent &&
(event.logicalKey == LogicalKeyboardKey.arrowUp ||
event.logicalKey == LogicalKeyboardKey.arrowDown ||
event.logicalKey == LogicalKeyboardKey.tab)) {
changeSelected(index);
}
return KeyEventResult.ignored;
}));
}
#override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: 10,
itemBuilder: (BuildContext context, int index) {
return ListTile(
focusNode: _focusNodes[
index], // allocate our custom focus node for each item
title: Text('Item $index'),
selected: index == _selectedIndex,
onTap: () {
changeSelected(index);
changeFocus(index);
},
);
},
);
}
}
Important Note: The above solution doesn't work when changing the number of items, because all the focusnodes are allocated during initState which only gets called once. For example if the number of items increases then there are not enough focusnodes to go around and the build step will crash.
The next solution (below) does not explicitly allocate focusnodes and is a more robust solution which supports rebuilding and adding and removing items dynamically.
Solution 2 (allows rebuilds, recommended)
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:developer' as developer;
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
static const String _title = 'Flutter selectable listview - solution 2';
#override
Widget build(BuildContext context) {
return const MaterialApp(
title: _title,
home: HomeWidget(),
);
}
}
// ╦ ╦┌─┐┌┬┐┌─┐╦ ╦┬┌┬┐┌─┐┌─┐┌┬┐
// ╠═╣│ ││││├┤ ║║║│ │││ ┬├┤ │
// ╩ ╩└─┘┴ ┴└─┘╚╩╝┴─┴┘└─┘└─┘ ┴
class HomeWidget extends StatefulWidget {
const HomeWidget({super.key});
#override
State<HomeWidget> createState() => _HomeWidgetState();
}
class _HomeWidgetState extends State<HomeWidget> {
// generate a list of 10 string items
List<String> _items = List<String>.generate(10, (int index) => 'Item $index');
String currentItem = '';
int currentIndex = 0;
int redrawTrigger = 0;
// clear items method inside setstate
void _clearItems() {
setState(() {
currentItem = '';
_items.clear();
});
}
// add items method inside setstate
void _rebuildItems() {
setState(() {
currentItem = '';
_items.clear();
_items.addAll(List<String>.generate(5, (int index) => 'Item $index'));
});
}
// set currentItem method inside setstate
void _setCurrentItem(String item) {
setState(() {
currentItem = item;
currentIndex = _items.indexOf(item);
});
}
// set currentindex method inside setstate
void _setCurrentIndex(int index) {
setState(() {
currentIndex = index;
if (index < 0 || index >= _items.length) {
currentItem = '';
} else {
currentItem = _items[index];
}
});
}
// delete current index method inside setstate
void _deleteCurrentIndex() {
// ensure that the index is valid
if (currentIndex >= 0 && currentIndex < _items.length) {
setState(() {
String removedValue = _items.removeAt(currentIndex);
if (removedValue.isNotEmpty) {
print('Item index $currentIndex deleted, which was $removedValue');
// calculate new focused index, if have deleted the last item
int newFocusedIndex = currentIndex;
if (newFocusedIndex >= _items.length) {
newFocusedIndex = _items.length - 1;
}
_setCurrentIndex(newFocusedIndex);
print('setting new newFocusedIndex to $newFocusedIndex');
} else {
print('Failed to remove $currentIndex');
}
});
} else {
print('Index $currentIndex is out of range');
}
}
#override
Widget build(BuildContext context) {
// print the current time
print('HomeView build at ${DateTime.now()} $_items');
return Scaffold(
body: Column(
children: [
// display currentItem
Text(currentItem),
Text(currentIndex.toString()),
ElevatedButton(
child: Text("Force Draw"),
onPressed: () => setState(() {
redrawTrigger = redrawTrigger + 1;
}),
),
ElevatedButton(
onPressed: () {
_setCurrentItem('Item 0');
redrawTrigger = redrawTrigger + 1;
},
child: const Text('Set to Item 0'),
),
ElevatedButton(
onPressed: () {
_setCurrentIndex(1);
redrawTrigger = redrawTrigger + 1;
},
child: const Text('Set to index 1'),
),
// button to clear items
ElevatedButton(
onPressed: _clearItems,
child: const Text('Clear Items'),
),
// button to add items
ElevatedButton(
onPressed: _rebuildItems,
child: const Text('Rebuild Items'),
),
// button to delete current item
ElevatedButton(
onPressed: _deleteCurrentIndex,
child: const Text('Delete Current Item'),
),
Expanded(
key: ValueKey('${_items.length} $redrawTrigger'),
child: ListView.builder(
itemBuilder: (BuildContext context, int index) {
// print(' building listview index $index');
return FocusableText(
_items[index],
autofocus: index == currentIndex,
updateCurrentItemParentCallback: _setCurrentItem,
deleteCurrentItemParentCallback: _deleteCurrentIndex,
);
},
itemCount: _items.length,
),
),
],
),
);
}
}
// ╔═╗┌─┐┌─┐┬ ┬┌─┐┌─┐┌┐ ┬ ┌─┐╔╦╗┌─┐─┐ ┬┌┬┐
// ╠╣ │ ││ │ │└─┐├─┤├┴┐│ ├┤ ║ ├┤ ┌┴┬┘ │
// ╚ └─┘└─┘└─┘└─┘┴ ┴└─┘┴─┘└─┘ ╩ └─┘┴ └─ ┴
class FocusableText extends StatelessWidget {
const FocusableText(
this.data, {
super.key,
required this.autofocus,
required this.updateCurrentItemParentCallback,
required this.deleteCurrentItemParentCallback,
});
/// The string to display as the text for this widget.
final String data;
/// Whether or not to focus this widget initially if nothing else is focused.
final bool autofocus;
final updateCurrentItemParentCallback;
final deleteCurrentItemParentCallback;
#override
Widget build(BuildContext context) {
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyX): () {
print('X pressed - attempting to delete $data');
deleteCurrentItemParentCallback();
},
},
child: Focus(
autofocus: autofocus,
onFocusChange: (value) {
print(
'$data onFocusChange ${FocusScope.of(context).focusedChild}: $value');
if (value) {
updateCurrentItemParentCallback(data);
}
},
child: Builder(builder: (BuildContext context) {
// The contents of this Builder are being made focusable. It is inside
// of a Builder because the builder provides the correct context
// variable for Focus.of() to be able to find the Focus widget that is
// the Builder's parent. Without the builder, the context variable used
// would be the one given the FocusableText build function, and that
// would start looking for a Focus widget ancestor of the FocusableText
// instead of finding the one inside of its build function.
developer.log('build $data', name: '${Focus.of(context)}');
return GestureDetector(
onTap: () {
Focus.of(context).requestFocus();
// don't call updateParentCallback('data') here, it will be called by onFocusChange
},
child: ListTile(
leading: Icon(Icons.map),
selectedColor: Colors.red,
selected: Focus.of(context).hasPrimaryFocus,
title: Text(data),
),
);
}),
),
);
}
}
Edit:
this works to regain focus, however, the focus starts again from the top widget and not from the widget that was clicked on. I hope this answer still helps
Edit 2 I found a solution, you'll have to create a separate FocusNode() for each element on your listview() and requestFocus() on that in your inkwell. Complete updated working example (use this one, not the one in the original answer):
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class SomeIntent extends Intent {}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.orange,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
MyHomePage({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
final myItemModelList = List.generate(10, (index) => Text('${index + 1}'));
final _focusNodes = List.generate(myItemModelList.length, (index) => FocusNode());
return Scaffold(
appBar: AppBar(),
body: myItemModelList.isEmpty
? const Center(child: CircularProgressIndicator())
: ListView.builder(
itemBuilder: (context, index) {
final item = myItemModelList[index];
return RawKeyboardListener(
focusNode: _focusNodes[index],
onKey: (event) {
if (event.logicalKey.keyLabel == 'Arrow Down') {
FocusScope.of(context).nextFocus();
}
},
child: Actions(
actions: {
SomeIntent: CallbackAction<SomeIntent>(
// this will not launch if I manually focus on the item and press enter
onInvoke: (intent) => print(
'SomeIntent action was launched for item ${item}'),
)
},
child: InkWell(
focusColor: Colors.blue,
onTap: () {
_focusNodes[index].requestFocus();
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
color: Colors.blue,
height: 50,
child: ListTile(
title: myItemModelList[index],
subtitle: myItemModelList[index]),
),
),
),
),
);
},
itemCount: myItemModelList.length,
),
);
}
}
Edit 3:
To also detect the up key you can try:
onKey: (event) {
if (event.isKeyPressed(LogicalKeyboardKey.arrowDown)) {
FocusScope.of(context).nextFocus();
} else if (event.isKeyPressed(LogicalKeyboardKey.arrowUp)) {
FocusScope.of(context).previousFocus();
}
},
Original answer (you should still read to understand the complete answer).
First of all, your adding RawKeyboardListener() within your appBar() don't do that, instead add it to the Scaffold().
Now, create a FocusNode() outside of your Build method:
class MyHomePage extends StatelessWidget {
MyHomePage({Key? key}) : super(key: key);
final _focusNode = FocusNode();
#override
Widget build(BuildContext context) {}
...
...
And assing the _focusNode to the RawKeyboardListener():
RawKeyboardListener(focusNode: _focusNode,
...
And here's the key point. Since you don't want to lose focus in the ListView(), in the onTap of your inkWell you'll have to request focus again:
InkWell(
focusColor: Colors.blue,
onTap: () {
_focusNode.requestFocus();
print('clicked item $index');
},
...
That's it.
Here is a complete working example based on your code. (I needed to modify some things, since I don't have all your data):
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const MyApp());
}
class SomeIntent extends Intent {}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.orange,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
MyHomePage({Key? key}) : super(key: key);
final _focusNode = FocusNode();
#override
Widget build(BuildContext context) {
final myItemModelList = List.generate(10, (index) => Text('${index + 1}'));
return Scaffold(
appBar: AppBar(),
body: myItemModelList.isEmpty
? const Center(child: CircularProgressIndicator())
: RawKeyboardListener(
focusNode: _focusNode,
onKey: (event) {
if (event.logicalKey.keyLabel == 'Arrow Down') {
FocusScope.of(context).nextFocus();
}
},
child: ListView.builder(
itemBuilder: (context, index) {
final item = myItemModelList[index];
return Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.enter): SomeIntent(),
},
child: Actions(
actions: {
SomeIntent: CallbackAction<SomeIntent>(
// this will not launch if I manually focus on the item and press enter
onInvoke: (intent) => print(
'SomeIntent action was launched for item ${item}'),
)
},
child: InkWell(
focusColor: Colors.blue,
onTap: () {
_focusNode.requestFocus();
print('clicked item $index');
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
color: Colors.blue,
height: 50,
child: ListTile(
title: myItemModelList[index],
subtitle: myItemModelList[index]),
),
),
),
),
);
},
itemCount: myItemModelList.length,
),
),
);
}
}
Demo:
I have a ListView.builder that is building items from a list of items, _cache. When I scroll to the end of the list _cache, a function is called to extend _cache by pulling more data from elsewhere. However, that data is limited, and eventually I run out of items to extend _cache with. So I want ListView.builder to stop building items there.
I understand that ListView.builder has a property called itemsCount, but I don't see how I can add that property only when I run out of items to add to _cache. How do I achieve what I want?
You can use ScrollController to load more data when you reached the end of the list item when you scroll.
This is the simple example I have done in my application.
class OtherUserListScreen extends StatefulWidget {
const OtherUserListScreen({Key? key}) : super(key: key);
#override
State<OtherUserListScreen> createState() => _OtherUserListScreenState();
}
class _OtherUserListScreenState extends State<OtherUserListScreen> {
// create a _scrollController variable
late ScrollController _scrollController;
#override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_handleScroll);
}
#override
Widget build(BuildContext context) {
final _usersProvider = Provider.of<UsersProvider>(context);
return Scaffold(
body: ListView.builder(
itemBuilder: (context, index) {
if (index == _usersProvider.users.length) {
return MoreLoadingIndicator(
isMoreLoading: _usersProvider.isMoreLoading,
);
}
final user = _usersProvider.users[index];
return FadeIn(
duration: const Duration(milliseconds: 400),
delay: Duration(milliseconds: index * 100),
child: GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PatientDetailScreen(
patientType: PatientType.OTHER,
user: user,
),
),
);
},
child: RecordUserDescription(
user: user,
showToolkit: false,
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
),
),
);
},
itemCount: _usersProvider.users.length,
),
);
}
_handleScroll() async {
if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
await context.read<UsersProvider>().loadMoreUsers(
context,
accountID: context.read<UserProvider>().user!.accountId!,
);
}
return;
}
}
For a flutter project I needed a tree structure in which when clicking on a node, not only the entries below it are displayed, but - as with the file manager in Windows - also the content: On a smartphone as a new screen and on a tablet as an additional area to the right of the list.
Unfortunately, the standard ExpansionTile does not have this capability.
Since I'm a newcomer to Flutter, I first looked at the source code and tried to understand the most important parts (I'm still at it ;-). Then I made the following changes:
A property 'AlignOpener' now decides whether the open/close icon is displayed on the left or right.
I added the properties 'onTap' and 'onLongPress' as callback. Once one of these properties is assigned in the calling widget, the '_isInAdvancedMode' property is set to true and an IconButton is inserted as leading or trailing.
Clicking this button will open/close the directory tree. A click on the remaining part is forwarded to the calling widget via callback.
Finally, I added a property 'indentListTile' to control the indent of each layer.
If none of the properties are assigned, the AvancedExpansionTile behaves like the standard ExpansionTile.
Since I'm a newbie, there's still a lot of uncertainty as to whether the code is really correct. As far as I can see, it works, but I would be happy if experienced developers among you could check the code and make suggestions for improvements if necessary.
Maybe the code solves a problem that others also had?
Her is the code:
master_list_item.dart
import 'package:meta/meta.dart';
class ItemMaster {
ItemMaster(
{this.id,
#required this.title,
this.subtitle,
this.children = const <ItemMaster>[]});
final int id;
final String title;
final String subtitle;
final List<ItemMaster> children;
}
master_list.dart
import 'master_list_item.dart';
class MasterList {
List<ItemMaster> get items {
return _items;
}
final List<ItemMaster> _items = <ItemMaster>[
ItemMaster(
title: 'Chapter Master List',
id: 1,
children: <ItemMaster>[
ItemMaster(
title: 'Scene A0',
id: 2,
children: <ItemMaster>[
ItemMaster(title: 'Item A0.1', id: 3),
ItemMaster(title: 'Item A0.2', id: 4),
ItemMaster(title: 'Item A0.3', id: 5),
],
),
ItemMaster(title: 'Scene A1', id: 6),
ItemMaster(title: 'Scene A2', id: 7),
],
),
ItemMaster(
title: 'Chapter B',
id: 8,
children: <ItemMaster>[
ItemMaster(title: 'Scene B0', id: 9),
ItemMaster(title: 'Scene B1', id: 10),
],
),
ItemMaster(
title: 'Chapter C',
id: 11,
children: <ItemMaster>[
ItemMaster(title: 'Scene C0', id: 12),
ItemMaster(title: 'Scene C1', id: 13),
ItemMaster(
title: 'Scene C2',
id: 14,
children: <ItemMaster>[
ItemMaster(title: 'Item C2.0', id: 15),
ItemMaster(title: 'Item C2.1', id: 16),
ItemMaster(title: 'Item C2.2', id: 17),
ItemMaster(title: 'Item C2.3', id: 18),
],
),
],
),
];
}
main.dart
import 'package:flutter/material.dart';
import 'advanced_expansion_tile.dart';
import 'master_list_item.dart';
import 'master_list.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: 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> {
List<ItemMaster> items;
#override
void initState() {
// TODO: implement initState
super.initState();
items = MasterList().items;
}
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Container(
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, int index) => MasterListEntry(items[index]),
)),
),
);
}
}
class MasterListEntry extends StatelessWidget {
const MasterListEntry(this.entry);
final ItemMaster entry;
Widget _buildTiles(ItemMaster root) {
if (root.children.isEmpty)
return ListTile(
title: Text(root.title),
onTap: () => print("onTap listTile"),
);
return AdvancedExpansionTile(
key: PageStorageKey<ItemMaster>(root),
title: Text(root.title),
children: root.children.map(_buildTiles).toList(),
onTap: () => print("onTap AdvancedExpansionTile"),
alignOpener: AlignOpener.Right,
indentListTile: 15.0,
// isInAdvancedMode: true,
);
}
#override
Widget build(BuildContext context) {
return _buildTiles(entry);
}
}
advanced_expansion_tile.dart (based on the source code from the Flutter team)
// Copyright 2017 The Chromium 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/widgets.dart';
import 'package:flutter/material.dart';
const Duration _kExpand = Duration(milliseconds: 200);
enum AlignOpener { Left, Right }
class AdvancedExpansionTile extends StatefulWidget {
const AdvancedExpansionTile({
Key key,
this.leading,
this.trailing,
#required this.title,
this.backgroundColor,
this.onExpansionChanged,
this.onTap,
this.onLongPress,
this.alignOpener,
this.indentListTile,
this.children = const <Widget>[],
this.initiallyExpanded = false,
}) : assert(initiallyExpanded != null),
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;
/// 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;
/// Specifies if the list tile is initially expanded (true) or collapsed (false, the default).
final bool initiallyExpanded;
/// A widget to display instead of a rotating arrow icon.
final Widget trailing;
/// A callback for onTap and onLongPress on the listTile
final GestureTapCallback onTap;
final GestureLongPressCallback onLongPress;
/// The side where the Open/Close-Icon/IconButton will be placed
final AlignOpener alignOpener;
/// indent of listTile (left)
final indentListTile;
#override
_AdvancedExpansionTileState createState() => _AdvancedExpansionTileState();
}
class _AdvancedExpansionTileState extends State<AdvancedExpansionTile>
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();
AnimationController _controller;
Animation<double> _iconTurns;
Animation<double> _heightFactor;
Animation<Color> _borderColor;
Animation<Color> _headerColor;
Animation<Color> _iconColor;
Animation<Color> _backgroundColor;
bool _isExpanded = false;
/// If set to true an IconButton will be created. This button will open/close the children
bool _isInAdvancedMode;
AlignOpener _alignOpener;
double _indentListTile;
#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) ?? widget.initiallyExpanded;
if (_isExpanded) _controller.value = 1.0;
/// OnTap or onLongPress are handled in the calling widget --> AdvancedExpansionTile is in Advanced Mode
if (widget.onTap != null || widget.onLongPress != null) {
_isInAdvancedMode = true;
} else {
_isInAdvancedMode = false;
}
/// fallback to standard behaviour if aligning isn't set
_alignOpener = widget.alignOpener ?? AlignOpener.Right;
/// if no indent is set the indent will be 0.0
_indentListTile = widget.indentListTile ?? 0.0;
}
#override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTap() {
setState(() {
_isExpanded = !_isExpanded;
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: () {
_isInAdvancedMode ? widget.onTap() : _handleTap();
}, // in AdvancedMode a callback will handle the gesture inside the calling widget
onLongPress: () {
_isInAdvancedMode ? widget.onLongPress() : _handleTap();
}, // in AdvancedMode a callback will handle the gesture inside the calling widget
leading: getLeading(),
title: widget.title,
trailing: getTrailing(),
),
),
ClipRect(
child: Padding(
padding: EdgeInsets.only(left: _indentListTile), // set the indent
child: Align(
heightFactor: _heightFactor.value,
child: child,
),
)),
],
),
);
}
#override
void didChangeDependencies() {
final ThemeData theme = Theme.of(context);
_borderColorTween..end = theme.dividerColor;
_headerColorTween
..begin = theme.textTheme.subhead.color
..end = theme.accentColor;
_iconColorTween
..begin = theme.unselectedWidgetColor
..end = theme.accentColor;
_backgroundColorTween..end = widget.backgroundColor;
super.didChangeDependencies();
}
#override
Widget build(BuildContext context) {
final bool closed = !_isExpanded && _controller.isDismissed;
return AnimatedBuilder(
animation: _controller.view,
builder: _buildChildren,
child: closed ? null : Column(children: widget.children),
);
}
/// A method to decide what will be shown in the leading part of the lisTile
getLeading() {
if (_alignOpener.toString() == AlignOpener.Left.toString() &&
_isInAdvancedMode == true) {
return buildIcon(); //IconButton will be created
} else if (_alignOpener.toString() == AlignOpener.Left.toString() &&
_isInAdvancedMode == false) {
return widget.leading ??
RotationTransition(
turns: _iconTurns,
child: const Icon(Icons.expand_more),
);
} else {
return widget.leading;
}
}
/// A method to decide what will be shown in the trailing part of the lisTile
getTrailing() {
if (_alignOpener.toString() == AlignOpener.Right.toString() &&
_isInAdvancedMode == true) {
return buildIcon(); //IconButton will be created
} else if (_alignOpener.toString() == AlignOpener.Right.toString() &&
_isInAdvancedMode == false) {
return widget.trailing ??
RotationTransition(
turns: _iconTurns,
child: const Icon(Icons.expand_more),
);
} else {
return widget.leading;
}
}
/// A widget to build the IconButton for the leading or trailing part of the listTile
Widget buildIcon() {
return Container(
child: RotationTransition(
turns: _iconTurns,
child: IconButton(
icon: Icon(Icons.expand_more),
onPressed: () {
_handleTap();
//toDo: open/close is working but not the animation
},
),
));
}
}