这是一篇来自 Dave Coffin 的客座博客文章。
当我从 Web 开发转向移动开发时,我遇到了很多之前没有认真思考过的 UI 问题。Web 开发比较宽容,移动用户对它的期望值较低。这可能不是一件好事,因为移动开发已经发展得相当成熟了,但是与移动开发相比,Web 开发对 UI 和 UX 的关注度要低得多。我认为这是因为移动体验设定了较高的标准,并且从一开始就让用户习惯了在移动设备上获得特定的体验。
无论如何,今天我发现自己正在解决一个 UI 问题,即如何处理用户从很长的列表中选择选项。在 Web 开发中,我曾经做过这样的事情。实际上,效果并不好,甚至可以说很糟糕,但毕竟是 Web,谁在乎呢?
在移动开发中甚至没有考虑这个问题的选项,因为前面提到的标准要求,很难为用户创建如此笨重的 UI。当然,在移动设备上制作糟糕的 UI 并非不可能,但我想寻找更简洁、更易于使用的解决方案。
这里的情景是,我将 Zendesk 集成到这个应用程序中,我们有一个相当强大的 Zendesk 操作,我需要为用户提供在提交工单时可以选择的选项。因此,我查询 Zendesk 以获取 ticket_fields
,这些字段包含用户可以在提交工单时使用的字段,并且可以拥有数百个选项。所以,与其让用户一直滚动来查找他们想要的内容,我更希望让他们能够通过输入内容来过滤列表。以下是我实现的方法。
旁注:我在这里使用的是 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
叠加在一起,就位于我的其他布局之上。添加工单的主要视图(也是一个叠层)看起来像这样
你可能会注意到,那些看起来像是“点击选择...”的 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
只是一种半透明的黑色背景,位于其他视图之上。
以下是实现的关键代码:
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()
函数显示创建工单的 UI。它调用我支持服务中的一个方法从 Zendesk 获取工单字段,然后处理结果。Zendesk 以奇特的字符串形式返回这些字段的选项,这些字符串旨在对选项进行分类,并用 ::
分隔类别。字符串的末尾是实际的产品名称,所以我获取产品名称(let prodParts = prod.name.split('::');
然后 prodParts[prodParts.length-1]
),并将其添加到产品数组中。
当用户点击应显示所选产品名称的 Label
时,会调用 showProducts()
函数。这会将数组 itemsToShow
设置为我们在 show()
中构建的产品列表。itemsToShow
是为选择器中的 ListView
提供数据的数组。
filterItem
被设置为用于过滤列表的 TextField
的 ngModel
。因此,filterItem
将包含用户输入的文本,我们使用 TextField
中的 ngModelChange
来调用 filterLongList
。
filterLongList
是用于过滤 ListView
中数据的函数。它非常简单,当模型更改时,它会将 itemsToShow
设置为完整数组的过滤版本。我们已经将 unfilteredItemsToShow
设置为包含所有选项的数组,因此我们可以使用它来进行过滤。然后,我们使用强大的 TypeScript 特性 filter 来构建一个新的数组。我们传递一个回调函数,该函数只返回包含 filterItem
内容的产品:return item.toLowerCase().indexOf(this.filterItem.toLowerCase()) !== -1;
。请注意,我们首先将两者都转换为小写,我们不希望这种过滤机制区分大小写。
由于 itemsToShow
本质上是一个 Observable,因此 UI 会自动更新。
当用户缩小搜索范围时,他们只需点击一个 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 与我联系。