返回博客主页
← 所有文章

在 NativeScript 中使用可过滤列表选择器

2017 年 8 月 8 日 — 作者 Dave Coffin

这是一篇来自 Dave Coffin 的客座博客文章。

当我从 Web 开发转向移动开发时,我遇到了很多之前没有认真思考过的 UI 问题。Web 开发比较宽容,移动用户对它的期望值较低。这可能不是一件好事,因为移动开发已经发展得相当成熟了,但是与移动开发相比,Web 开发对 UI 和 UX 的关注度要低得多。我认为这是因为移动体验设定了较高的标准,并且从一开始就让用户习惯了在移动设备上获得特定的体验。

无论如何,今天我发现自己正在解决一个 UI 问题,即如何处理用户从很长的列表中选择选项。在 Web 开发中,我曾经做过这样的事情。实际上,效果并不好,甚至可以说很糟糕,但毕竟是 Web,谁在乎呢?

bad implementation of list picker

在移动开发中甚至没有考虑这个问题的选项,因为前面提到的标准要求,很难为用户创建如此笨重的 UI。当然,在移动设备上制作糟糕的 UI 并非不可能,但我想寻找更简洁、更易于使用的解决方案。

这里的情景是,我将 Zendesk 集成到这个应用程序中,我们有一个相当强大的 Zendesk 操作,我需要为用户提供在提交工单时可以选择的选项。因此,我查询 Zendesk 以获取 ticket_fields,这些字段包含用户可以在提交工单时使用的字段,并且可以拥有数百个选项。所以,与其让用户一直滚动来查找他们想要的内容,我更希望让他们能够通过输入内容来过滤列表。以下是我实现的方法。

animated gif of filtered list

旁注:我在这里使用的是 Angular,但在常规 NativeScript 中使用 Observable Array 也可以轻松实现相同的效果。

<GridLayout style="background-color: rgba(0,0,0,0.8);" [visibility]="showingCreateTicket ? 'visible' : 'collapsed'">
    <StackLayout row="1" col="1" style="background-color: white; margin: 30 10 -10 10; border-radius: 10;">
        // heres where all my ticket fields live. example field to launch the filterable list picker:
        <StackLayout class="input_group">
            <Label class="label" text="Choose your product."></Label>
            <Label [class]="selectedProduct ? 'input hasData' : 'input'" (tap)="showProducts()" [text]="selectedProduct ? selectedProduct : 'Tap to choose...'"></Label>
        </StackLayout>
    </StackLayout>
    <ActivityIndicator [visibility]="loadingTicketFields ? 'visible' : 'collapsed'" [busy]="loadingTicketFields"></ActivityIndicator>
</GridLayout>

<GridLayout [visibility]="showingLongListPicker ? 'visible' : 'collapsed'" rows="*, auto, *" columns="30, *, 30">
    <GridLayout style="background-color: rgba(0,0,0,0.8);" rowSpan="3" colSpan="3" #longListPickerDimmer></GridLayout>
    <StackLayout row="1" col="1" #longListPickerContainer style="background-color: white; border-radius: 10;">
        <StackLayout>
            <TextField hint="Edit text to filter..." [(ngModel)]="filterItem" (ngModelChange)="filterLongList($event)" style="font-size: 14;"></TextField>
            <ListView [items]="itemsToShow" class="list-group" height="300" (itemTap)="chooseLongList($event)">
                <template let-item="item" let-odd="odd" let-even="even">
                  <StackLayout>
                    <Label [text]="item" class="list-group-item"></Label>

                  </StackLayout>
                </template>
            </ListView>
        </StackLayout>
        <GridLayout columns="auto, *, auto" style="background-color: #E0E0E0;" paddingTop="5" paddingBottom="5">
            <Button class="transBtn sm" text="Cancel" (tap)="closeLongListPicker()"></Button>
        </GridLayout>
    </StackLayout>    
</GridLayout>

我真的很喜欢简洁的 UI,它可以让用户始终保持在他们正在执行的任务的上下文中,所以我选择在一个小的叠层中显示 ListView。它只是通过将 GridLayout 叠加在一起,就位于我的其他布局之上。添加工单的主要视图(也是一个叠层)看起来像这样

add a ticket

你可能会注意到,那些看起来像是“点击选择...”的 TextField 实际上是 Label(上面代码段的第 6 行)。

<Label [class]="selectedProduct ? 'input hasData' : 'input'" (tap)="showProducts()" [text]="selectedProduct ? selectedProduct : 'Tap to choose...'"></Label>

使用输入类,我将它设置为看起来像一个 hint,而 hasData 类看起来像一个包含文本的 TextField。但是用户无法编辑它,值是由可过滤选择器提供的。

下面的 GridLayout 被设置为具有 3 行 3 列,基本上在中间创建一个框。每侧都有 30 个边距(columns="30, *, 30"),第二行第二列中有一个用白色背景样式化的 StackLayout,其中包含我们的选择器。#longListPickerDimmer 只是一种半透明的黑色背景,位于其他视图之上。

filter by country

以下是实现的关键代码:

import { Component, ViewChild, ElementRef, EventEmitter, Output } from "@angular/core";
import { AnimationCurve } from "ui/enums";

@Component({
    selector: "createticket",
    moduleId: module.id,
    templateUrl: "./createticket.component.html",
    styleUrls: ["./createticket.component.css"],
})
export class CreateTicketComponent {

    constructor(private supportService: SupportService) {}

    public showingCreateTicket: any = false;
    public loadingTicketFields: boolean = false;

    public showingLongListPicker: any = false;
    public unfilteredItemsToShow = [];
    public itemsToShow = [];

    public selectedProduct = '';
    public productMap = {};
    public listProducts = [];

    public filterItem: string;

    @Output() outputEvent: EventEmitter<any> = new EventEmitter();
    @ViewChild("longListPickerContainer") longListPickerContainer: ElementRef;
    @ViewChild("longListPickerDimmer") longListPickerDimmer: ElementRef;

    show() {
        this.loadingTicketFields = true;
        this.supportService.getTicketFields().subscribe(result => {
            console.dir(result);
            result.ticket_fields.forEach(field => {
                if (field.title == 'Product') {
                    field.custom_field_options.forEach(prod => {
                        let prodParts = prod.name.split('::');
                        this.productMap[prodParts[prodParts.length-1]] = prod;
                        this.listProducts.push(prodParts[prodParts.length-1])
                    })
                    this.listProducts.sort();
                }
            })
            this.loadingTicketFields = false;
        })
        this.showingCreateTicket = true
    }

    showProducts() {
        this.animateLongListPicker('products');
        this.itemsToShow = this.listProducts;
        this.unfilteredItemsToShow = this.listProducts;
    }

    filterLongList() {
        this.itemsToShow = this.unfilteredItemsToShow.filter(item => {
            return item.toLowerCase().indexOf(this.filterItem.toLowerCase()) !== -1;
        });
    }

    animateLongListPicker(type) {
        this.showingLongListPicker = type;
        this.longListPickerDimmer.nativeElement.opacity = 0;
        this.longListPickerDimmer.nativeElement.animate({
            opacity: 1,
            duration: 200
        })
        this.longListPickerContainer.nativeElement.opacity = 1;
        this.longListPickerContainer.nativeElement.scaleX = .7;
        this.longListPickerContainer.nativeElement.scaleY = .7;
        this.longListPickerContainer.nativeElement.animate({
            opacity: 1,
            scale: {x: 1, y: 1},
            duration: 400,
            curve: AnimationCurve.cubicBezier(0.1, 0.1, 0.1, 1)
        })
    }

    chooseLongList(event) {
        this.filterItem = '';
        if (this.showingLongListPicker == 'products') {
            this.selectedProduct = this.itemsToShow[event.index];
        }
        this.closeLongListPicker();
    }

    closeLongListPicker() {
        this.longListPickerDimmer.nativeElement.animate({
            opacity: 0,
            duration: 200
        })
        this.longListPickerContainer.nativeElement.animate({
            opacity: 0,
            scale: {x: .7, y: .7},
            duration: 300,
            curve: AnimationCurve.cubicBezier(0.1, 0.1, 0.1, 1)
        }).then(() => {
            this.showingLongListPicker = false;
        })
    }

    doneCreateTicket() {
        this.closeCreateTicket();
        this.outputEvent.emit('create ticket finished');
    }

    closeCreateTicket() {
        this.showingCreateTicket = false;
    }
}

该文件已被截断,只显示了为从 Zendesk 调用返回的“产品”工单字段设置可过滤列表选择器。让我们逐步了解一下这个文件...

show()

show() 函数显示创建工单的 UI。它调用我支持服务中的一个方法从 Zendesk 获取工单字段,然后处理结果。Zendesk 以奇特的字符串形式返回这些字段的选项,这些字符串旨在对选项进行分类,并用 :: 分隔类别。字符串的末尾是实际的产品名称,所以我获取产品名称(let prodParts = prod.name.split('::'); 然后 prodParts[prodParts.length-1]),并将其添加到产品数组中。

showProducts()

当用户点击应显示所选产品名称的 Label 时,会调用 showProducts() 函数。这会将数组 itemsToShow 设置为我们在 show() 中构建的产品列表。itemsToShow 是为选择器中的 ListView 提供数据的数组。

filterItem

filterItem 被设置为用于过滤列表的 TextFieldngModel。因此,filterItem 将包含用户输入的文本,我们使用 TextField 中的 ngModelChange 来调用 filterLongList

filterLongList()

filterLongList 是用于过滤 ListView 中数据的函数。它非常简单,当模型更改时,它会将 itemsToShow 设置为完整数组的过滤版本。我们已经将 unfilteredItemsToShow 设置为包含所有选项的数组,因此我们可以使用它来进行过滤。然后,我们使用强大的 TypeScript 特性 filter 来构建一个新的数组。我们传递一个回调函数,该函数只返回包含 filterItem 内容的产品:return item.toLowerCase().indexOf(this.filterItem.toLowerCase()) !== -1;。请注意,我们首先将两者都转换为小写,我们不希望这种过滤机制区分大小写。

由于 itemsToShow 本质上是一个 Observable,因此 UI 会自动更新。

chooseLongList($event)

当用户缩小搜索范围时,他们只需点击一个 ListView 项目,就会调用 chooseLongList($event),该函数获取索引并将 this.selectedProduct(显示在 Label 中的属性)设置为 this.itemsToShow 中的产品名称。

你还会注意到,我正在对选择器进行动画进出,这很明显。

当我设置从 Zendesk 调用返回的数据时,我还创建了这些实体的映射,其键是我在 ListView 中显示的项目。这使得获取完整实体变得非常简单,因为我将在实际创建工单时需要它。我需要传递工单字段 ID,我不会在 ListView 中显示它。一旦我获得了用户选择的商品名称,我所需要做的就是 this.productMap[this.selectedProduct] 来获取完整的对象。

我希望在不久的将来将其制作成一个跨平台的 NativeScript 插件。NativeScript 绝对是最好的,我喜欢能够使用我的 TypeScript 和 JavaScript 知识来做这样的事情,并创建功能齐全的移动应用程序。如果你有任何问题,正在寻找自由职业的应用程序开发人员,或者只是想打个招呼,请通过 davecoffin.com/contact 或 Twitter @davecoffin 与我联系。