返回博客首页
← 所有文章

使用 Vue 和 NativeScript 为 ListView 添加粘性章节标题

2024年2月13日 — 作者:Nandee Tjihero

简介

有时,在 NativeScript 中使用 ListView 时,您可能希望添加粘性章节标题。在本文中,我将向您展示如何使用 Vue 和 NativeScript 为您的 ListView 添加粘性章节标题。

👉 StackBlitz 演示

要素

为了实现粘性章节标题效果,除了 ListView 视图外,我们还需要以下视图

  • Label 用于显示标题
  • GridLayout 用于将 Label 视图堆叠在 ListView 的第一行之上。

添加粘性标题

以下是我们将采取的步骤,以将粘性标题添加到 ListView 中

准备数据

我们如下准备 ListView 数据

  • 将(如果尚未添加)标题数据添加到 ListViewitems 数组中,以便每个标题都出现在 ListView 中相应数据部分的开头。也就是说,数组中的第一个项目将是第一节的标题,第二节的标题将在第一节的最后一项之后,第三个标题将在第二节的最后一项之后,依此类推。包含标题数据的 items 数组将如下所示
[
  "Header 1",
  "item 1",
  "item 2",
  "item 3",
  "Header 2",
  "item 4",
  "item 5",
  "item 6",
  "Header 3",
  "item 7",
  "item 8",
  "item 9",
];

将 ListView 和标题 Label 包裹在 GridLayout 中

ListView 和将显示粘性标题的 Label 视图添加到 GridLayout 中,确保以下几点

  • GridLayout 有一行一列,用于将标题 Label 堆叠在 ListView 之上。

  • ListViewGridLayout 的第一个子元素,以便首先渲染它,而 Label 视图是第二个,以便将其渲染在 ListView 之上。

  • 我们将 Label 视图的 verticalAlignment 属性(如果您使用 TailwindCSS,则通过 align-top CSS 类)设置为 top,以使 Label 视图粘贴到 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>
  • Label 视图以第一个标题的文本开始。当用户在 ListView 中滚动时,我们使用其他标题值更新 Label 视图的 text 属性。

::: tip 提示 使 Label 视图的大小与包含标题的 ListView 行的大小相同。这样,当用户在 ListView 中滚动时,当标题是可见项目列表中的第一个时,Label 将正确覆盖标题行。::

监听 ListView 的原生滚动事件

包装类

要监听 ListView 的原生滚动事件,我们可以编写一个 JavaScript 类来包装原生代码,并充当原生代码和 Vue 组件之间的桥梁。对于本文,我将该包装类命名为 CurrentHeaderSetter

为了在 iOS 和 Android 两个平台上共享代码,CurrentHeaderSetter 类扩展了 CurrentHeaderSetterCommon 类。CurrentHeaderSetterCommon 类包含以下成员

  • _headerLabelView 属性,它保存对标题 Label 视图的引用
  • _headers 属性,它保存列表视图标题的列表
  • setCurrentHeader() 方法负责接收来自原生平台的原生可见行数据,并使用该数据检查标题列表与可见行,以确定 Label 视图的当前标题值。

在 iOS 上监听滚动事件

要在 iOS 上监听滚动事件,我们需要实现 UITableViewDelegate 协议的 scrollViewDidScroll 方法。要使用类定义语法执行此操作,我们创建一个用 @NativeClass() 装饰器装饰的类,扩展 NSObject 类,实现 UITableViewDelegate 协议,并具有一个静态的 ObjCProtocols 属性,该属性绑定到包含 UITableViewDelegate 协议的数组,如下所示

@NativeClass()
class UITableViewDelegateImpl extends NSObject implements UITableViewDelegate {
  public static ObjCProtocols = [UITableViewDelegate];

  //
}

有关完整实现,请参阅 listview-scroll.ios.ts

原始委托

UITableViewDelegate 协议有很多方法,例如 tableView:didSelectRowAtIndexPath:tableView:heightForRowAtIndexPath:,它们可以启用某些功能。例如,tableView:didSelectRowAtIndexPath: 方法允许您处理用户点击行,而 tableView:heightForRowAtIndexPath: 允许自定义行高。这些方法以及更多方法已由 NativeScript 核心工程师实现。

因此,我们无需从头开始在自定义委托中编写所有这些方法的实现代码,而只需在我们的自定义委托类中调用原始委托方法的对应方法,如下所示

{
  // ...
  tableViewDidSelectRowAtIndexPath(tableView, indexPath) {
    this._originalDelegate.tableViewDidSelectRowAtIndexPath(
      tableView,
      indexPath
    );
  }
  // ...
}

我们通过 ListView 对象的 _delegate 属性获取对该原始委托的引用,如下所示

this._originalDelegate = (<any>owner.get())._delegate;
// see line 14 in on-listview-scroll.ios.ts

scrollViewDidScroll

在将必要的方法添加到自定义委托后,我们实现我们感兴趣的 scrollViewDidScroll 方法,如下所示

 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)

}

在实现 scrollViewDidScroll 方法后,我们将列表视图的 _delegate 属性设置为自定义委托的实例,从而用我们的自定义委托替换原始委托,如下所示

const del = new UITableViewDelegateImp(
  new WeakRef(listView),
  new WeakRef(this)
);
(listView as any)._delegate = del;

在 iOS 上获取可见行数据

要获取可见行的数据,我们首先通过 indexPathsForVisibleRows 属性从 UITableView 对象获取包含 NSIndexPath 对象的数组,如下所示

const indexPathsForVisibleRows = this._owner.get().ios.indexPathsForVisibleRows;

然后,我们使用 indexPathsForVisibleRows 变量的值和 items 数组(完整列表视图的数据)来创建一个仅包含可见行数据的 JavaScript 数组。

我们如下获取 ListView 的完整原始数据

const items = (this._owner.deref() as ListView).items as string[];

我们过滤可见行的数据并将其传递给 CurrentHeaderSetter 对象的 setCurrentHeader() 方法,如下所示

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

在 Android 上监听滚动事件

要在 Android 上监听滚动事件,我们调用 ListView 对象的 setOnScrollListener 方法。setOnScrollListener 方法接受 AbsListView.OnScrollListener 构造函数的一个实例,该构造函数在 NativeScript 中包装了 AbsListView.OnScrollListener 接口。我们使用接口的实现对象创建一个 android.widget.AbsListView.OnScrollListener 构造函数的实例,如下所示

listViewAndroid.setOnScrollListener(
  new android.widget.AbsListView.OnScrollListener({
    onScrollStateChanged: (view, scrollState) => {
      // ...
    },
    onScroll: (view, firstVisibleItem, visibleItemCount, totalItemCount) => {
      // ...
    },
  })
);

::: tip 注意 有关完整实现,请参阅 listview-scroll.android.ts。::

onScroll 方法实现中,我们调用 CurrentHeaderSetter 对象的 setCurrentHeader() 方法将数据传递到 JavaScript 世界。

在 Android 上获取可见行数据

要在 Android 上获取可见行数据,在 onScroll 方法中,我们使用 AbsListView 类的原生 getChildCount()getChildAt(i) 方法创建一个可见行的 JavaScript 数组,如下所示

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

getChildCount() 方法返回可见行的数量,getChildAt(i) 方法返回指定索引处的行。在本例中,view.getChildAt(i) 返回行布局容器,它是一个 GridLayout 视图。

然后,我们从 GridLayout 视图中获取 (child.getChildAt(0)) Label 视图并提取 Label 视图的文本。然后,我们将 Label 视图的文本作为 map 回调函数的值返回。map 函数返回一个可见行数据的数组。

最后,我们将可见行数据传递给 CurrentHeaderSetter 对象的 setCurrentHeader() 方法,如下所示

this.setCurrentHeader(visibleItems);

setCurrentHeader() 方法

setCurrentHeader() 方法遍历标题列表,对于向上滚动,检查可见项目列表是否包含标题。如果包含并且标题位于列表顶部,我们如下将标题 Label 视图的 text 属性设置为该迭代的标题值

if (visibleRows.includes(header) && visibleRows.indexOf(header) == 0) {
  // Upward scroll
  this._headerLabelView.text = header;
}

否则,对于向下滚动,如果迭代的标题值位于可见项目列表中并且它不在列表顶部,我们如下将标题 Label 视图的 text 属性设置为其前面迭代的标题值

 else { // Downward scroll
        if (visibleRows.includes(header) && visibleRows.indexOf(header) !== 0) {
            this._headerLabelView.text = this._headers[index - 1];
        }
    }

结论

我们已经使用 Vue 和 NativeScript 为 ListView 添加了粘性章节标题。您可以在 此 StackBlitz 中找到完整的代码。

此 StackBlitz 也经过了一些修改以匹配 Vue 颜色,例如:https://stackblitz.com/edit/nativescript-vue-nativescript-vue-ck4ywz?file=src%2Fcomponents%2FStickyHeadersListview.vue