Flutter - How to quickly implement an contact list page (azlist) 📓

LinXunFeng
9 min readNov 19, 2023

--

Overview

I believe everyone is familiar with the contact list page function. To achieve this effect, you may directly use existing open source packages, such as: azlistview. These packages are excellent and can quickly help us complete function development, but there will be some minor problems, such as:

  • It relies on some other third-party packages (such as: scrollable_positioned_list), but the limited version may conflict with those in your own project.
  • Some refresh widgets may not be supported (such as: easy_refresh)
  • Customization of function and layout is limited (for example: Header and Footer cannot be customized, list items have no check function)

What’s even more terrible is that if one day the product colleague suddenly requires the display to be switched between ListView mode and GridView mode. 😭
Therefore, it is most reliable and highly flexible to stick to the ScrollView officially provided by Flutter. In this article, follow my pace and let’s quickly create a contact list.

Let’s take a look at the effect first:

Difficulty analysis

At a glance, the above effect shows that the difficulties involved are the following two:

  • Data positioning, anti-jumping
  • Letter list interaction

Data positioning

When sliding the letter list on the right to jump to the position of the data module corresponding to the letter, you need to consider the data length. If the amount of data is enough, the data content will naturally be displayed from the top. However, if the amount of data is not enough, scroll to the maximum offset of the ScrollView. To put it bluntly, it means to display the data at the bottom of the ScrollView and keep the page motionless. Here we need to consider how to avoid the jumping problem.

However, today our protagonist(flutter_scrollview_observer) has already helped us deal with this jumping problem, so this is no longer a difficulty. We can just adjust jumpTo in the code.

Letter list

When a finger touches the letter list view, the corresponding index and the corresponding offset of the letter item view need to be returned in real time, so that the cursor view can be suspended to the corresponding position.

For finger touch, you can use the official widget GestureDetector. This widget provides some touch callbacks, such as onVerticalDragUpdate. We can get the offset of the finger in the letter list view through the details parameter in the callback, we can get it directly through details.localPosition.dy.

Here comes the key point, how do we know the corresponding index at this time based on the finger offset obtained above? 🧐

To change our thinking, we can use flutter_scrollview_observer to get the first item currently displayed. If we do not intervene in the calculation, the first item will definitely always be A. Therefore, we also need to specify an offset to start calculation in real time, so that we can normally obtain our ideal data of the first item being displayed.

Next, let’s start practicing together.

Practical part

Contact data

Since our focus is on how to process the view display, we simply generate the data. I believe that how to process the real data will not be difficult for everyone.

/// Contact model
class AzListContactModel {
// Store the corresponding letters
final String section;
// All contact names under this letter
final List<String> names;

AzListContactModel({
required this.section,
required this.names,
});
}

/// Store all contact model
List<AzListContactModel> contactList = [];

/// Generate contact data
generateContactData() {
// Traverse and generate A-Z data in ASCII code
final a = const Utf8Codec().encode("A").first;
final z = const Utf8Codec().encode("Z").first;
int pointer = a;
while (pointer >= a && pointer <= z) {
final character = const Utf8Codec().decode(Uint8List.fromList([pointer]));
contactList.add(
AzListContactModel(
section: character,
// In order to test the situation where the amount of data is not enough, a maximum of 8 elements are set here.
names: List.generate(Random().nextInt(8), (index) {
return '$character-$index';
}),
),
);
pointer++;
}
}

Page Layout

// The controller corresponding to the ScrollView
ScrollController scrollController = ScrollController();

// The controller corresponding to the SliverViewObserver
// Here you need to pass in scrollController so that the jumpTo method of observerController can take effect.
// The internal jump function is implemented using ScrollController.
SliverObserverController observerController = SliverObserverController(controller: scrollController);

// BuildContext Map that stores the letter index and the corresponding sliver
Map<int, BuildContext> sliverContextMap = {};

The contact ScrollView is implemented using CustomScrollView. slivers stores a list of contacts corresponding to all letters SliverList.

Stack(
children: [
// Observe the ScrollView
SliverViewObserver(
controller: observerController,
sliverContexts: () {
// Return the BuildContext of all slivers corresponding to the letter module.
// Because we want to observe all lists, all are returned here.
return sliverContextMap.values.toList();
},
// Contact ScrollView
child: CustomScrollView(
...
controller: scrollController,
slivers: contactList.mapIndexed((i, e) {
return _buildSliver(index: i, model: e);
}).toList(),
),
),
// Floating cursor
_buildCursor(),
// Letter list view
Positioned(
top: 0,
bottom: 0,
right: 0,
child: _buildIndexBar(),
),
],
),

Build contact SliverList for each letter

Widget _buildSliver({
required int index,
required AzListContactModel model,
}) {
// If there is no data, return SliverToBoxAdapter
final names = model.names;
if (names.isEmpty) return const SliverToBoxAdapter();

// Create a SliverList (of course you can create a SliverGrid)
Widget resultWidget = SliverList(
delegate: SliverChildBuilderDelegate(
(context, itemIndex) {
// Store the BuildContext corresponding to SliverList
if (sliverContextMap[index] == null) {
sliverContextMap[index] = context;
}
// Return to the item view
return AzListItemView(name: names[itemIndex]);
},
childCount: names.length,
),
);
// Use the flutter_sticky_header package to implement letter floating view
resultWidget = SliverStickyHeader(
header: Container(
height: 44.0,
color: const Color.fromARGB(255, 243, 244, 246),
padding: const EdgeInsets.symmetric(horizontal: 16.0),
alignment: Alignment.centerLeft,
child: Text(
model.section,
style: const TextStyle(color: Colors.black54),
),
),
sliver: resultWidget,
);
return resultWidget;
}

Letter List View

The main code to build the letter list view is as follows:

/// The key of the parent view of the letter list view, 
/// used to obtain the offset of the letter view later.
final indexBarContainerKey = GlobalKey();

/// Get all letters from contact data
List<String> get symbols => contactList.map((e) => e.section).toList();

Widget _buildIndexBar() {
return Container(
key: indexBarContainerKey,
...
child: AzListIndexBar(
// The key of the parent view, just remember it, it will be used below
parentKey: indexBarContainerKey,
// letter data
symbols: symbols,
onSelectionUpdate: (index, cursorOffset) {
// The callback of touch start and touch sliding
...
},
onSelectionEnd: () {
// The callback of touch end and touch cancel
...
},
),
);
}

The above two callbacks are used to update the cursor view, so we won’t talk about them here.

Next, let’s talk about the key code of AzListIndexBar.

/// The controller required to observe ScrollView
ListObserverController observerController = ListObserverController();

/// Record the offset of the current finger
double observeOffset = 0;

// Overall layout of letter list view
@override
Widget build(BuildContext context) {
// Use ListViewObserver to observe ListView
Widget resultWidget = ListViewObserver(
// The observed ScrollView
child: _buildListView(),
controller: observerController,
// Dynamically returns the offset of the current finger,
// which is used to specify the offset to start calculation,
// so that the ideal first item can be obtained by observation.
dynamicLeadingOffset: () => observeOffset,
);
// Touch listening
resultWidget = GestureDetector(
onVerticalDragDown: _onGestureHandler,
onVerticalDragUpdate: _onGestureHandler,
onVerticalDragCancel: _onGestureEnd,
onVerticalDragEnd: _onGestureEnd,
child: resultWidget,
);
return resultWidget;
}

Let’s start simple. After releasing your finger, you need to hide the cursor, but this is not part of the letter list view, so you only need to notify the outside through a callback..

// Touch end/cancel
_onGestureEnd([_]) {
// Directly tell the outside that touch is over.
widget.onSelectionEnd?.call();
}

When the finger presses and slides, the title on the cursor needs to be displayed and updated, so on the letter list view side, some data can be provided to the outside.

// Handling start touch and touch slide
// This method is the core
_onGestureHandler(dynamic details) async {
// The type of details may be DragDownDetails or DragUpdateDetails
// These two types have no parent class, but they both have a localPosition
// of the same type, which stores the offset of the current finger on the
// list, so for convenience, the type is declared as dynamic here.
if (details is! DragUpdateDetails && details is! DragDownDetails) return;
observeOffset = details.localPosition.dy;

// Trigger an observation
// By using await you can get the observation results there
final result = await observerController.dispatchOnceObserve(
// In the ListViewObserver above, we did not implement the onObserve and
// onObserveAll callbacks
// By default, if the callback is not implemented, it will not be observed,
// so here is set to false for isDependObserveCallback, so that you can
// observe directly without relying on the implementation of the callback.
isDependObserveCallback: false,
);
final observeResult = result.observeResult;
// The observation result is null, indicating that the first item has not
// changed this time. For example: it was the letter A in the last
// observation, and it is still the letter A this time.
//
// By default, the observation results are compared internally. If you want
// to return the data directly without comparing each observation, you can
// set isForce to true in the dispatchOnceObserve method above.
if (observeResult == null) return;

// Get the data of the first item
final firstChildModel = observeResult.firstChild;
if (firstChildModel == null) return;
// The index of the first item
final firstChildIndex = firstChildModel.index;

// Take out the RenderObject corresponding to the letter view
final firstChildRenderObj = firstChildModel.renderObject;
// Calculate the offset of the center point of the current letter relative
// to the upper left corner of the parent view. We mainly take the y value
//
// ancestor: Pass in the RenderObject of the ancestor view as the reference
// coordinate system
final firstChildRenderObjOffset = firstChildRenderObj.localToGlobal(
Offset.zero,
ancestor: widget.parentKey.currentContext?.findRenderObject(),
);
// After calculation, the data is returned through the onSelectionUpdate
// callback.
final cursorOffset = Offset(
firstChildRenderObjOffset.dx,
firstChildRenderObjOffset.dy + firstChildModel.size.width * 0.5,
);
widget.onSelectionUpdate?.call(
firstChildIndex,
cursorOffset,
);
}

Update Cursor

Now let’s go back and look at the two touch callbacks

/// Cursor data model
class AzListCursorInfoModel {
/// Letter
final String title;

/// The offset of letter center point
final Offset offset;

AzListCursorInfoModel({
required this.title,
required this.offset,
});
}
// The data model of the cursor is stored here.
ValueNotifier<AzListCursorInfoModel?> cursorInfo = ValueNotifier(null);

Widget _buildIndexBar() {
return Container(
...
child: AzListIndexBar(
parentKey: indexBarContainerKey,
symbols: symbols,
onSelectionUpdate: (index, cursorOffset) {
// Update the cursor data to show the cursor
cursorInfo.value = AzListCursorInfoModel(
title: symbols[index],
offset: cursorOffset,
);
// Get the BuildContext of SliverList, the contact list view
// corresponding to the letter
final sliverContext = sliverContextMap[index];
if (sliverContext == null) return;
// Jump to the first item of the corresponding letter section
//
// The jumpTo method handles the jumping problem internally, just call
// it!
observerController.jumpTo(
index: 0,
sliverContext: sliverContext,
);
},
onSelectionEnd: () {
// Clear cursor data and hide cursor
cursorInfo.value = null;
},
),
);
}

The code to build the cursor view is as follows

Widget _buildCursor() {
// Implement partial refresh view based on changes in cursorInfo data
return ValueListenableBuilder<AzListCursorInfoModel?>(
valueListenable: cursorInfo,
builder: (
BuildContext context,
AzListCursorInfoModel? value,
Widget? child,
) {
Widget resultWidget = Container();
double top = 0;
double right = indexBarWidth + 8;
if (value == null) {
// No cursor data, hidden cursor
resultWidget = const SizedBox.shrink();
} else {
// There is cursor data, display AzListCursor view
double titleSize = 80;
// Based on the y value of the center point offset of the current
// letter view, subtract the height of the cursor view to get the top
// offset of the cursor.
top = value.offset.dy - titleSize * 0.5;
resultWidget = AzListCursor(size: titleSize, title: value.title);
}
resultWidget = Positioned(
top: top,
right: right,
child: resultWidget,
);
return resultWidget;
},
);
}

There is nothing important in the cursor view AzListCursor, so I won’t explain it. You can implement it arbitrarily.

Demo link: azlist_demo

--

--