Sometimes, when working with a ListView in NativeScript, you may want to add sticky section headers. In this article, I will show you how to add sticky section headers to your ListView using Vue with NativeScript.
👉 StackBlitz Demo
To achieve the sticky section headers effect, in addition to the ListView view, we will need the following views:
The following are the steps we will take to add the sticky headers to the ListView:
We prepare the ListView data as follows:
ListView
's items
array such that each header appear at the beginning of the corresponding section of the data in the ListView
. That is, the first item in the array will be the first section's header, the second section's header will be after the last item of the first section, the third header will be after the last item of the second section, and so on. The items
array including the headers data would look as follows:[
"Header 1",
"item 1",
"item 2",
"item 3",
"Header 2",
"item 4",
"item 5",
"item 6",
"Header 3",
"item 7",
"item 8",
"item 9",
];
Add the ListView
and the Label
view that will display the sticky headers to a GridLayout
ensuring the following:
The GridLayout has one row and one column to stack the header Label on top of the ListView.
The ListView
is the first child of the GridLayout
so that it is rendered first and the Label
view is the second so that it is rendered on top of the ListView
.
We set the verticalAlignment
property ( via the align-top
CSS class if you're using TailwindCSS) of the Label view to top
to make the Label view stick to the top of the GridLayout.
<GridLayout>
<ListView for="item in items" @itemTap="onItemTap">
<v-template>
<label :text="item" />
</v-template>
</ListView>
<label text="Header 1" class="align-top" />👈
</GridLayout>
text
property of the Label view with other headers values as the user scrolls through the ListView.::: tip Tip Make the Label view to be the same size as the the rows of the ListView that contain the headers. That way as the user scrolls through the ListView the Label will properly cover a header row when the header is the first in the list of visible items. :::
To listen to the ListView's native scroll events, we can write a JavaScript class that wraps the native code and acts as a bridge between the native code and the Vue component. For this article, I've named that wrapper class CurrentHeaderSetter
.
To share code across platforms both iOS and Android, the CurrentHeaderSetter
class extends the CurrentHeaderSetterCommon
class. The CurrentHeaderSetterCommon
class contains the following members:
_headerLabelView
property that holds a reference to the headers Label
view_headers
property that holds a list of the listview's headerssetCurrentHeader()
method is responsible for receiving the native visible rows data from the native platforms and use the data to check the headers list against the visible rows to determine the current header value for the Label view.To listen to the scroll event on iOS, we need to implement the scrollViewDidScroll
method of the UITableViewDelegate protocol. To do that using the class definition syntax, we create a class that is decorated with the @NativeClass() decorator, extends the NSObject class, implements the UITableViewDelegate
protocol, and has a static ObjCProtocols
property bound to an array containing the UITableViewDelegate
protocol as follows:
@NativeClass()
class UITableViewDelegateImpl extends NSObject implements UITableViewDelegate {
public static ObjCProtocols = [UITableViewDelegate];
//
}
For the complete implementation, see listview-scroll.ios.ts.
The UITableViewDelegate protocol has a number of methods, such as tableView:didSelectRowAtIndexPath: and tableView:heightForRowAtIndexPath: that enable certain functionalities. For example, the tableView:didSelectRowAtIndexPath:
method allows you to handle a user tapping a row, while tableView:heightForRowAtIndexPath:
enables row height customization. These methods and more are already implemented by NativeScript Core engineers.
So, instead of us writing the implementation code for all the those methods in our custom delegate from scratch, we just call the corresponding methods of the original delegate methods in our custom delegate class as follows:
{
// ...
tableViewDidSelectRowAtIndexPath(tableView, indexPath) {
this._originalDelegate.tableViewDidSelectRowAtIndexPath(
tableView,
indexPath
);
}
// ...
}
We get a reference to that original delegate via the _delegate
property of the ListView
object as follows:
this._originalDelegate = (<any>owner.get())._delegate;
// see line 14 in on-listview-scroll.ios.ts
Once we have added the necessary methods to our custom delegate, we implement the scrollViewDidScroll
method, which is what we're interested in, as follows:
public scrollViewDidScroll(scrollView: UIScrollView): void {
const items = (this._owner.deref() as ListView).items as string[];
const indexPathsForVisibleRows = (this._owner.deref() as ListView).ios.indexPathsForVisibleRows as NSArray<NSIndexPath>;
const visibleItems = Array.from({ length: indexPathsForVisibleRows.count }, (_, i) => i).map(i => {
const visItem = indexPathsForVisibleRows[i];
return items[visItem.row] as string;
});
(<CurrentHeaderSetter>this._headerSetter.deref()).setCurrentHeader(visibleItems)
}
After we have implemented the scrollViewDidScroll
method, we set the listview's _delegate
property to an instance of our custom delegate, thereby replacing the original delegate with our custom delegate as follows:
const del = new UITableViewDelegateImp(
new WeakRef(listView),
new WeakRef(this)
);
(listView as any)._delegate = del;
To get the data for the visible rows, we first get the array containing the NSIndexPath objects from the UITableView
object via the indexPathsForVisibleRows
property as follows:
const indexPathsForVisibleRows = this._owner.get().ios.indexPathsForVisibleRows;
We then use the value of the indexPathsForVisibleRows
variable and the items
array, the full listview's data, to create a JavaScript array with only the visible rows data.
We obtain the full raw data of the ListView as follows:
const items = (this._owner.deref() as ListView).items as string[];
We filter the data for the visible rows and pass it to the setCurrentHeader()
method of the CurrentHeaderSetter
object as follows:
const visibleItems = Array.from(
{ length: indexPathsForVisibleRows.count },
(_, i) => i
).map((i) => {
const visItem = indexPathsForVisibleRows[i];
return items[visItem.row] as string;
});
(<CurrentHeaderSetter>this._headerSetter.deref()).setCurrentHeader(
visibleItems
);
To listen to the scroll event on Android, we call the setOnScrollListener
method of the ListView object. The setOnScrollListener
method takes an instance of the android.widget.AbsListView.OnScrollListener
constructor function which , on NativeScript, wraps the AbsListView.OnScrollListener interface. We create an instance of the android.widget.AbsListView.OnScrollListener
constructor function with the interface's implementation object as follows:
listViewAndroid.setOnScrollListener(
new android.widget.AbsListView.OnScrollListener({
onScrollStateChanged: (view, scrollState) => {
// ...
},
onScroll: (view, firstVisibleItem, visibleItemCount, totalItemCount) => {
// ...
},
})
);
::: tip Note For a complete implementation, see listview-scroll.android.ts. :::
In the onScroll
method implementation is where we call the setCurrentHeader()
method of the CurrentHeaderSetter
object to pass the data to the JavaScript world.
To get the visible rows data on Android, in the onScroll
method ,we use the native getChildCount()
and getChildAt(i)
methods of the AbsListView class to create a JavaScript array of the visible rows as follows:
const visibleItems = Array.from(
{ length: view.getChildCount() },
(_, i) => i
).map((i) => {
const child = view.getChildAt(i) as org.nativescript.widgets.GridLayout; // row layout container
const textView = child.getChildAt(0) as android.widget.TextView; // Label
return textView.getText();
});
The getChildCount()
method returns the number of visible rows, and the getChildAt(i)
method returns the row at the specified index. In this case view.getChildAt(i)
returns the row layout container which is a GridLayout
view.
We then get (child.getChildAt(0)
) the Label
view from the GridLayout
view and extract the text of the Label
view. We then return the text of the Label
view as the value of the map
callback function. The map
function returns an array of the visible rows data.
We finally pass the visible rows data to the setCurrentHeader()
method of the CurrentHeaderSetter
object as follows:
this.setCurrentHeader(visibleItems);
The setCurrentHeader()
method iterates through the list of headers and , for upward scrolling, checks if the list of visible items contains a header. If it does and the header is at the top of the list, we set the text
property of the headers Label
view to that iteration's header value as follows:
if (visibleRows.includes(header) && visibleRows.indexOf(header) == 0) {
// Upward scroll
this._headerLabelView.text = header;
}
Otherwise, for downward scrolling, if a header value of the iteration is in the list of visible items and it is not at the top of the list, we set the text
property of the headers Label
view to the header value of the iteration before it as follows:
else { // Downward scroll
if (visibleRows.includes(header) && visibleRows.indexOf(header) !== 0) {
this._headerLabelView.text = this._headers[index - 1];
}
}
We have added sticky section headers to a ListView using Vue with NativeScript. You can find the complete code in this StackBlitz.
This StackBlitz is also slightly modified to match Vue colors for example: https://stackblitz.com/edit/nativescript-vue-nativescript-vue-ck4ywz?file=src%2Fcomponents%2FStickyHeadersListview.vue