Recommended approach to lists with expandable elements in Flutter - flutter

I would like to create a list with expandable items/children based on data provided from the stream. Expanded items would include textual details and actions which the user can take.
One of the key requirements for this layout is that - at any time only one list item can be expanded.
From what I've managed to found out - with current out-of-the-box Flutter widgets this needs to be managed programmatically. Once user expands one tile to set values as not-expanded for all other tiles and rebuild UI.
I've managed to make this work in two ways:
Using ListView.builder & ExpansionTile widgets as children
Using ExpansionPanelList & ExpansionPanel widgets as children
Both approached were combined with Riverpod's StateProvider to keep state of the selected/expanded item and invoke rebuild once the state changes (user taps on a item/tile).
Both approaches have its trade-offs from UI perspective - e.g. ExpansionPanelList children padding is poor looking and cannot be modified. Then we have ExpansionTile approach which looses animation once "key" is set with unique PageStorageKey - a necessary config in order for ExpansionTile to save and restore its expanded state (basically programmatic expanding and collapsing of tiles does not work without this...).
What I don't understand is the performance impact of both approached and which one is better in this regard.
How does setting unique key: param and rebuilding ExpansionTile widgets compare ExpansionPanelList and generating list of its items (List<ExpansionPanel>) with each widget rebuild - in order to define which Panel is expanded and which is not.
Intuitively it seems that ListView.builder & ExpansionTile approach is better - but I am not sure what is the impact of setting "key:". Please help in understanding which way is better performance wise.
You can see bellow implementation for both approaches.
ListView.builder & ExpansionTile approach
return ListView.builder(
padding: EdgeInsets.all(10),
itemCount: data.length,
itemBuilder: ((context, index) {
return ExpansionTile(
key: PageStorageKey("${DateTime.now().millisecondsSinceEpoch}"),
initiallyExpanded: (selectedItem == data[index].id.toString()),
onExpansionChanged: ((value) =>
ref.read(selectedProductListItem.notifier).state = (value == true) ? data[index].id.toString() : null),
tilePadding: EdgeInsets.all(10),
leading: Icon(Icons.account_box),
title: Text(data[index].name, style: TextStyle(fontWeight: FontWeight.bold)),
childrenPadding: EdgeInsets.all(10),
expandedAlignment: Alignment.center,
children: [
Text(data[index].description),
SizedBox(height: 10),
ElevatedButton(onPressed: () {}, child: Text('Apply Now')),
],
);
}),
);
ExpansionPanelList & ExpansionPanel approach
final List<ExpansionPanel> listData = [];
for (var i = 0; i < data.length; i++) {
listData.add(
ExpansionPanel(
headerBuilder: (context, isExpanded) {
return Padding(padding: EdgeInsets.all(10), child: Text(data[i].name));
},
body: Padding(
padding: EdgeInsets.all(10),
child: Column(
children: [
Text(data[i].description),
SizedBox(height: 10),
ElevatedButton(onPressed: () {}, child: Text('Apply Now')),
],
),
),
isExpanded: (data[i].id == selectedItem),
canTapOnHeader: true,
),
);
}
return SingleChildScrollView(
padding: EdgeInsets.all(15),
child: ExpansionPanelList(
children: listData,
expansionCallback: (panelIndex, isExpanded) {
ref.read(selectedProductListItem.notifier).state = isExpanded ? null : data[panelIndex].id;
},
),
);

Related

Flutter: Is it possible to use single JSON file in bodies of more expansion panels?

In my app, I have one JSON file with some static data which I use to produce List of a widgets. Then, I have one screen with ExpansionPanelRadio showing few items and each of them, when expanded, (in their bodies) are containing that list of a widgets made using JSON file.
I am using provider and I am able to display that list of widgets inside body of expansionpanel but the lists are somehow repeating.
For example, I expand one panel and in its body list is displayed few times, like in a loop. I guess, that the problem is in provider but I don't understand it quite well.
I am pretty new to flutter and would appreciate if someone could explain me why is this happening and what approach should I use to solve it.
here is part of a code where i make those expansion panels with provided JSON in a body:
SingleChildScrollView(
child: ExpansionPanelList.radio(
elevation: 0,
children: MyList.map<ExpansionPanelRadio>((Item item) {
return ExpansionPanelRadio(
value: MyList.indexOf(item),
headerBuilder: (BuildContext context, bool isExpanded) {
return Row(
children: [
Padding(
padding: const EdgeInsets.all(10),
child: SvgPicture.asset(
"assets/images/some_image.svg"
),
),
Text('some label'),
],
);
},
body: ChangeNotifierProvider(
create: (context) => FeaturesProvider(),
builder: (context, child) {
Provider.of<FeaturesProvider>(context, listen: false)
.readFeatures();
return SingleChildScrollView(
child: Container(
child: Features(item.objectId.toString()),
),
);
}));
}).toList(),
))

Flutter: Body of my ExpansionPanelRadio is repeating many times. (when I have more than one item listed)

I am using ExpansionPanelRadio() to make an list of expandable items. Each item, when expanded, contains another list of let's say 'features'.
Now, when I have only one expandable element, its list of features looks fine. As soon as I add two or more items, their expanded lists of features grows somehow. (they repeat themselves).
And, as more times, as I am clicking (opening) expansion panels, the lists of items grow more and more. Does anybody have an Idea whats going on?
Both of the lists (expansion tiles and their expanded bodies must be scrollable).
My 'simplified' code looks like:
SingleChildScrollView(
child: ExpansionPanelList.radio(
elevation: 0,
children: MyList.map<ExpansionPanelRadio>((Item item) {
return ExpansionPanelRadio(
value: MyList.indexOf(item),
headerBuilder: (BuildContext context, bool isExpanded) {
return Row(
children: [
Padding(
padding: const EdgeInsets.all(10),
child: SvgPicture.asset(
"assets/images/some_image.svg"
),
),
Text('some label'),
],
);
},
body: ChangeNotifierProvider(
create: (context) => FeaturesProvider(),
builder: (context, child) {
Provider.of<FeaturesProvider>(context, listen: false)
.readFeatures();
return SingleChildScrollView(
child: Container(
child: Features(item.objectId.toString()),
),
);
}));
}).toList(),
))

Nested Listview not scrolling horizontally

I have a relatively complex Listview setup, where one Listview acts as a scrolling parent of a horizontal Listview, which acts as a parent to a third vertical Listview.
Here is an image of the general idea of the layout: https://cdn.discordapp.com/attachments/752981111738466467/895739370227773480/IMG_20211007_142732.jpg.
I'm having trouble getting the middle Listview, the horizontal Listview (marked 2 in the image), to scroll.
ConstrainedBox(
constraints: BoxConstraints(...),
child: ListView.builder( // This is Listview 1
controller: ScrollController(),
itemCount: itemCount,
itemBuilder: (context, worldIndex) {
return ConstrainedBox(
constraints: BoxConstraints(...),
child: ListView.builder( // This is Listview 2
controller: ScrollController(),
itemCount: ...,
scrollDirection: Axis
.horizontal, // grows horizontally to show the hierarchy of one card (the card selected in the hierarchy above it, or for the first level, the world) to its children
itemBuilder: (context, index) {
...
return ConstrainedBox(
constraints: BoxConstraints(...),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Container(
decoration: ...,
),
child: ListView.builder( // This is Listview 3
controller: ScrollController(),
itemCount: getNumChildren(index),
itemBuilder: (context, index2) {
return ...;
}),
)));
}));
}));
For brevity, I have removed several parts of my code and replaced them with elipses. I don't think it is likely that any of these areas could cause any issues with the Listview, but please let me know if they could.
Edit: The code I have already works properly, aside from the horizontal Listview not scrolling. My solution needs to have a dynamically expandable Listview at each level of the tree (1, 2, and 3, in the image, for example). The primary target platform for this is Windows.
I'm assuming the issue involves a problem with which Listview wins the GestureArena, but I am currently at a loss in how to work around that issue, providing a way to scroll all of available Listviews.
Thank you in advance, and I hope you have a great day!
As I could understand it. Maybe you are looking for something like this? I have commented the 3 types of scrolls you need. If anything is not as expected, please mention.
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
for (var i = 0; i < 5; i++)
// One big section in the largest col (1).
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 200.0,
child: ListView.builder(
// Horizontal item builder. (2)
shrinkWrap: true,
scrollDirection: Axis.horizontal,
itemCount: 10,
itemBuilder: (context, index) {
return SingleChildScrollView(
child: Column(
// Inside one vertical section. (3)
children: [
for (var i = 0; i < 5; i++)
ElevatedButton(
onPressed: () {}, child: Text("OK")),
],
),
);
},
),
),
],
),
],
),
),
),
),
);
}
The solution to this turned out to be that Flutter has intentionally turned off the ability to scroll horizontally using both scroll wheel and dragging for Windows (or at least, that's what seems to be the case according to what I was able to find in this issue).
To solve that, they have made a migration guide here.
Following the guide, it is extremely simple to override the drag devices used in order to re-enable the intended drag scrolling. This still does not enable scrolling horizontally with a mouse wheel, but for my code that is not an issue.
class CustomScrollBehavior extends MaterialScrollBehavior { // A custom scroll behavior allows setting whichever input device types that we want, and in this case, we want mouse and touch support.
#override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.mouse,
PointerDeviceKind.touch,
};
}
Then, in the actual Listview's code, we set a ScrollConfiguration with this new class as its behavior.
ScrollConfiguration(
behavior: CustomScrollBehavior(),
child: Listview.Builder(
controller: ScrollController(),
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
...
})

A dismissed Dismissible widget is still part of the tree

There seem to be many questions regarding this error but I'm yet to find an answer that will work in my situation.
The behaviour I'm seeing is that the Dismissible works, it fires and deletes the item, but for a moment it shows an error in the ListView. I'm guessing it's waiting for the tree to update based on the Stream<List>, which in turn is removing the record from Firebase.
My StreamBuilder...
return StreamBuilder<List<Person>>(
stream: personBloc.personsByUserId(userId),
builder: (context, snapshot) {
...
}
My ListView.builder()
ListView.builder(
itemCount: snapshot.data.length,
itemBuilder: (context, index) {
var person = snapshot.data[index];
return GestureDetector(
onTap: () {
Navigator.of(context)
.pushNamed('/reading/${person.personId}');
},
child: Dismissible(
key: Key(person.personId),
direction: DismissDirection.endToStart,
onDismissed: (direction) {
personBloc.deletePerson(person.personId);
},
background: Container(
child: Padding(
padding: const EdgeInsets.all(15.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Icon(
FontAwesomeIcons.trash,
color: Colors.white,
),
Text(
'Delete',
style: TextStyle(color: Colors.white),
textAlign: TextAlign.right,
),
],
),
),
color: Colors.red,
),
child: AppCard(
//Bunch of properties get set here
),
),
);
},
My deletePerson
deletePerson(String personId) async {
fetchPersonId(personId).then((value) {
if (value.imageUrl.isNotEmpty) {
removeImage();
}
db.deletePerson(personId);
});
}
I've tried changing the onDismissed to a confirmDismiss with no luck.
Any suggestions?
This happens when you dismiss with a Dismissible widget but haven't removed the item from the list being used by the ListView.builder. If your list was being stored locally, with latency not being an issue, you might never see this issue, but because you are using Firestore (I assume, based on your mention ofFirebase) then there is going to be some latency between asking the item to be removed from the DB and the list getting updated on the app. To avoid this issue, you can manage the local list separately from the list coming from the Stream. Updating the state as the stream changes, but allowing you to delete items locally from the local list and avoiding these kind of view errors.
I ended up making a couple of changes to my code to address this.
I added a BehaviourSubject in my bloc to monitor whether the delete was taking place or not. At the beginning of the firestore delete I set this to true and then added a .then to the delete to set it back to false.
I then added a Streambuilder around the ListView on the screen to monitor the value of this and show a CircularProgressIndicator when true.
It now looks like this:
Thanks for your help.

How to pass a listview.builder in flutter_slidable (Slidable)

This is just a simple todo list app. I want to implement a feature which lets me mark a task as complete when swiping right and delete the task when swiping left. This app uses a database and stores an added task in a list of type database (List<TodoItem> Itemlist= <TodoItem>[];).
Is it possible to use flutter_slidable?.
I have tried using Dismissible but i could only get it to delete the task.
List<TodoItem> Itemlist= <TodoItem>[];
SingleChildScrollView(
child: ListView.builder(
scrollDirection: Axis.vertical,
shrinkWrap: true,
itemCount: Itemlist.length,
itemBuilder: (BuildContext context, int index){
String item = Itemlist[index].toString();
return Dismissible(
key: Key(UniqueKey().toString()),
onDismissed: (direction){
setState((){
deleteItem(Itemlist[index].id, index);
}
);
},
background: Container(
child: Icon(Icons.delete),
color: Colors.red,
alignment: Alignment.centerLeft,
),
child: Itemlist[index],
);
}
),
)
I want to get the same result as below but am unsure on how to pass the listview.builder in Slidable.
Wrap your tile widget with Slidable
Setup "actionPane" and "actions" parameters for Slidable