Vaadin 实践教程(二)
五、数据绑定
Vaadin 经常用于实现业务应用的表示层。这种应用处理通常被建模为一组域类的数据。例如,在一个电子商务应用中,您可能会发现诸如Item、Cart、Order、Customer、Payment等类似的类。表示层作为一种媒介来呈现来自域对象的数据,并将来自用户的数据捕获到域对象中,后端可以使用这些对象来运行任何业务逻辑。
数据从外部服务或用户界面到达应用。在最后一种情况下,输入组件中的值需要在业务对象的属性中设置。例如,TextField中客户姓名的值(在用户注册用例中)应该设置在Customer类的name属性中。正如您在前一章中了解到的,您可以使用getValue()方法来实现这一点。同样的事情发生在相反的方向。例如,为了允许用户更新他们的名字,您可以从数据库中读取存储的值,并使用setValue(String)方法将其显示在一个TextField中。
数据绑定是将 Java 属性中的值与输入组件中的值连接起来的过程,以及该过程中可能出现的所有错综复杂的情况。这包括验证输入和将数据从表示层支持的格式转换成后端服务所需的格式。
手动实现数据绑定
让我们将我们目前所学的一些概念应用到一个假想的销售点(POS)软件中,实现一个简单的视图来管理产品。这类软件的中心思想是产品。假设我们被指派实现一个 UI 来管理产品。UI 应该包括所有产品的列表,每个产品都有一个编辑和删除它的选项,以及一个创建新产品的按钮。
实现领域模型
让我们省略任何连接数据库的逻辑,使用一个Set将产品保存在内存中。你会从哪里开始?在我的例子中,我会从域模型开始,所以为了简单起见,这里有一个域类的实现来封装 POS 软件所需的数据:
public class Product {
private String name;
private boolean available = true;
private Manufacturer manufacturer;
public Product() {
}
... getters and setters ...
}
public class Manufacturer {
private String name;
private String phoneNumber;
private String email;
public Manufacturer() {
}
public Manufacturer(String name, String phoneNumber,
String email) {
this.name = name;
this.phoneNumber = phoneNumber;
this.email = email;
}
... getters and setters ...
}
实现视图
下一步是什么?说到 UI,我通常从视图级开始——用@Route注释的类。我们需要产品的静态Set,制造商的Set,以及展示产品的布局。静态集,因为我们希望确保所有用户使用相同的数据。为了简单起见,让我们在一个静态块中定义所有的制造商(这个版本的 POS 软件不包括制造商管理)。那么,下面呢?
public class ProductManagementView extends Composite<Component> {
private static Set<Product> products = new HashSet<>();
private static Set<Manufacturer> manufacturers =
new HashSet<>();
private VerticalLayout productsLayout = new VerticalLayout();
static {
manufacturers.add(new Manufacturer("Unix Beers", "555111",
"beer@example.com"));
manufacturers.add(new Manufacturer("Whisky Soft", "555222",
"whisky@example.com"));
manufacturers.add(new Manufacturer("Wines Java", "555333",
"wine@example.com"));
}
}
在我们开始编码之前,让我们停下来想一想。这门课我们需要哪些操作?我们需要
-
初始化内容
-
用户创建、编辑或删除产品时,随时更新产品列表
-
当用户单击给定产品中的“新建”按钮或“更新”按钮时显示表单
-
保存和删除产品
考虑到这一点,我们可以向ProductManagementView类添加以下方法:
@Override
protected Component initContent() {
}
private void updateList() {
}
private void showProductForm(Product product) {
}
private void delete(Product product) {
}
private void save(Product product) {
}
现在让我们开始一次编写一个方法。方法应该返回视图的布局。它还应该更新产品列表,以便它们在页面刷新时可见:
@Override
protected Component initContent() {
updateList();
return new VerticalLayout(
new Button("New product", VaadinIcon.PLUS.create(),
event -> showProductForm(new Product())),
productsLayout
);
}
这将显示一个创建新产品的按钮(注意我们如何调用相应的方法传递新产品)和带有产品列表的布局(productsLayout)。接下来是updateList()法。为了“刷新”产品列表,我们可以通过删除productsLayout组件中的所有组件来重建列表,并一次添加一个产品:
private void updateList() {
productsLayout.removeAll();
products.stream()
.map(product -> new Details(
product.getName() +
(product.isAvailable() ? "" : " (not available)"),
new HorizontalLayout(
new Button(VaadinIcon.PENCIL.create(),
event -> showProductForm(product)),
new Button(VaadinIcon.TRASH.create(),
event -> delete(product))
)
))
.forEach(productsLayout::add);
}
这段代码使用一个 Java 流来获取products集合中的每个产品,并将其“转换”成一个我们还没有涉及到的 UI 组件。惊喜!Details组件是一个可扩展的面板,用于显示和隐藏内容。在这个例子中,带有两个Button的HorizontalLayout是隐藏的,直到用户点击标题(见图 5-1 )。每个产品都被映射到一个带有产品和动作按钮信息的Details组件,然后被添加到productsLayout实例中。
图 5-1
Details组件。点击标题(啤酒)显示按钮
让我们继续展示表单。为此,我们将把表单的实际实现委托给一个单独的类(ProductForm),我们将很快开发这个类。这样做是有意义的,因为我们可能希望在应用的其他部分重用该表单。下面是showProduct(Product)方法的实现:
private void showProductForm(Product product) {
Dialog dialog = new Dialog();
dialog.setModal(true);
dialog.open();
dialog.add(new ProductForm(product, manufacturers, () -> {
dialog.close();
save(product);
}));
}
该方法首先创建并配置一个新的Dialog并添加下一个即将诞生的ProductForm类的实例。我实现这段代码的方式和我现在描述的完全一样。我通常倾向于在实现类本身之前编写使用类(该类的客户端)的代码。这样,我可以专注于我需要的那个类的 API。我认为 ProductForm 类的构造函数可以接受我希望在表单中显示的产品(新产品或现有产品)、可用制造商列表,以及当用户在表单中保存产品时执行我的代码的回调。因此,当用户完成表单时,我可以关闭对话框,并使用作为 lambda 表达式实现的回调来保存产品。在我们进入实际的ProductForm类之前,这里是save(Product)和delete(Product)方法的实现:
private void save(Product product) {
products.add(product);
updateList();
Notification.show("Product saved: " + product.getName());
}
private void delete(Product product) {
products.remove(product);
updateList();
Notification.show("Product deleted");
}
这里没有惊喜成分。这两个方法在静态Set中添加或删除产品,更新列表以反映 UI 中的变化,并显示一个通知,通知用户操作已成功完成。
实现表单
现在让我们在实现ProductForm类时讨论数据绑定。这个类连接(绑定)到一个Product实例(bean)。更具体地说,我们希望将Product的属性与表单中的输入字段连接起来。当我们创建一个ProductForm时,输入字段应该显示 bean 中属性的值。例如,我们需要一个TextField来引入或编辑Product类中的name属性。类似地,当TextField中的值改变时,我们需要更新name属性。
让我们从定义我们需要的输入组件和数据开始。我们将它们作为属性添加到类中,因为我们以后需要访问它们:
public class ProductForm extends Composite<Component> {
private final SerializableRunnable saveListener;
private Product product;
private TextField name = new TextField("Name");
private ComboBox<Manufacturer> manufacturer = new ComboBox<>();
private Checkbox available = new Checkbox("Available");
}
顾名思义,SerializableRunnable是Runnable的可序列化版本。这允许我们对这个类(比如我们在上一节开发的ProductManagementView类)的客户端执行回调,并给它们机会处理product实例中的数据。我们还有一个代表名称的TextField,一个代表制造商的ComboBox,以及一个代表产品可用性的Checkbox。
Tip
当你在 UI 类中保存对对象的引用时,使用Serializable类。例如,当您在 Apache Tomcat 等服务器中使用会话持久性来允许会话在重启后存储在硬盘中时,这是必需的。Vaadin 中包含的所有 UI 组件和其他助手类都实现了Serializable。
在上一节中,我们决定这个类应该在构造函数中接受一个Product实例(以SerializableRunnable的形式与侦听器一起)。为了实现从Product到输入字段方向的绑定,我们可以从构造函数中的Product bean 的相应 Java 属性中设置每个输入字段的值,如下所示:
public ProductForm(Product product,
Set<Manufacturer> manufacturers,
SerializableRunnable saveListener) {
this.product = product;
this.saveListener = saveListener;
manufacturer.setItems(manufacturers);
manufacturer.setItemLabelGenerator(Manufacturer::getName);
if (product.getName() != null) {
name.setValue(product.getName());
manufacturer.setValue(product.getManufacturer());
available.setValue(product.isAvailable());
}
}
构造函数中的前两行将值赋给类的实例字段,这样我们以后可以在其他方法中使用它们。然后,ComboBox被填充并配置为显示每个项目的制造商名称。因为输入字段中的setValue(T)方法会抛出一个NullPointerException,如果你传递了null,我们必须在调用它之前做一个空检查。通过将值从 bean 设置到输入字段,我们已经在那个方向上实现了数据绑定。
Note
当我谈到输入字段时,我指的是用于数据输入的 UI 组件,例如TextField和Checkbox。当我谈到字段(或 Java 字段,或实例字段)时,我指的是 Java 类中的成员变量。
准备好输入组件后,我们可以实现如下的表单布局:
@Override
protected Component initContent() {
return new VerticalLayout(
new H1("Product"),
name,
manufacturer,
available,
new Button("Save", VaadinIcon.CHECK.create(),
event -> saveClicked())
);
}
这将创建一个新的布局,带有一个标题(H1)、输入字段(name和available),以及一个允许用户调用保存操作的按钮。在saveClicked()方法中,我们可以实现相反方向的数据绑定——使用 bean 的 setters 和字段的getValue()方法将字段输入到Product:
private void saveClicked() {
product.setName(name.getValue());
product.setManufacturer(manufacturer.getValue());
product.setAvailable(available.getValue());
saveListener.run();
}
我们已经成功地实现了一个带有数据绑定的表单。结果如图 5-2 所示。
图 5-2
带有手动数据绑定的表单
活页夹助手类
尽管这种技术是可行的,但是在前面的部分中,数据绑定的实现存在一个问题。当您向Product类添加属性时,您必须记住在一个地方设置输入字段值(构造函数),在另一个地方设置 bean 属性值(saveClicked()方法)。这使得代码很难维护,尤其是当您向表单添加更多的输入字段时。
由于在数据源(如域对象)和输入字段之间同步值是业务应用中的一项常见任务,Vaadin 提供了简化这一过程的功能——Binder类。这个类不是一个 UI 组件,而是一个帮助器类,它根据一个可定制的配置来保持值的同步,这个配置告诉我们哪个输入字段被绑定到哪个 bean 属性。
以编程方式定义绑定
让我们修改ProductForm类,让 Vaadin 为我们进行数据绑定。下面是该表单的完整实现:
public class ProductForm extends Composite<Component> {
private final SerializableRunnable saveListener;
private TextField name = new TextField("Name");
private ComboBox<Manufacturer> manufacturer = new ComboBox<>();
private Checkbox available = new Checkbox("Available");
public ProductForm(Product product,
Set<Manufacturer> manufacturers,
SerializableRunnable saveListener) {
this.saveListener = saveListener;
manufacturer.setItems(manufacturers);
manufacturerComboBox.setItemLabelGenerator(
Manufacturer::getName);
Binder<Product> binder = new Binder<>();
binder.bind(name, Product::getName, Product::setName);
binder.bind(manufacturer,
Product::getManufacturer, Product::setManufacturer);
binder.bind(available, Product::isAvailable,
Product::setAvailable);
binder.setBean(product);
}
@Override
protected Component initContent() {
return new VerticalLayout(
new H1("Product"),
name,
manufacturer,
available,
new Button("Save", VaadinIcon.CHECK.create(),
event -> saveListener.run())
);
}
}
在Binder类的帮助下,我们能够大幅减少代码量。我们不再需要保存对Product实例的引用,因为所有的数据绑定逻辑都发生在构造函数中。这里,我们创建了一个用Product参数化的Binder类的新实例,因为我们想将表单绑定到产品。然后,我们通过在域类中指定输入字段以及 Java 属性的 getter 和 setter,将输入字段绑定到它们在Product类中对应的 Java 属性。稍后,Binder类使用 getter 从 bean 中获取值,并将其设置为输入字段中的值。setter 用于根据输入字段中的值设置 bean 属性中的值。一旦我们定义了绑定,我们通过调用setBean(BEAN)方法告诉Binder类使用哪个 bean。此时,Binder类从 bean 中读取值,并将它们写入匹配的输入字段。从现在开始,当用户在表单中引入数据时,Binder类还将从输入字段设置 bean 中的值。
Note
在内部,Binder 类向每个输入字段添加值更改侦听器,以便有机会将值写回 bean。没有魔法。只是为你工作的框架。
Binder级提供了大量的多功能性。在前面的例子中,我们使用方法引用来指定在绑定中使用的 getter 和 setter。我们也可以使用 lambda 表达式并包含任何我们想要的逻辑。假设我们的客户要求我们更改 UI,将产品标记为不可用,而不是可用。也就是说,当产品不可用时,用户希望看到复选标记。由于种种原因,我们不想修改数据模型(例如,这是这个需求有意义的唯一视图,但是在这个假设的应用的其他 174 个视图中没有)。这是会让我们的客户满意的变化:
private Checkbox unavailable = new Checkbox("Unavailable");
...
binder.bind(unavailable,
prod -> !prod.isAvailable(),
(prod, booleanValue) -> prod.setAvailable(!booleanValue));
我们更改了引用Checkbox的变量的名称,以使代码更加清晰,并使用 lambda 表达式对 bean(Product类型的prod)和输入字段(unavailable)中的值求反。在这种情况下需要简单的逻辑,但是您已经明白了——当在属性和 UI 中相应的输入字段之间同步值时,您可以运行任何业务或 UI 逻辑(简单的或复杂的)。
当您需要对何时运行数据绑定逻辑进行更细粒度的控制时,请使用readBean(BEAN)和writeBean(BEAN)方法。第一个读取属性并设置输入字段中的值。第二个获取输入字段中的值,并将它们写入 bean 的属性中。见https://vaadin.com/api 。
使用属性名定义绑定
在 Java 中,当您有一个带有匹配的 getter 和 setter 的字段时,您可以将该变量称为属性。Binder类允许您使用域类中属性的名称来定义绑定,而不是指定 getter 和 setter 函数。例如,如果我们想使用属性名为Product类的manufacturer属性定义绑定,我们必须做两件事。第一种是使用Binder(Class)构造函数而不是默认构造函数来创建实例:
Binder<Product> binder = new Binder<>(Product.class);
通过这种改变,我们可以通过字符串的形式引用属性的名称来定义绑定。例如:
binder.bind(manufacturer, "manufacturer");
我们还可以更改输入字段的名称,使示例更加清晰:
private ComboBox<Manufacturer> comboBox = new ComboBox<>();
现在绑定看起来像这样:
binder.bind(comboBox, "manufacturer");
Caution
使用属性名定义绑定的好处是可以使用 Jakarta Bean 验证。然而,您的代码将不再是类型安全的,如果以后您在您的域模型中重命名一个属性,您也必须在您的绑定逻辑中重命名包含该属性名称的字符串。否则会产生运行时错误。通过方法引用和 lambda 表达式,您的代码是类型安全的,并且您可以使用 IDE 的重构工具来重命名属性,因为您知道代码不会中断。这两种方法都没有对错;你必须根据你的需求和设计来决定什么是最好的。正如在下一节中所讨论的,自动绑定包含了一个特性,这个特性减少了与通过属性名进行数据绑定相关的风险。
使用自动绑定
我们可以让 Vaadin 为我们定义绑定。这种方法在很多情况下都很方便,尤其是当您想要使用 Jakarta Bean 验证时(将在本章后面介绍)。让我们看一个自动绑定的例子:
public class AutoBindingProductForm extends Composite<Component> {
private TextField name = new TextField("Name");
private ComboBox<Manufacturer> manufacturer = new ComboBox<>();
private Checkbox available = new Checkbox("Available");
public AutoBindingProductForm(Product product, ...) {
...
Binder<Product> binder = new Binder<>(Product.class);
binder.bindInstanceFields(this);
binder.setBean(product);
}
...
}
首先,请注意,我使用省略号(...)来表示我们目前不感兴趣的额外代码。其次,注意我们使用的Binder构造函数。这在使用自动绑定时是必需的,因为属性的名称用于将输入字段与 domain 类中的 Java 属性相匹配,就像我们在上一节中看到的那样。第三,看看我们如何通过调用bindInstanceFields(Object)方法来替换绑定定义。
通过自动绑定,Vaadin 检查您传递给bindInstanceFields(Object)方法的类,以找到该类中的所有 Java 字段。如果被检查的字段也是HasValue(所有 Vaadin UI 输入组件实现的接口)的一个实例,那么它试图在您传递给setBean(BEAN)的 bean 中找到一个与被检查字段同名的属性,然后使用相应的 getter 和 setter 来执行数据绑定。
在前面的例子中,我们有三个类型为HasValue的 Java 字段,它们在Product类的属性中具有匹配的名称。因此,Vaadin 绑定
-
AutoBindingProductForm::name至Product::name -
AutoBindingProductForm::manufacturers至Product:manufacturers -
AutoBindingProductForm::available至Product::available
为了提高可维护性,我们可以使用@PropertyId注释来覆盖命名约定。这允许我们在 UI 类中为输入字段使用任何标识符:
@PropertyId("name")
private TextField nameTextField = new TextField("Name");
@PropertyId("manufacturer")
private ComboBox<Manufacturer> manufacturerComboBox
= new ComboBox<>();
@PropertyId("available")
private Checkbox availableCheckbox = new Checkbox("Available");
例如,看看我们如何将Checkbox的标识符改为availableCheckbox。我们可以用任何我们喜欢的名字。如果没有注释,绑定就不会发生,因为两个类中 Java 字段的名称不匹配。
Tip
当使用自动绑定时,总是使用@PropertyId来使你的代码更容易维护。您可以在不破坏代码的情况下自由更改输入字段的名称。当您在您的域模型中重命名一个属性时,您可以搜索@PropertyId("nameOfTheProperty")并相应地进行替换。
定义嵌套属性的绑定
假设在一次在线会议后,很明显我们的客户希望能够以编辑产品的相同形式编辑制造商的电话号码和电子邮件。他们声称这将节省大量时间,因为他们目前拥有的系统(用 Fortran 实现)没有这个选项。
Note
我从来没用过 Fortran。然而,当我还是个孩子的时候,当我父亲讲述在大学使用编程的鼓舞人心的故事时,它间接地激励了我去学习更多的编程知识。我接触过的最接近 Fortran 的是一个用 C 实现的在线文本游戏,但它是基于克罗泽和伍兹用 Fortran 为传说中的 PDP-10 主机编写的原版(Adventure)。你可以在 https://quuxplusone.github.io/Advent 玩游戏。
需求表明,当用户想要创建新产品时,我们必须显示组合框来选择制造商。然而,当他们想要编辑一个时,我们必须禁用组合框并显示两个额外的文本字段来更新制造商的电话号码和电子邮件。因此,我们必须向AutoBindingForm类添加两个字段:
private TextField phoneNumber = new TextField(
"Manufacturer phone number");
private TextField email = new TextField("Manufacturer email");
这些也应该添加到布局中:
@Override
protected Component initContent() {
return new VerticalLayout(
new H1("Product"),
name,
available,
manufacturer,
phoneNumber,
email,
new Button("Save", VaadinIcon.CHECK.create(),
event -> saveListener.run())
);
}
实现的关键部分在构造函数中。我们有两种场景:表单用于新产品或现有产品。如果产品名称为空,则它一定是新产品。我们可以根据需要使用一个if...else语句来隐藏和禁用输入字段。为了创建绑定,我们可以使用“点符号”来指定我们想要绑定的属性。代码如下:
Binder<Product> binder = new Binder<>(Product.class);
binder.bindInstanceFields(this);
if (product.getName() == null) {
phoneNumber.setVisible(false);
email.setVisible(false);
} else {
manufacturer.setEnabled(false);
binder.bind(phoneNumber, "manufacturer.phoneNumber");
binder.bind(email, "manufacturer.email");
}
binder.setBean(product);
我们使用自动绑定来创建对Product类属性的绑定。但是请注意我们是如何创建到Manufacturer类的嵌套属性的绑定的。不幸的是,在撰写本文时,Vaadin 不支持使用@PropertyId注释的嵌套绑定。我们也可以使用类型安全绑定。因为我们可以使用 lambda 表达式来获取和设置任何我们想要的 Java 逻辑的值,所以我们可以选择这样的方式:
binder.bind(email,
p -> p.getManufacturer().getEmail(),
(p, e) -> p.getManufacturer().setEmail(e));
在这里,p是一个Product,e是一个String。
Caution
更复杂的场景可能需要在创建这种类型的嵌套数据绑定时进行空检查,以避免NullPointerException s。
图 5-3 显示了编辑模式下的表单。
图 5-3
具有嵌套数据绑定的表单
数据转换和验证
为了有效,活页夹工具应该包括管理数据转换和验证的功能。Vaadin 的Binder类包含了以灵活和健壮的方式处理这两个方面的方法。
数据转换是将数据从输入字段支持的格式(如TextField中的String)转换为领域模型中的格式并返回的过程。例如,某个产品的可用商品数量可以通过一个TextField进行编辑(尽管在这种情况下,我建议使用NumberField来代替),并存储在域模型的一个int属性中。
数据验证是确保输入字段中的数据在存储到域模型之前根据一组业务规则是有效的过程。例如,在将产品保存到数据库中之前,您可能希望保证产品的名称不为 null 并且不是空字符串。
使用转换器
为了说明转换器的使用,假设客户告诉我们 POS 软件必须包含产品代码。他们坚持认为,他们希望能够在文本字段中引入产品代码,即使部分代码被固定为一组预定义的选项,这些选项从来没有机会,而且他们肯定在可预见的未来不会改变。他们称这部分代码为“类型”,其余部分为“数字”(即使它可以包含字母)。这其实是根据一个真实的故事改编的。
总之…在一次软件设计会议之后,很明显我们需要一个枚举来存储类型和一个数字字符串。这是领域模型:
public enum Type {
DRINK, SNACK
}
public class Code {
private Type type;
private String number;
public Code(Type type, String number) {
this.type = type;
this.number = number;
}
... getters and setters ...
}
public class Product {
...
private Code code = new Code(Type.DRINK, "");
...
}
我们需要一个新的输入字段,格式如下:
public class AutoBindingProductForm extends Composite<Component> {
...
private TextField code = new TextField("Code");
...
}
我们当然将文本字段添加到布局中(此处省略)。如果您在没有其他更改的情况下尝试该应用,您将得到如下错误消息:
Property type 'com.apress.practicalvaadin.ch05.Code' doesn't match the field type 'java.lang.String'. Binding should be configured manually using converter.
这是因为 Vaadin 不知道如何将Code实例转换为TextField的String。尽管错误消息确认我们需要一个转换器,但是我们可以使用定义绑定时接受的 getter 和 setter 函数来实现转换:
binder.bind(codeTextField,
(p) -> p.getCode().getType().toString() +
p.getCode().getNumber(),
(p, s) -> {
for (Type t : Type.values()) {
if (s.startsWith(t.toString())) {
p.setCode(
new Code(t, s.substring(t.toString().length()))
);
return;
}
}
}
);
这里,p是类型Product的,s是类型String的。第一个 lambda 表达式是 getter,所以我们只需要连接类型和数字并返回结果字符串。第二个 lambda 表达式需要解析TextField中的字符串,以正确设置Code实例中的类型和数量。
虽然这种方法可行,但您可能希望使用转换器来代替。以下是如何:
public class StringToCodeConverter
implements Converter<String, Code> {
@Override
public Result<Code> convertToModel(String value,
ValueContext context) {
for (Type t : Type.values()) {
if (value.startsWith(t.toString())) {
Code code =
new Code(t, value.substring(t.toString().length()));
return Result.ok(code);
}
}
return Result.error("Error parsing the code");
}
@Override
public String convertToPresentation(Code code,
ValueContext context) {
return code.getType().toString() + code.getNumber();
}
}
这个类实现了Converter及其两个方法。第一个采用一个String值并创建一个新的Code实例。注意使用Result类来告诉 Vaadin 转换是否成功。第二种方法采用一个Code实例,并将连接的值作为一个String返回。这可能看起来是额外的工作,因为它实际上比以前的方法需要更多的代码行。但是,当您希望在应用的多个部分中重用转换逻辑时,这是很有用的。使用转换器,您可以绑定属性并指定转换器,如下所示:
binder.forField(codeTextField)
.withConverter(new StringToCodeConverter())
.bind(Product::getCode, Product::setCode);
这一次,我们使用不同的方式来定义绑定。首先,我们用forField(HasValue)指定输入字段,然后我们使用Binder类的 fluent API 通过链接方法调用来配置绑定。
Vaadin 包括最常见数据类型的转换器。以下是他们的名单:
-
StringToBooleanConverter -
StringToIntegerConverter -
StringToLongConverter -
StringToFloatConverter -
StringToDoubleConverter -
StringToBigDecimalConverter -
StringToBigIntegerConverter -
StringToDateConverter -
LocalDateToDateConverter -
LocalDateTimeToDateConverter -
DateToSqlDateConverter -
StringToUuidConverter
实施验证规则
我们的客户打电话告诉我们,在保存数据之前,产品表单需要检查以下内容:
-
产品名称是必需的。
-
产品代码是必需的。
-
制造商的电话号码应多于七个字符。
-
制造商的电子邮件应该是格式正确的电子邮件地址。
为了在使用Binder类时添加自定义验证逻辑,我们必须在不使用自动绑定的情况下以编程方式定义绑定。因此,让我们将表单中的输入字段重构如下(没有@PropertyId注释):
private TextField name = new TextField("Name");
private TextField code = new TextField("Code");
private ComboBox<Manufacturer> manufacturer = new ComboBox<>();
private Checkbox available = new Checkbox("Available");
private TextField phoneNumber =
new TextField("Manufacturer phone number");
private TextField email = new TextField("Manufacturer email");
现在让我们来解决验证 1(姓名是必需的):
Binder<Product> binder = new Binder<>(Product.class);
binder.forField(name)
.asRequired("The name of the product is required")
通过调用asRequired(String),我们告诉 Vaadin 可视地标记所需的字段,并在字段为空时显示指定的错误。见图 5-4 。
图 5-4
必填字段
实现验证 2(需要代码)非常相似:
binder.forField(code)
.asRequired("Please introduce a code")
.withConverter(new StringToCodeConverter())
.bind(Product::getCode, Product::setCode);
在定义绑定时,我们将调用链接到转换器和验证器。验证 3(超过七个字符的电话号码)需要调用不同的方法:
binder.forField(phoneNumber)
.withValidator(
value -> value.length() > 7,
"Invalid phone number"
).bind("manufacturer.phoneNumber");
我们使用 lambda 表达式来实现验证。事实上,我们可以使用框架提供的众多验证器之一:
-
EmailValidator -
LongRangeValidator -
DateTimeRangeValidator -
BigDecimalRangeValidator -
FloatRangeValidator -
ShortRangeValidator -
BigIntegerRangeValidator -
IntegerRangeValidator -
DoubleRangeValidator -
DateRangeValidator -
ByteRangeValidator -
StringLengthValidator -
RangeValidator -
RegexpValidator
让我们使用其中一个来实现验证 4(正确的电子邮件格式):
binder.forField(email)
.withValidator(new EmailValidator("Invalid email address"))
.bind("manufacturer.email");
虽然验证是在值被发送到服务器时运行的(例如,当用户编辑一个字段时),但是我们应该在单击 save 按钮时调用验证。我们使用Binder类调用验证,这并不奇怪。所以首先我们需要确保可以从点击监听器访问binder对象。我们可以如下移动对象的定义:
public class AutoBindingProductForm extends Composite<Component> {
...
private Binder<Product> binder = new Binder<>(Product.class);
...
}
现在,我们可以使用 binder 首先验证字段,使错误在 UI 中可见,然后检查是否有任何错误:
new Button("Save", VaadinIcon.CHECK.create(),
event -> {
binder.validate();
if (binder.isValid()) {
saveListener.run();
} else {
Notification.show("Please fix the errors");
}
})
使用 Jakarta Bean 验证
Jakarta Bean Validation 是由 ?? JSR 380 ?? 定义的 Java 规范。它允许您在域类的属性中使用注释来表达验证规则。通过从Binder切换到BeanValidationBinder类,我们可以使用注释而不是手动设置验证器。
要开始使用 Jakarta Bean 验证,我们需要添加 API 的一个实现。Hibernate 提供了最流行的实现之一。我们可以将其添加到pom.xml文件的<dependencies>部分:
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.1.5.Final</version>
</dependency>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>jakarta.el</artifactId>
<version> 3.0.3</version>
</dependency>
Caution
在撰写本文时,Vaadin 与使用javax.*名称空间的 Jakarta EE 规范兼容。许多 Jakarta 规范实现的最新版本,包括 Hibernate Validator 版本 7.0.0 和更高版本,都使用了新的jakarta.*名称空间。这种变化被称为“大爆炸”,是将 Java 企业版迁移到 Eclipse Foundation 所需的变化的一部分。您可以在 https://eclipse-foundation.blog/2020/12/08/jakarta-ee-9-delivers-the-big-bang 了解更多相关信息。
让我们定义验证 1(名称是必需的):
public class Product {
@NotNull
@NotBlank
private String name;
...
}
现在是验证 2(需要代码):
public class Code {
...
@NotNull
@NotBlank
private String number;
...
}
最后,验证 3(超过七个字符的电话号码)和 4(正确的电子邮件格式):
public class Manufacturer {
...
@Size(min = 8)
private String phoneNumber;
@Email
private String email;
...
}
为了实现这一点,我们需要删除使用binder对象时添加的所有验证器,并替换它的类型:
private BeanValidationBinder<Product> binder =
new BeanValidationBinder<>(Product.class);
完成了。我们的 POS 软件版本 1 已经完成。尝试通过添加更多验证来改进它。例如,制造商也应该是必填字段。实验并探索Binder类的 API,以了解更多信息。
Note
我写了一篇关于 Jakarta Bean 验证的快速教程,展示了如何向普通 Servlet、Spring 和 Jakarta EE 应用添加不同的提供者,如何定制错误消息,以及有哪些可用的注释。可以在 https://vaadin.com/learn/tutorials/introduction-to-java-bean-validation 找到教程。位于 https://beanvalidation.org 的 Jakarta Bean 验证项目的官方网站也提供了多种学习资源。
摘要
在本章中,你学习了什么是数据绑定,以及如何使用Binder类自动或手动定义绑定。您了解了如何使用转换器将数据从输入字段支持的格式转换成领域模型中的格式。您还了解了如何添加验证器来确保表单中的值在发送之前是正确的,例如,发送到后端服务。
在下一章中,您将了解大多数业务应用使用的强大组件——Grid组件。
六、网格组件
显示和操作表格数据是大多数业务应用的基础。Vaadin 的Grid组件是一个高质量的 UI 组件,用于高效地显示和操作以行和列布局的数据。
这个Grid组件是 Vaadin 的研发团队努力改进 Vaadin 7 和更早版本中已经很强大的Table组件的结果。该组件包括过滤、排序、组件呈现、延迟加载等特性,是业界最先进的表格组件之一。
添加列
Grid组件总是用您希望在每行中呈现数据的域类来参数化。比如要显示订单,用Grid<Order>;如果你想展示产品,你用Grid<Product>;诸如此类。这允许您在向Grid组件添加列和行时直接使用自己的域模型。添加行总是手动完成的(您必须告诉Grid组件要显示哪些行或数据)。但是,对于列,Grid组件支持两种模式:自动或手动列定义。使用的模式取决于您使用的Grid构造函数:
// scans Product and automatically adds a column per property
var grid1 = new Grid<>(Product.class);
// no scan, you have to add the columns manually
var grid2 = new Grid<Product>();
按键管理列
假设您有以下域类:
public class Book {
private Integer id;
private String title;
private String author;
private int quantity;
... constructors, getters, and setters ...
}
通过将类类型传递给Grid构造函数,可以让 Vaadin 自动添加列来显示Book类中的每个属性(参见图 6-1 ):
图 6-1
带有自动创建的列的空Grid
var grid = new Grid<>(Book.class);
每个列都有一个String键,您可以使用它来进一步配置该列。您可以通过使用getColumnByKey(String)方法来实现。例如,可以按如下方式设置标题:
grid.getColumnByKey("title").setHeader("Book");
此外,您可以链接配置:
grid.getColumnByKey("title")
.setHeader("Book")
.setFooter("text here")
.setAutoWidth(true);
setAutoWidth(boolean)方法计算宽度,为内容留出空间。
您可以通过属性名添加更多的列,这在您想要显示嵌套属性时非常有用。例如,如果我们在Book类中添加一个属性,比如类型Publisher,并且您希望在另一列中显示发布者的姓名,那么您可以按如下方式添加:
grid.removeColumnByKey("publisher");
grid.addColumn("publisher.name");
我们移除了使用Grid(Class)构造函数时自动生成的publisher列。您也可以使用列键来定义显示哪些属性以及显示顺序(参见图 6-2 ):
图 6-2
设置列的可见性(id 不可见)和顺序
grid.setColumns("title", "publisher.name", "author", "quantity");
grid.getColumnByKey("publisher.name").setHeader("Publisher");
Caution
在为指定的属性添加新列之前,setColumns(String...)方法删除所有现有的列。
使用 ValueProvider 定义列
我们已经看到了一种在使用Grid(Class)构造函数时通过属性名手动定义列的方法。当您使用默认构造函数(Grid())时,Vaadin 不会向组件添加任何列。相反,您必须定义和配置每一列,传递一个叫做ValueProvider的东西。实现ValueProvider的一个简单方法是使用对域类中 getter 的方法引用或 lambda 表达式来显示任何想要显示的内容(例如,通过导航 getter 来嵌套属性)。与使用自动生成的列相比,这种方法的一个优点是它的类型安全特性:
var grid = new Grid<Book>(); // default constructor used
grid.addColumn(Book::getTitle)
.setHeader("Book").setAutoWidth(true);
grid.addColumn(book -> book.getPublisher().getName())
.setHeader("Publisher");
grid.addColumn(Book::getAuthor).setHeader("Author");
grid.addColumn(Book::getQuantity).setHeader("Quantity");
Tip
如果希望代码显式且类型安全,请使用手动列定义。自动列适用于您喜欢较短代码的情况,或者在高级场景中,Grid在运行时从您在编译时不知道的域对象生成,并且可能由第三方库提供。
添加行
向Grid组件添加行是由许多具有不同签名的setItems方法处理的。在我们使用这些方法之前,让我们定义一个服务层。通常,web 应用使用后端服务来提供对数据的操作。对于 Vaadin,您可以简单地调用一个 Java 方法来运行后端逻辑。例如,我们可以实现一个服务类来添加任何逻辑以获取数据,而无需向视图层公开实现细节。服务类可以连接到 SQL 数据库,读写硬盘上的文件,连接到外部 web 服务,甚至是所有这些的组合。例如:
public class BookService {
...
public static List<Book> findAll() {
...
}
}
实现细节在这里并不重要,因此省略了。如果你想看实现(它只包含一个 Java Collection来保存内存中的对象),可以通过本书位于 www.apress.com/ISBN 的产品页面来看看 GitHub 上的代码。
内存中的数据
准备好后端服务后,我们就可以开始消费数据了。要在一个Grid中显示所有的书,我们只需做如下的事情:
List<Book> books = BookService.findAll();
grid.setItems(books);
就这么简单。你可以把这想象成Grid中的每一行都有一个Book。每个单元格中的值取决于定义列时的配置。
Caution
在Grid类中没有删除项目的方法。相反,您必须传递一个没有要移除的元素的新集合或数组。
setItems(Collection)方法重载了一个接受数组或传递单个对象的版本(见图 6-3 ):
图 6-3
用数据填充的Grid
Book[] booksArray = ...
grid.setItems(booksArray);
gridSetItems(book1, book2, book3, book4);
一旦设置了项目,Grid组件就将对象保存在内存中。在开发应用时,请记住这一点。请记住,看到视图的每个用户在服务器中都有自己的视图实例,反过来,还有一个Grid实例。每个Grid实例都有自己的一组条目,也就是自己的一组域对象。只有当你确定Grid中的行数不大并且不会随时间增长时,这才是可以接受的。在其他情况下,您应该使用延迟加载。
Tip
在您的应用中,您应该考虑使用数据传输对象(dto)在Grid中显示数据。想象一下Book类有 20 多个属性。其中之一是二进制数据。当您查询后端时,您会得到一个由Book实例组成的集合,其中包含大量您并不真正需要在Grid中显示的数据。这将消耗比所需更多的内存。相反,创建一个新的类来封装视图需要的数据。如果您正在使用持久性框架,请查阅文档以了解如何只请求您需要的数据。
惰性装载
如果后端服务返回一百万本书会怎么样?假设一个Book实例需要的内存量大约是 100 字节。这相当于 100 兆字节的内存来保存后端服务返回的数据。有了分配给 JVM 的 1gb 内存,您可以处理大约 10 个并发用户。可能更少,因为您需要内存来存储其他资源。当您在开发环境中测试您的应用时,您可能不会注意到这一点,因为Grid组件能够处理如此大量的数据。
Tip
之前的估计是基于对存储类的每个字段所需的字节数以及 JVM 可能增加的开销的快速分析。如果您的项目需要更好的大小估计,看看 Java 中可用的Instrumentation接口。在 https://docs.oracle.com/en/java/javase 查阅 API 文档。
尽管您应该回顾并考虑在一个Grid中显示一百万行的其他方法,但是如果必须的话,总是使用延迟加载。我们来看看怎么做。
后端服务应该能够以“切片”的形式提供数据如果您熟悉 SQL,您可能知道LIMIT和OFFSET子句,例如:
SELECT * FROM book OFFSET 300 LIMIT 100
该查询在开始返回 100 行之前跳过了 300 行。在我们的示例应用中,我们不处理 SQL,我们仍然将所有数据保存在内存中。然而,表示层并不知道这一点,您应该能够只更改底层技术来使用 SQL、NoSQL 或任何其他数据库解决方案存储数据。最后,后端服务应该包括如下所示的方法:
public static List<Book> findAll(int offset, int limit) {
...
}
Grid组件可以如下使用这个方法:
grid.setItems(query -> BookService.findAll(
query.getOffset(), query.getLimit()).stream()
);
提供一个Query对象,该对象具有Grid的当前状态所需的偏移量和限制(具体由滚动条的位置决定)。我们可以将值传递给后端,并将Collection转换成Stream。
Note
实现延迟加载的另一种方法是创建带编号的页面来显示切片中的数据。这个概念类似于我们在这里实现的内容,但是需要您设置 UI 组件来显示页码(例如,使用Button),并添加逻辑来设置构成所选页面的Grid项。
整理
Grid组件允许用户通过单击列的标题来对行进行排序。当您让 Vaadin 通过使用Grid(Class)构造函数生成列时,所有的列都被映射到实现Comparable的属性,然后该列就可以排序了。当您手动定义列时,可以按如下方式启用排序:
grid.addColumn(Book::getTitle). setSortable(true);
可以通过false到setSortable(boolean)来禁用排序。使用列键时,可以在多列中启用排序,如下所示:
grid.setSortableColumns("title", "author");
当一个属性不是Comparable或者您想要调整比较逻辑时,您可以提供一个Comparator:
grid.addColumn(Book::getTitle).setComparator(
(book1, book2) ->
book1.getTitle().compareToIgnoreCase(book2.getTitle()));
前面的示例忽略大小写对标题列进行排序。请记住,您可以将对配置列的多个方法的调用链接起来。例如,您可以配置标题和宽度,并添加一个比较器,如下所示:
grid.addColumn(Book::getTitle)
.setHeader("Book")
.setAutoWidth(true)
.setComparator((book1, book2) ->
book1.getTitle().compareToIgnoreCase(book2.getTitle()));
我们在这里使用 lambda 表达式,但是当代码比这个简单的例子更复杂时,没有什么可以阻止您创建一个新的类来封装逻辑。
Tip
当您使用延迟加载时,您可以将排序委托给后端。Query类包含如何根据用户点击的列对数据进行排序的信息。排序逻辑的实际实现取决于您的特定后端。例如,如果您正在使用 SQL 查询,您可以根据Query::getSorted()和Query::getDirection()返回的内容添加由ASC或DESC修改的ORDER BY子句。
处理行选择
既然我们已经介绍了如何在Grid中显示数据,让我们来探索如何使组件更具交互性。您可能想知道的第一件事是如何处理行选择,这是一个在数据编辑、数据下钻以及一般的数据消费和操作等领域实现功能的特性。
Grid组件有三种选择模式,您可以使用setSelectionMode(SelectionMode)方法进行配置:
-
grid.setSelectionMode(Grid.SelectionMode.NONE):不允许选择。 -
grid.setSelectionMode(Grid.SelectionMode.SINGLE):一次可以选择一行。 -
grid.setSelectionMode(Grid.SelectionMode.MULTI):可以同时选择多行。
默认情况下,Grid组件允许对行进行单项选择,因此如果您以前没有更改过这个选项,就不必显式地配置它。
假设我们需要添加一个按钮,将在Grid中选择的书的quantity属性增加 1。我们希望只有在选中一行时才启用按钮。在视图的构造器中,让我们创建按钮,禁用它(因为在构建视图时没有选择行),并将其添加到布局中(参见图 6-4 ):
图 6-4
在Grid组件中选择的一行
var increaseQuantity = new Button("Increase quantity");
increaseQuantity.setEnabled(false);
...
return new VerticalLayout(increaseQuantity, grid);
现在,我们需要一个侦听器,当选择或取消选择一行时调用它:
grid.addSelectionListener(event -> {
... logic here ...
});
按照这里的逻辑,我们需要检查是否有一行被选中。我们可以得到Grid的“单一选择”视图,并得到选择的值:
Book selectedBook = grid.asSingleSelect().getValue();
当用户取消选择一行时,引用是null,因此我们可以使用它来启用或禁用按钮(参见图 6-5 ):
图 6-5
禁用选择更改时的按钮
grid.addSelectionListener(event -> {
Book selectedBook = grid.asSingleSelect().getValue();
increaseQuantity.setEnabled(selectedBook != null);
});
或者我们可以使用event对象将选中的书包装在Optional中,在我看来这是一个更好的解决方案,因为它将块中的代码从grid实例中分离出来:
grid.addSelectionListener(event -> {
boolean enabled = event.getFirstSelectedItem().isPresent();
increaseQuantity.setEnabled(enabled);
});
现在缺少的部分是单击按钮时运行的逻辑。我们需要更新所选Book实例的quantity属性中的值,并更新Grid。由于我们需要从两个地方(当我们创建Grid和当用户点击按钮时)更新(用数据填充)Grid,为此创建一个方法是有意义的:
private void updateGrid(Grid<Book> grid) {
List<Book> books = BookService.findAll();
grid.setItems(books);
}
您可以使grid实例成为类中的一个字段,或者简单地将它传递给方法。我们将它传递给前面代码片段中的方法。准备就绪后,我们可以在按钮上实现点击监听器,如下所示:
increaseQuantity.addClickListener(event ->
grid.asSingleSelect().getOptionalValue().ifPresent(
book -> {
BookService.increaseQuantity(book);
updateGrid(grid);
}
)
);
我们可以直接更新侦听器中的quantity属性,但是如果我们处理的是一个具有外部服务和数据库连接的真实应用,您最有可能将逻辑委托给服务类。服务方法逻辑作为一个(极其简单的)练习(给指定的Book的quantity属性加一)。
注意使用了getOptionalValue()方法,而不是前面解释的getValue()。这很方便,因为我们只关心值的存在,而不是执行时的值。
多重选择以类似的方式处理,除了您可以将选择的行作为一个Set:
Set<Book> selectedBooks = grid.asMultiSelect().getValue();
或者从选择监听器:
Set<Book> selectedBooks = event.getAllSelectedItems();
向单元格添加 UI 组件
一个显著增加Grid组件灵活性的特性是可以添加其他 UI 组件。让我们探索一下选择。
组件列
有一种特殊的列,它的单元格中可以有组件。假设我们想去掉在上一节中实现的按钮。相反,我们希望每行每本书都有一个按钮,允许用户通过单击Grid中的相应按钮来增加数量。以下是如何:
grid.addComponentColumn(
book -> new Button(VaadinIcon.PLUS.create(), event -> {
BookService.increaseQuantity(book);
updateGrid(grid);
})
);
使用addComponentColumn(ValueProvider)方法,我们可以获取一个Book实例并返回一个 UI 组件。在前面的例子中,我们返回了一个Button,它运行逻辑来增加该行中的图书数量(参见图 6-6 )。
图 6-6
组件列
需要时,我们可以使用Book实例中的属性。例如,我们可以用进度条替换数量列中的数值。让我们假设每本书我们最多能有 50 本。我们可以这样配置该列:
grid.addComponentColumn(
book -> new ProgressBar(0, 50, book.getQuantity())
).setHeader("Quantity").setSortable(true);
我们还为该列设置了一个标题,并使其可排序。但是,如果您尝试单击标题,排序将不起作用。在这种情况下,我们需要设置一个Comparator:
.setComparator(Comparator.comparingInt(Book::getQuantity));
我们将这个实现中的比较逻辑委托给 Java。图 6-7 显示的是点击两次表头进行降序排序后的结果。
图 6-7
Grid组件中的有序行
这个实现有一个问题。你能看见吗?尝试多次单击其中一个按钮。调查问题。在 IDE 中查看应用的日志,并尝试修复它(本书在 www.apress.com/ISBN 的源代码示例中提供了修复方法)。如果你喜欢编码,试着在每一行增加另一个按钮来减少相应的数量。您可以尝试为“新建”按钮添加一个新列,或者创建一个在同一列中包含两个按钮的布局。
项目详细信息
有时,表中的一行并不是显示与域对象相关的所有数据的最佳位置。通常,应用包含一个选项,可以深入到表中某一行的详细信息。当用户单击一行时,Grid组件允许您在定制组件中显示更多信息(项目细节)。
假设我们向Book类添加了一个description属性。该书的描述可能太长而不能直接显示在新列中,用户可能希望在列表中识别出该书后阅读该书的描述。因此,让我们添加当用户单击一行时显示描述的功能:
grid.setItemDetailsRenderer(
new ComponentRenderer<>(book -> new VerticalLayout(
new Text(book.getDescription())
))
);
这段代码添加了一个ComponentRenderer(一个负责在单元格中呈现组件的对象),它使用指定的 lambda 表达式构建一个布局,其中包含与被单击的行相关联的图书描述。图 6-8 显示了结果。
图 6-8
单击行时显示的项目详细信息
我们可以向细节部分添加任何组件和布局。例如,我们添加一个按钮来删除或编辑一本书,甚至删除我们为增加数量而创建的 component 列,并将按钮移到 details 行。让我们试着移动按钮。我们需要做的就是将创建Button的代码从组件列定义移动到细节部分的VerticalLayout,并删除创建列的代码:
grid.setItemDetailsRenderer(
new ComponentRenderer<>(book -> new VerticalLayout(
new Text(book.getDescription()),
new Button(VaadinIcon.PLUS.create(), event -> {
BookService.increaseQuantity(book);
updateGrid(grid);
grid.select(book);
})
))
);
看对grid.select(book)的调用。这就是如何以编程方式选择行。但是为什么这里需要它呢?任何时候单击某一行,都会选中或取消选中该行。选中该行时,将显示详细信息区域。再次单击该行时,该行将被取消选择,并且详细信息区域将被隐藏。当细节区域可见时,单击我们添加的按钮会传播到取消选中它的行,并导致细节区域隐藏。为了避免这种情况,我们简单地再次“重新选择”同一行(书)。这样,用户可以在需要时多次单击该按钮,而不必每次都选择该行。
此外,看看我们如何改变按钮的图标和添加文本,以使用户清楚这个按钮是什么。当按钮在数量栏旁边时,很容易理解该按钮增加了数量,但是如果我们将它移到细节区域,情况就不同了。图 6-9 显示了结果。
图 6-9
详细信息行中的组件
Tip
注意不要将复杂的布局添加到详细信息行中。这可能会使用户界面变得混乱,并对 UX 产生负面影响,尤其是在小型视口(如移动设备上的视口)中使用该应用时。
导出到 CSV
为了结束这一章,让我们探索一下业务应用中的一个常见用例——将Grid的内容导出到逗号分隔值文件(CSV)。也可以导出到许多其他格式,但 CSV 似乎是软件行业的一个流行要求。我们将使用一个Anchor组件和 Opencsv ,一个用于读取、写入和处理 csv 文件的免费库。
让我们从编码Anchor开始。一个Anchor组件可以指向一个定义好的 URL,您可以将它指定为一个字符串(href)或者指向一个StreamResource,它是一个允许您将动态数据从服务器传输到客户端的类。实施一个StreamResources很容易:
var streamResource = new StreamResource("books.csv",
() -> {
// TODO
return new ByteArrayInputStream(null);
}
);
当单击浏览器中的链接时,lambda 表达式运行,您有机会返回 Vaadin 用来传输数据的InputStream。我们还没有数据,所以我们现在传递一个null。现在我们可以将这个StreamResource传递给一个新的Anchor组件,我们可以将它添加到 UI 中:
var download = new Anchor(streamResource, "Download");
return new VerticalLayout(download, grid);
生成数据的时间。在编码之前,我们需要将 Opencsv 库添加到 pom.xml 文件中(在 http://opencsv.sourceforge.net 查看最新版本):
<dependency>
<groupId>com.opencsv</groupId>
<artifactId>opencsv</artifactId>
<version>5.4</version>
</dependency>
现在让我们来处理TODO部分。我们需要Grid中的Book实例。我们可以通过以下方式获得它们:
var books = grid.getGenericDataView().getItems();
这将返回一个类型为Stream<Book>的对象(books)。数据准备好了。我们需要改变它。为此,我们可以使用一个StatefulBeanToCsvBuilder(来自 Opencsv 库)。下面是它的使用方法:
StringWriter output = new StringWriter();
var beanToCsv =
new StatefulBeanToCsvBuilder<Book>(output).build();
beanToCsv.write(books);
在调用write(Stream)之后,output对象将保存一个包含 CSV 格式数据的String。我们可以将构成这个String的字节传递给InputStream(代替我们在前面的代码片段中使用的null):
return new ByteArrayInputStream(output.toString().getBytes());
为了让您全面了解,下面是完整的StreamResource实现:
var streamResource = new StreamResource("books.csv",
() -> {
try {
var books = grid.getGenericDataView().getItems();
StringWriter output = new StringWriter();
var beanToCsv =
new StatefulBeanToCsvBuilder<Book>(output).build();
beanToCsv.write(books);
return new ByteArrayInputStream(
output.toString().getBytes());
} catch (CsvDataTypeMismatchException |
CsvRequiredFieldEmptyException e) {
e.printStackTrace();
return null;
}
}
);
如果您运行应用并下载文件,您将会看到Book类中的所有属性都包含在文件中,而Grid只显示了其中的一部分。当我们转换网格中包含的Book对象时,这是意料之中的。我们可以排除如下属性:
var beanToCsv = new StatefulBeanToCsvBuilder<Book>(output)
.withIgnoreField(Book.class,
Book.class.getDeclaredField("id"))
.withIgnoreField(Book.class,
Book.class.getDeclaredField("nextId"))
.build();
另一个问题是,Publisher列显示了来自Object类的默认toString()方法的结果,而不是发布者的名字。解决这个问题最简单的方法是覆盖Book类中的toString()方法来返回发布者的名字:
public class Publisher {
private String name;
@Override
public String toString() {
return name;
}
...
}
Note
Opencsv 是一个灵活的库,具有比这里描述的更多的功能。要了解更多信息,请参见 http://opencsv.sourceforge.net 。
摘要
在这一章中,您学习了如何在 Vaadin 的一个更复杂的 UI 组件中使用最重要的功能:组件Grid。您了解了如何自动和手动添加列,以及如何添加在内存中保存数据的行,或者使用延迟加载将数据提供给Grid组件以节省内存。您看到了如何启用排序,以及如何对Grid行中的选择更改做出反应。您了解了如何在Grid的单元格中添加 UI 组件,以及如何在用户单击一行时显示“细节视图”,以便显示关于某个项目的更多信息。您还了解了 web 应用中的一个常见用例——将数据从Grid导出到 CSV 文件。同时探索通过使用事件侦听器组合和连接 UI 组件来组合视图的方法。Grid类提供了比我们在这里讨论的更多的功能。例如,您可以冻结标题或添加拖放功能。详见 Vaadin 官方文档了解更多( https://vaadin.com/docs )。
下一章涵盖了 Vaadin 的一个中心主题:导航和路由。大多数应用需要多个视图,所以不要错过这个主题!
七、多视图导航和路由
尽管 Vaadin 抽象出了许多 web 平台和 Java Servlet 技术细节,但是用该框架开发的应用仍然是 Web 应用。这听起来显而易见,但值得重申。当您开始使用 Vaadin 实现应用时,您可能会被代码所吸引,而忘记您正在开发一个 web 应用。我去过那里。
web 应用的核心部分是请求-响应模型。Vaadin 隐藏了与该模型相关的复杂性,同时允许您使用其优势。您已经知道在同一个应用中可以有多个视图,每个视图都可以通过不同的 URL 访问。您还可以通过编程方式从一个视图导航到另一个视图,在用户进入视图之前或之后运行自定义逻辑,配置错误视图,在 URL 中包含参数,等等。
路线
路由将 Java 类与 URL 连接起来。这个 Java 类通常是一个视图,必须实现Component。我们已经用@Route注释定义了路线:
@Route("hello")
public class Hello extends VerticalLayout {
public Hello() {
this.add(new Text("Hello!");
}
}
当应用启动时(在 Servlet 初始化时),Vaadin 扫描这些类以识别那些用@Route注释的类。对于每个类,它会创建一个包含每个路由配置的注册表。当用户请求由VaadinServlet管理的 URL 时,会用到这个配置,例如,在您的开发机器上的*http://localhost:8080/hello*,或者在生产机器上的 https://example.com/businessapp/hello 。
Tip
您可以使用@RouteAlias注释为单个视图定义多条路线。如果你决定使用这个注释,我建议你关注 https://github.com/vaadin/flow/issues/7862 的问题。如果你想看看在像 Vaadin 这样的开源产品的开发过程中发生的讨论,请阅读这个帖子。
运行时定义路线
除了让 Vaadin 自动检测来自您的类的路由,您还可以在运行时以编程方式定义它们。让我们开发一个带有登录表单的基本应用。根据登录表单中引入的凭证,我们授予对两个视图之一的访问权限。我们将使这变得简单,但是你将得到如何使它适应你的应用的想法。假设我们只有两个用户:
-
用户名:
user,密码:user。只能访问UserView组件。 -
用户名:
admin,密码:admin。只能访问AdminView组件。
视图是显示消息的简单的 Vaadin 组件。我们现在不会用@Route来注释实现:
public class UserView extends Composite<Component> {
@Override
protected Component initContent() {
return new Text("Welcome, user.");
}
}
public class AdminView extends Composite<Component> {
@Override
protected Component initContent() {
return new Text("Hello, admin.");
}
}
如果我们用@Route注释这些类,我们将允许任何人访问。相反,我们想告诉 Vaadin 哪些路由是可用的,这取决于登录表单显示后运行的逻辑。在 Vaadin 应用中显示登录表单最简单的方法是使用LoginOverlay(或者,LoginForm)组件:
@Route("login")
public class AppLoginForm extends Composite<Component> {
@Override
protected Component initContent() {
LoginI18n i18n = LoginI18n.createDefault();
LoginOverlay loginOverlay = new LoginOverlay(i18n);
loginOverlay.setTitle("Chapter 7");
loginOverlay.setDescription("Navigation and Routing");
loginOverlay.setOpened(true);
loginOverlay.addForgotPasswordListener(event ->
Notification.show("Use admin/admin or user/user"));
loginOverlay.addLoginListener(event -> {
... login logic here ...
});
return new VerticalLayout(loginOverlay);
}
}
这个类用@Route进行了注释,因为我们希望每个人都能使用它。
LoginI18n是 Vaadin 提供的一个类,用于定制LoginOverlay组件中的文本。LoginOverlay组件在页面中央显示一个登录表单,如图 7-1 所示。该组件是完全响应的,这意味着它会根据显示它的屏幕大小进行调整。尝试实现这个例子或者从本书的源代码运行这个项目(可以在 GitHub 上的 www.apress.com/ISBN 获得),并尝试不同的窗口大小。
图 7-1
LoginOverlay组件
当您创建一个LoginOverlay时,您可以传递一个LoginI18n对象,该对象允许您定制诸如错误消息、标题和要使用的表单之类的东西。我们暂时不考虑这个。在 https://vaadin.com/api 浏览 IDE 或 API 文档中可用的方法。
看看我们是如何打开组件的,也就是说我们让它在页面上可见。您可以使用LoginOverlay组件作为任何视图的覆盖图。在这里,除了登录表单本身,我们没有其他任何东西。例如,在其他应用中,您可以添加一个按钮来显示登录表单。
另外,请注意我们添加的侦听器。第一个处理表单中显示的忘记密码选项,它只显示一个通知,告诉您可用的用户和密码。我不建议在您的应用中这样做。第二个响应登录按钮的点击事件。让我们实现这个按钮的逻辑。
我们需要根据用户引入的凭证来定义或注册可用的视图。为此,我们可以使用RouteConfiguration类。例如,要使AdminView组件作为通过admin路径的一条路径可用,我们可以运行
RouteConfiguration.forSessionScope().setRoute(
"admin", AdminView.class
);
这告诉 Vaadin,对于当前会话,并且仅对于当前会话,用户可以看到AdminView组件,就好像该类已经用@Route("admin")进行了注释。我们可以用这个来根据登录表单中的凭证设置相应的路由,如下所示:
loginOverlay.addLoginListener(event -> {
if ("user".equals(event.getUsername())
&& "user".equals(event.getPassword())) {
RouteConfiguration.forSessionScope().setRoute(
"user", UserView.class
);
UI.getCurrent().navigate(UserView.class);
} else if ("admin".equals(event.getUsername())
&& "admin".equals(event.getPassword())) {
RouteConfiguration.forSessionScope().setRoute(
"admin", AdminView.class
);
UI.getCurrent().navigate(AdminView.class);
} else {
loginOverlay.setError(true);
}
});
Caution
使用硬编码的字符串来定义用户和密码远不是一个好的做法。这些信息应该安全地存储在外部数据源(如 SQL 数据库)中,并对密码进行适当的加密。
我们在登录监听器中检查来自event对象的用户名和密码值,并相应地设置路由。我们还使用来自UI类的navigate(Class)方法导航到适当的视图。这段代码应该进行重构,调用一个方法来设置路由,而不是我在本例中编写的基于复制粘贴的版本。或者,我们可以使用一个String来指定我们想要导航到的路径。例如,为了导航到管理视图,我们可以使用
UI.getCurrent().navigate("admin");
在两种情况下(用户和管理员),我们都必须关闭LoginOverlay组件;否则,它将继续可见,我们将看不到我们导航到的视图。
要检查实施是否有效,请参见图 7-2 。在屏幕截图中,请注意只有登录视图可用。此时,Vaadin 只知道登录视图。
图 7-2
在引入有效凭证之前,仅设置登录视图
一旦我们进入登录视图,输入有效的凭证(例如,用户 / 用户,并在浏览器中手动请求空的视图( http://localhost:8080/ ),我们看到一个新的视图(用户)可用(参见图 7-3 )。Vaadin 现在知道了这个观点。它不知道管理视图,而这正是我们想要的——我们想要限制对该视图的访问。如果你尝试请求管理视图,Vaadin 将不能显示它,因为它还没有被设置。
图 7-3
运行时添加的视图(用户)
这个例子有一个错误。如果您以用户身份登录,然后以管理员身份再次登录,您将可以访问这两个视图。这个怎么解决?我就当是给你的一个练习。也许删除以前的视图?让登录视图只对未经认证的用户可用怎么样?实现一个注销选项怎么样?探索RouteConfiguration和UI类的 API,并尝试实现一个解决方案。
Tip
在更复杂的应用中,您可以灵活地在运行时创建路线,例如,从外部系统读取可用的视图及其路径。例如,SQL 数据库可以存储实现视图的 Java 类的完全限定名及其路径。然后,应用在运行时利用这些信息来注册路线。甚至关于哪些用户有权访问哪些路线的规则也可以存储在数据库中。此外,新视图可能由第三方实现,并在部署时而不是编译时添加到系统中。
路由器布局
路由器布局允许你装饰视图。我们来研究一个例子。web 应用中一个常见的 UI 模式是在所有“页面”,或者用 Vaadin 术语来说,视图中使用相同的标题。我们可以创建一个名为Header的可重用 UI 组件,所有视图实现者(比如你、我或我们的同事)都可以手动将其添加到他们的视图中:
@Route("my-fancy-view")
public class MyFancyView extends VerticalLayout {
public MyFancyView() {
...
var header = new Header();
add(header, ... and other components ...);
}
}
如果我们确定永远不需要添加页脚或菜单之类的东西,并且所有视图都共享这些东西,那么这样做就很好。对于那些我们不能 100%确定的情况,我们可以使用路由器布局。
路由器布局是一个实现RouterLayout的类,它设置由一组视图共享的所有组件。每个视图都可以使用@Route注释来指定要使用的路由器布局。下面是一个简单的路由器布局实现,它显示了一个标头:
public class MainLayout extends Composite<Component>
implements RouterLayout {
@Override
protected Component initContent() {
var header = new Div(new Text("Chapter 7"));
header.setWidthFull();
header.getStyle().set("font-size", "2em");
header.getStyle().set("font-weight", "bold");
header.getStyle().set("color", "white");
header.getStyle().set("background-color", "#002211");
return new VerticalLayout(header);
}
}
现在请不要被getStyle()方法分散注意力。这几行代码只是设置了 CSS 属性,使标题看起来更有趣,便于您稍后看到截图。我们将在第十章中讨论造型。重要的一点是,这个类看起来像任何其他定制 UI 组件,除了它实现了RouterLayout。组件本身由一个Div组成,里面有一个Text表示章 7 。以下是当 Vaadin 必须在浏览器中显示管理视图时,我们如何指示 vaa din 使用此路由器布局(参见图 7-4 ):
图 7-4
路由器布局中实现的报头
@Route(layout = MainLayout.class)
public class AdminView extends Composite<Component> {
@Override
protected Component initContent() {
return new Text("Hello, admin.");
}
}
因为在这个示例应用中,我们是以编程方式定义路线的,所以我们不能使用@Route注释。相反,我们必须在定义路由时指定路由器布局,如下所示:
RouteConfiguration.forSessionScope().setRoute(
"admin", AdminView.class, MainLayout.class
);
RouterLayout接口包含一个默认方法,负责将视图添加到布局的末尾。我们可以覆盖实现来处理更复杂的布局。为了说明这一点,让我们尝试在路由器布局中添加一个页脚:
public class MainLayout extends Composite<Component>
implements RouterLayout {
@Override
protected Component initContent() {
...
var footer = new Div(
new Text("Building UIs in Java is awesome!"));
footer.setWidthFull();
footer.getStyle().set("color", "white");
footer.getStyle().set("background-color", "#002211");
return new VerticalLayout(header, footer);
}
}
由于默认情况下是在布局的末尾追加视图,我们将在VerticalLayout中以标题、容器、视图的顺序结束(见图 7-5 )。
图 7-5
路由器布局的默认行为
我们想要的是在页面底部显示页脚,而不是在页眉之后。为了解决这个问题,我们可以覆盖RouterLayout接口的showRouterLayoutContent(HasElement)方法。但是首先,我们需要创建一个组件作为视图的占位符。姑且称之为container:
public class MainLayout extends Composite<Component>
implements RouterLayout {
private VerticalLayout container = new VerticalLayout();
@Override
protected Component initContent() {
...
return new VerticalLayout(header, container, footer);
}
}
注意我们是如何将header、container和footer实例依次添加到VerticalLayout中的。每当一个视图将要显示在路由器布局中时,我们需要清空container并将视图添加到其中:
public class MainLayout extends Composite<Component>
implements RouterLayout {
private VerticalLayout container = new VerticalLayout();
...
@Override
public void showRouterLayoutContent(HasElement content) {
container.removeAll();
container.getElement().appendChild(content.getElement());
}
}
这里我们再次使用了我们还没有涉及到的东西——元素 API。这个 API 允许你添加和删除 HTML 元素。出于某种原因,在路由器布局中,Vaadin 使用这个 API,而不是我们熟悉的组件 API。简而言之,每个组件都有一个元素(浏览器中 HTML 元素的 Java 表示)。我们使用这个更低级的 API 将视图添加到布局中。在第九章中你会学到更多关于元素 API 的知识。实际上,代码的效果就像我们使用了VerticalLayout的add方法一样。结果如图 7-6 所示。
图 7-6
定制的路由器布局
导航生命周期
当您请求一个视图时,Vaadin 会寻找一个匹配的类,要么已经用@Route进行了注释,要么已经手动注册到了RouterConfiguration类。如果找到匹配的类,Vaadin 会在浏览器中呈现它,您可以开始使用该视图。稍后,您可能希望导航到另一个视图。您通过 URL 或应用本身的链接请求视图,然后重复这个过程。我们称这个过程为导航生命周期,您可以主要在两点上连接定制逻辑:
-
在用户进入视图之前
-
在用户离开视图之前
Note
在用户进入视图后(或者在导航事件发生后)运行逻辑还有第三点,我们在这里不讨论。如果您想了解更多,请参见位于 https://vaadin.com/api 的AfterNavigationEvent类的 Javadoc。
在进入观察者之前
“在用户进入视图之前”场景的一个典型用例是,如果没有要可视化的数据,您希望将用户重定向到不同的视图。让我们看看如何实现这一点。
假设我们有这个视图,它是我们应用的“主页”:
@Route("")
public class HomeView extends Composite<Component> {
@Override
protected Component initContent() {
return new VerticalLayout(
new H1("Welcome!"),
new RouterLink("Go to my data", DataView.class)
);
}
}
该视图映射到空航路("")并包括两个部分。第一个是标题(H1),第二个是到另一个视图的链接(接下来我们将实现这个视图)。RouterLink组件对于在应用中创建菜单选项或视图链接很有用。当你点击转到我的数据时,Vaadin 渲染DataView类。图 7-7 显示了浏览器中的HomeView组件。
图 7-7
RouterLink组件
为了实现DataView类,我们假设数据是存储在VaadinSession中的一个简单的String,并且这个数据显示在一个TextArea组件中以允许改变它。大概是这样的:
@Route("data")
public class DataView extends Composite<Component> {
private TextArea textArea;
@Override
protected Component initContent() {
textArea = new TextArea("Data", getData().orElse(""),
"type your data here");
return new VerticalLayout(
new H1("Data view"),
new RouterLink("Home", HomeView.class),
textArea,
new Button("Save", event -> {
setData(textArea.getValue());
Notification.show("Thanks for your data");
})
);
}
private Optional<String> getData() {
String data = (String) VaadinSession.getCurrent()
.getAttribute("data");
return Optional.ofNullable(data);
}
private void setData(String data) {
VaadinSession.getCurrent().setAttribute("data", data);
}
}
图 7-8 将帮助您理解代码。
图 7-8
显示存储在VaadinSession中的数据的视图
VaadinSession是一个可以存储键值对的映射。应用的每个用户都有自己的VaadinSession对象。getData()方法从会话中读取一个带有键data的值。setData(String)方法在会话中用键data设置一个值。这些方法分别被textArea对象和Button组件用来可视化和存储值。
如果没有数据,我们希望将用户重定向到不同的视图。我们将这个新视图称为NoDataView,稍后我们将实现它。首先,我们需要连接逻辑来检查是否有数据,并在需要时进行重定向。这是HomeView班的职责。Vaadin 使用观察者模式来实现这一点。我们需要做的就是在HomeView类中实现BeforeEnterObserver接口:
@Route("data")
public class DataView extends Composite<Component>
implements BeforeEnterObserver {
...
@Override
public void beforeEnter(BeforeEnterEvent event) {
if (getData().isEmpty() || getData().get().isEmpty()) {
event.rerouteTo(NoDataView.class);
}
}
...
}
我们必须用我们需要的逻辑实现beforeEnter(BeforeEnterEvent)方法。我们检查是否没有数据,如果有,我们使用rerouteTo(Class)方法将用户重定向到NoDataView组件。下面是该组件的实现:
@Route("no-data")
public class NoDataView extends Composite<Component> {
@Override
protected Component initContent() {
return new VerticalLayout(
new H1("Oops! There's no data \uD83D\uDE31"),
new Button("Create data \uD83E\uDDEF", event -> {
VaadinSession.getCurrent()
.setAttribute("data", "This is the default data");
UI.getCurrent().navigate(DataView.class);
Notification.show("Default data created");
})
);
}
}
我们使用 Unicode 代码添加了一些有趣的表情符号。但是我们不要被这些分散注意力!让我们了解一下我们实现了什么。如果转到 http://localhost:8080 ,Vaadin 会创建一个HomeView类的实例,并在浏览器中呈现出来(图 7-7 )。这个类包含一个RouterLink到DataView组件。你点击这个链接,Vaadin 创建一个DataView的实例。然而,在浏览器中呈现组件之前,它调用我们实现的beforeEnter(BeforeEnterEvent)方法。该方法意识到会话中没有数据,并重定向(或重新路由)到NoDataView组件,而不是DataView组件(图 7-9 )。
图 7-9
由“输入前”处理程序导致的重新路由
单击 Create data 按钮后,带有关键数据的默认值被存储在会话中,代码将用户再次引导到DataView组件。这一次,beforeEnter(BeforeEnterEvent)方法没有重定向,因为现在它可以看到数据了。视图最终使用TextArea组件中的默认数据进行渲染(图 7-10 )。
图 7-10
在TextArea组件中呈现的默认数据
离开观察者之前
业务应用中的另一个常见用例是警告用户,如果他们离开视图,更改可能会丢失。我们可以在用户即将离开视图之前的事件中实现这一点。这是通过实现BeforeLeaveObserver来完成的。假设我们想问用户,当他们更改了TextArea中的值时,他们是否真的想离开视图。以下是如何:
@Route("data")
public class DataView extends Composite<Component>
implements BeforeEnterObserver, BeforeLeaveObserver {
...
@Override
public void beforeLeave(BeforeLeaveEvent event) {
if (!getData().get().equals(textArea.getValue())) {
ContinueNavigationAction action = event.postpone();
Dialog dialog = new Dialog();
dialog.add(
new Text("Are you sure?"),
new Button("Yeah", clickEvent -> {
dialog.close();
action.proceed();
})
);
dialog.open();
}
}
...
}
beforeLeave(BeforeLeaveEvent)方法检查textArea组件中的值是否与存储在VaadinSession中的值不同,如果是这样,它通过调用postpone()方法暂停导航操作,并显示一个Dialog让用户确认。如果用户确认,对话框关闭,通过调用proceed()方法继续导航操作。您可以通过导航到DataView路线、编辑数据并点击 Home 链接来测试这一点。图 7-11 显示了结果。
图 7-11
由BeforeLeaveObserver推迟的导航动作
URL 参数
URL 参数是通过请求视图的 URL 传递给视图的值。例如,用@Route("users")注释的视图实现通过http://localhost:8080/users*访问。类似*的网址 http://localhost:8080/users?selectedId=3 包含一个值为 3 的 URL 参数( selectedId )。URL 参数可以作为 URL 中路径的一部分传递。例如,在*http://localhost:8080/users/3*中,数字 3 可以是一个 URL 参数,视图可以用它来选择 ID 为 3 的用户。
URL 模板
Vaadin 中处理 URL 参数的最强大的特性叫做 URL 模板。URL 模板用@Route注释指定,匹配的 URL 参数用BeforeEnterObserver处理。看一下这个例子:
@Route("template-parameter/:value")
public class TemplateParameterView extends Composite<Component>
implements BeforeEnterObserver {
private H1 text = new H1();
@Override
protected Component initContent() {
return new VerticalLayout(text);
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
Optional<String> value = event.getRouteParameters()
.get("value");
setValue(value.orElse("(no value)"));
}
private void setValue(String value) {
text.setText(value);
}
}
请密切注意@Route("template-parameter/:value")中的语法。 :value 部分表示视图期望在 URL 的那个位置有一个String。该字符串稍后在beforeEnter(BeforeEnterEvent)方法中被检索,并被设置为H1组件的文本内容(text)。图 7-12 显示了一个例子。密切注意浏览器中的 URL。
图 7-12
使用 URL 模板处理的 URL 参数
如果您请求不带参数的 URL(*http://localhost:8080/template-parameter*),您将得到一个错误,尽管视图用一个`Optional`对此进行了检查。如果您希望 URL 参数可以不存在(或为空),您必须在 URL 模板中使用?字符:
@Route("template-parameter/:value?")
您可以声明多个 URL 参数,并将它们放在 URL 模板中的任何位置。例如:
@Route("companies/:companyId/:employeeId/edit")
像*http://localhost:8080/companies/6/7/edit*这样的 URL 会匹配这个路由。
您还可以使用通配符(*)来匹配 URL 的最后一段。例如:
@Route("api/:path*")
您可以从BeforeEnterEvent对象获取值,如下所示:
var path = event.getRouteParameters().get("path").orElse("");
如果请求 URL*http://localhost:8080/API/com/company/list*,那么`path`变量将包含字符串`com/company/list`。
也可以使用正则表达式。例如:
@Route("companies/:companyId?([0-9]/edit")
这将只匹配在 /edit 前包含一位数的 URL。
Tip
使用getRouteParameters()返回的RouteParameters对象的getInteger(String)和getLong(String)方法可以得到Integer和Long值。
类型化参数
HasUrlParameter接口是 URL 模板的替代,它允许您获取特定类型的 URL 参数。以下示例显示了如何从 URL 获取一个Integer值:
@Route("typed-parameter")
public class TypedParameterView extends Composite<Component>
implements HasUrlParameter<Integer> {
private H1 text = new H1();
@Override
protected Component initContent() {
return new VerticalLayout(text);
}
@Override
public void setParameter(BeforeEvent beforeEvent,
Integer number) {
text.setText("" + number);
}
}
请参见图 7-13 注意 URL 和 UI 中呈现的值。
图 7-13
由HasUrlParameter处理的类型化参数
Tip
您可以在setParameter(BeforeEvent, T)方法的参数中使用@OptionalParameter注释,使参数可选。如果使用这个注释,记得检查空值。
查询参数
查询参数(或查询字符串)是一组键值对,包含在 URL 中的问号字符(?)之后。例如,我们可以使用 URL http://localhost:8080/query-parameter?userId=13在userId参数中传递一个类似于13的值。以下示例显示了如何使用此查询参数:
@Route("query-parameter")
public class QueryParameterView extends Composite<Component>
implements BeforeEnterObserver {
private H1 text = new H1("(no user ID)");
@Override
protected Component initContent() {
return new VerticalLayout(text);
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
Location location = event.getLocation();
Map<String, List<String>> list = location
.getQueryParameters().getParameters();
List<String> userIds = list.get("userId");
if (!list.isEmpty()) {
text.setText(userIds.get(0));
}
}
}
注意我们是如何获得参数userId的值列表的。这是因为 URL 可能多次包含该参数,因此在同一个键下获得多个值。在前面的例子中,我们只使用列表中的第一个值。
更新页面标题
为了结束这一章,让我们看看如何设置当你请求一个视图时浏览器显示的标题。您可能已经注意到,到目前为止,浏览器标签中显示的标题显示的是 URL。您可以使用@PageTitle注释轻松设置标题(参见图 7-14 ):
图 7-14
配置了页面标题的浏览器选项卡
@PageTitle("This is the title")
@Route("page-title")
public class PageTitleView extends Composite<Component> {
@Override
protected Component initContent() {
return new H1("Hello!");
}
}
要在运行时设置标题,可以使用HasDynamicTitle界面(见图 7-15 ):
图 7-15
动态页面标题
@Route("dynamic-page-title")
public class DynamicPageTitleView extends Composite<Component>
implements HasDynamicTitle {
@Override
protected Component initContent() {
return new H1("Hello again, and bye for now!");
}
@Override
public String getPageTitle() {
return "Title at " + LocalDateTime.now();
}
}
Caution
不要同时使用@PageTitle和HasDynamicTitle。当您这样做时,会引发异常。
摘要
就在那里!在这一章中,你学习了在 Vaadin 中将 URL 和视图连接起来所需要的一切。您看到了如何使用@Route注释或者在运行时使用RouteConfiguration类动态地定义路由。您了解了如何使用路由器布局来修饰视图,路由器布局允许您实现 UI 结构,包括页眉、菜单、页脚和 web 页面中的任何其他区域。您还了解了导航生命周期,以及如何在用户进入视图之前和离开视图之前连接您的逻辑。您看到了如何使用 URL 参数来配置使用 URL 模板、类型化参数和查询参数的 UI。
下一章将介绍现代 web 应用中一个令人兴奋的特性:服务器推送。