Back to Blog Home
← all posts

Add sticky section headers to a ListView using Vue with NativeScript

February 13, 2024 — by Nandee Tjihero

Introduction

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

Ingredients

To achieve the sticky section headers effect, in addition to the ListView view, we will need the following views:

  • Label for displaying the headers
  • GridLayout for stacking the Label view on top of the first row of the ListView.

Adding the sticky headers

The following are the steps we will take to add the sticky headers to the ListView:

Prepare the data

We prepare the ListView data as follows:

  • Add ( if they are not already added ) the headers data to the 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",
];

Wrap the ListView and the headers Label in a GridLayout

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>
  • The Label view starts off with the text for the first header. We update the 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. :::

Listen to the ListView's native scroll events

The wrapper class

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 headers
  • a setCurrentHeader() 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.

Listen to the scroll event on iOS

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 original delegate

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

scrollViewDidScroll

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;

Getting the visible rows data on iOS

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
);

Listen to the scroll event on Android

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.OnScrollListenerconstructor 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.

Getting the visible rows data on Android

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

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];
        }
    }

Conclusion

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