Flutter Community
Published in

Flutter Community

Flutter : iOS styled listview

On November 2020 was released two gaming consoles, starting a war between Xbox fans and Playstation one. On both side, they compared technical points about their favorite gaming console.

Let the fight continue!

That will be our base subject to understand how can we make a nice iOS styled list view containing children grouped by sections. We will add a menu to let the user scroll to a specific section. All of that in Flutter of course !

Warning : console characteristics may be inaccurate, as I merged data from a lot a web sites.

Me, not wanting to hurt anyone with my lack of knowledge about gaming consoles.

What do we need ?

We will use two libraries:

  • cupertino_listview, a library that I made, providing iOS styled listview, which tends to mimics iOS UITableView component.
  • scroll_to_index, a library that let user scrolls to a specific index in your listview (or any other scrollable widget).

Even if scroll_to_index is not well documented, and not very easy to use it, hopefully the example in its README.md file is enough to understand the concept. But with some subtleties, explained later in this article …

Me, discovering the “subtleties”, the first time I used scroll_to_index.

Development preparation

I created a new flutter project, and add those two dependencies we describes above, in pubspec.yaml.

dependencies:
flutter:
sdk: flutter
# ensure that developers will be able to scroll to indexed widget
scroll_to_index: ^1.0.6

# Cupertino list view
cupertino_listview: ^1.0.4

Next, we will create a simple class storing console characteristics, as a multiton (I mean multiple singletons design pattern). That may not be the best solution, but its a convenient way to store data, and ensure that they will be properly processed.

class Console {
final String name;
final String date;
final String price;
final String cpu;
final String gpu;
final String storage;
final String memory;
final String discPlayer;

const Console._(
{this.name,
this.date,
this.price,
this.cpu,
this.gpu,
this.storage,
this.memory,
this.discPlayer});

static final ps5 = Console._(
name: 'PS5',
date: '12 nov. 2020',
price: '499,00 \$US',
cpu: '...',
gpu: '...',
storage: '...',
memory: '...',
discPlayer: 'Blu-ray/DVD',
);
static final ps5DigitalEdition = Console._(
...

No much to see here. I want to display one section for the price, another section for cpu, gpu, and so on. So each Console attribute refers to a section in the list. To process that easily, I created a Section class to merge all data into a list of Section.

class ConsoleAttribute {
final String console;
final String attribute;

ConsoleAttribute({this.console, this.attribute});
}

class Section {
final String name;
final List<ConsoleAttribute> attributes;

int get itemCount => attributes.length;

ConsoleAttribute operator [](int i) => attributes[i];

Section({this.name, this.attributes});
...

It will allow me to process the list of consoles, and create all required sections. In that case, I like to create a list accessor operator to access easily to a section’s attribute. So instead of sectionsList[sectionIndex].attribute[index], we will be able to get an attribute like this: sectionList[sectionIndex][index].

Into that Section class, I implemented a method to retrieve all data.

static List<Section> allData() {
Map<String, List<ConsoleAttribute>> data = {};
Console.all.forEach((console) {
console.characteristics.forEach((key, value) {
final values = data.containsKey(key) ? data[key] : <ConsoleAttribute>[];
values.add(ConsoleAttribute(console: console.name, attribute: value));
data[key] = values;
});
});
return data
.map((key, value) =>
MapEntry(key, Section(name: key, attributes: value)))
.values
.toList();
}

This method concatenate all console data having the same attribute. To do that, we defined a “characteristics” property to console that format data into a map. It will ensure that keys are the same between two Console instances. After that, we concatenate values having the same key. The result is finally mapped into a list of Section instances.

Development

At this stage, we are capable of generating data, already sorted into sections. Now comes the main part: display them into a vertical list, and create a quick access menu to jump from a section to another.

After a short scroll_to_index library investigation, I can understand that It uses Keys instances to locate the widget to display on top of the list. Several experiments showed that scroll_to_index cannot be used with a “dynamically” built list (as ListView.builder for example). Added to that, every widget in the list must be wrapped into an AutoScrollTag widget.

The library, when I tried to load my list of widgets “dynamically”.

First I define all needed attributes into my State : the list of sections to display, and the AutoScrollController instance that let us move to a specific index.

class _MyHomePageState extends State<MyHomePage> {
final _data = Section.allData();
AutoScrollController _scrollController;@override
void initState() {
super.initState();
_scrollController = AutoScrollController();
}

@override
void dispose() {
_scrollController.dispose();
super.dispose();
}

To being able to scroll to another section, we need to wrap each child into an AutoScrollTag widget.

Widget __wrap({Widget child, int index, bool disabled = false}) {
return AutoScrollTag(
index: index,
key: ValueKey(index),
controller: _scrollController,
child: child,
disabled: disabled,
);
}

With this __wrap method, it makes sections and item widgets more easy to build. I have done the same for separator and sections.

Widget _buildItem(BuildContext context, IndexPath index) {
final item = _data[index.section][index.child];
return __wrap(
index: index.absoluteIndex,
child: Container(
padding: const EdgeInsets.all(12.0),
constraints: BoxConstraints(minHeight: 50.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(child: Text(item.console), width: 120.0),
Expanded(child: Text(item.attribute)),
],
),
),
);
}

There is no point to show you how do I process the data to build section and item widgets, as this is not the main difficult part of the job. To have a clear understanding about the build method, I deported the widget list creation (sections, separators, items) part in _setupList method, storing all widgets into _children attribute.

@override
Widget build(BuildContext context) {
_setupList();
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: CupertinoListView(
controller: _scrollController,
children: _children,
floatingSectionBuilder: _buildSection,
),
);
}

I used _buildSection method for both building the section widget in the list, and the other one floating on top of the scrollable widget.

At this moment, I tested to ensure that there is no error in the code, and customize a little bit rows and sections display.

Test pass, pass, pass … “Excellent” !

Now, the final part: create a menu for automatically jumping to a specific section of the list. To do that, I have to keep the index (absolute index of the list of widgets) of each sections, stored into _sectionIndexes.

Widget _buildMenu() {
return PopupMenuButton(
child: Icon(Icons.menu),
onSelected: (index) {
print("Scroll to section index $index");
_scrollController.scrollToIndex(index,
preferPosition: AutoScrollPosition.begin);
},
itemBuilder: (context) {
List<PopupMenuEntry<int>> entries = [];
for (var i = 0; i < _data.length; i++) {
entries.add(PopupMenuItem(
child: Text(_data[i].name),
value: _sectionIndexes[i],
));
}
return entries;
},
);
}

Nothing complex here: I created a PopupMenuButton , and build its PopupMenuEntry list thanks to _data attribute, containing my list of sections. I linked each menu entry with the section index. Therefore, on entry selection, the only thing I have to do is to scroll.

But when I started testing my menu, a big problem appeared.

When I jumped to a section below, the scroll_to_index library throw an error.

The guilty line of code, in scroll_to_index library, was provoked by this assertion below :

final ScrollableState scrollableState = Scrollable.of(ctx);
assert(scrollableState != null);

When we ask to scroll, the library finds the closest “displayed” widget (having a render object), and animate to it. Then it repeats the operation until the target has been found.

This is a very short summary of the scroll_to_index internal mechanism

As we need to set a Key for each elements (section, items, separator widgets) in the list, I reused section widgets to display the floating section. It means that in CupertinoListView, there is a case where we have two section widgets having the same Key: one in the list, the other floating on top of the list.

It takes me a lot of time to understand that the problem were not located in scroll_to_index library but in my code!

There is several ways to avoid that problem. In my case, the only thing I do were to disable the AutoScrollTag for the floating section widget.

Widget _buildSection(
BuildContext context, SectionPath index, bool isFloating) {
final style = Theme.of(context).textTheme.headline6;
return __wrap(
index: index.absoluteIndex,
disabled: isFloating,

We can also implement conditional statement in the __wrap method, returning child “as-is” if disabled input parameter is true.

Conclusion

You can have here an overview of the final result.

iOS styled listview and jump feature at the same time :D

Visually, it may appears simple, but coding it in Flutter is far more complex. What I’m not showing you is the research part, trying several libraries, try to make them working together…

A study of around 2 full-time days, and 5 days for the development for cupertino_listview library.

Flutter pub package website contains more than 6 libraries telling you that they implements the iOS listview style. Actually, before creating my own library, the only one implement it concretely, and even that I encountered bugs in it. That’s why I decided to implement my own widget, and provide it to everyone as the cupertino_listview librarie.

As Always, you will be able to find the source code of this article here:

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store