有时在 NativeScript 中使用 ListView 时你可能想要添加粘性分区标题。在本文中我们看看如何用 Vue 和 NativeScript 来实现。
为实现固定分区标题,除了 ListView 视图外还需要以下视图:
Label用于显示标题GridLayout用于将Label视图堆叠在 ListView 的第一行之上。
以下是将粘性标题添加到 ListView 的步骤:
- 准备数据
- 将 ListView 和标题
Label包装在GridLayout中 - 监听 ListView 的原生滚动事件
准备数据
我们按如下方式准备 ListView 数据:添加(如果尚未添加)标题数据到 ListView 的 items 数组中,使每个标题出现在 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 之上。- ListView 是
GridLayout的第一个子元素,以便首先渲染,Label视图是第二个,以便在 ListView 之上渲染。 - 设置
Label视图的verticalAlignment属性(如果使用 TailwindCSS,可通过align-topCSS 类)为top,使Label视图粘贴到GridLayout的顶部。
xml
<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视图的文本属性。
让 Label 视图与包含标题的 ListView 行大小相同。这样当用户滚动 ListView 时,当标题是可见项目列表中的第一个时,Label 将正确覆盖标题行。
监听 ListView 的原生滚动事件
包装类
要监听 ListView 的原生滚动事件,我们可以编写一个 JavaScript 类CurrentHeaderSetter来包装原生代码,并充当原生代码和 Vue 组件之间的桥梁。为了在 iOS 和 Android 平台上共享代码,CurrentHeaderSetter 类扩展了 CurrentHeaderSetterCommon 类。CurrentHeaderSetterCommon 类包含以下成员:
_headerLabelView属性,保存对标题Label视图的引用_headers属性,保存 ListView 标题列表- 一个
setCurrentHeader()方法负责接收来自原生平台的可见行数据,并使用数据检查标题列表与可见行,确定Label视图的当前标题值。
监听 iOS 上的滚动事件
要监听 iOS 上的滚动事件,我们需要实现 UITableViewDelegate 协议的 scrollViewDidScroll 方法。为此我们将创建一个用 @NativeClass() 装饰器装饰的类,扩展 NSObject 类,实现 UITableViewDelegate 协议,并具有一个静态 ObjCProtocols 属性绑定到包含 UITableViewDelegate 协议的数组,如下所示:
typescript
@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 Core 工程师实现。因此,我们不需要从头开始编写所有这些方法的实现代码,而是只需在我们的自定义委托类中调用原始委托方法的相应方法,如下所示:
typescript
{
// ...
tableViewDidSelectRowAtIndexPath(tableView, indexPath) {
this._originalDelegate.tableViewDidSelectRowAtIndexPath(
tableView,
indexPath
);
}
// ...
}
我们通过 ListView 对象的 _delegate 属性获得对该原始委托的引用,如下所示:
typescript
this._originalDelegate = (<any>owner.get())._delegate;
// 参见 on-listview-scroll.ios.ts 第14行
scrollViewDidScroll
一旦我们在自定义委托中添加了必要的方法,我们就能实现我们感兴趣的 scrollViewDidScroll 方法,如下所示:
typescript
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 方法后,我们将 listview 的 _delegate 属性设置为我们的自定义委托实例,从而用我们的自定义委托替换原始委托,如下所示:
typescript
const del = new UITableViewDelegateImpl(
new WeakRef(listView),
new WeakRef(this)
);
(listView as any)._delegate = del;
在 iOS 上获取可见行数据
要获取可见行的数据,我们首先从 UITableView 对象通过 indexPathsForVisibleRows 属性获取包含 NSIndexPath 对象的数组,如下所示:
typescript
const indexPathsForVisibleRows = this._owner.get().ios.indexPathsForVisibleRows;
然后我们使用 indexPathsForVisibleRows 变量的值和 items 数组(完整的 ListView 数据)创建一个只包含可见行数据的 JavaScript 数组。
我们按如下方式获取 ListView 的完整原始数据:
typescript
const items = (this._owner.deref() as ListView).items as string[];
我们过滤可见行的数据并将其传递给 CurrentHeaderSetter 对象的 setCurrentHeader() 方法,如下所示:
typescript
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 方法接受一个 android.widget.AbsListView.OnScrollListener 构造函数的实例,该构造函数在 NativeScript 中包装了 AbsListView.OnScrollListener 接口。我们用接口的实现对象创建一个 android.widget.AbsListView.OnScrollListener 构造函数的实例,如下所示:
typescript
listViewAndroid.setOnScrollListener(
new android.widget.AbsListView.OnScrollListener({
onScrollStateChanged: (view, scrollState) => {
// ...
},
onScroll: (view, firstVisibleItem, visibleItemCount, totalItemCount) => {
// ...
},
})
);
注意 完整实现请参见 listview-scroll.android.ts。
在 onScroll 方法实现中,我们调用 CurrentHeaderSetter 对象的 setCurrentHeader() 方法将数据传递到 JavaScript 世界。
获取 Android 上的可见行数据
要在 Android 上获取可见行数据,在 onScroll 方法中我们使用 AbsListView 类的原生 getChildCount() 和 getChildAt(i) 方法创建可见行的 JavaScript 数组,如下所示:
typescript
const visibleItems = Array.from(
{ length: view.getChildCount() },
(_, i) => i
).map((i) => {
const child = view.getChildAt(i) as org.nativescript.widgets.GridLayout; // 行布局容器
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() 方法,如下所示:
typescript
this.setCurrentHeader(visibleItems);
setCurrentHeader() 方法
setCurrentHeader() 方法遍历标题列表,对于向上滚动,检查可见项目列表是否包含标题。如果是,并且标题位于列表顶部,我们将标题 Label 视图的文本属性设置为该迭代的标题值,如下所示:
typescript
if (visibleRows.includes(header) && visibleRows.indexOf(header) == 0) {
// 向上滚动
this._headerLabelView.text = header;
}
否则,对于向下滚动,如果迭代的标题值在可见项目列表中并且不在列表顶部,我们将标题 Label 视图的文本属性设置为前一个迭代的标题值,如下所示:
typescript
else { // 向下滚动
if (visibleRows.includes(header) && visibleRows.indexOf(header) !== 0) {
this._headerLabelView.text = this._headers[index - 1];
}
}