个人工具

如何创建一个原生AOM输入域组件

智志
www.operamasks.org

2008年10月9日


本文介绍如何在AOM中加入一个具有特殊功能的输入域组件:一个带有时间选择功能的日期组件。包括了从Javascript形态的EXT扩展控件开始到将这个控件纳入AOM体系成为原生AOM组件的全过程。由于输入域类组件体系在AOM中已经较为成熟,因此在本文中并不需关注Ajax特性(通过继承即可获得支持),主要着眼点是如何让组件在首次渲染时能正确展现,并且能在JSF的管理下与服务器进行交互。本文涉及修改AOM源代码,预期读者是AOM的开发人员和对AOM开发感兴趣的社区成员。

1. 准备工作:从一个Ext扩展开始

实际应用中,用户往往希望能通过可视化的图形组件同时输入日期和时间,AOM2.1版本中没有提供现成的组件,现在我们就来动手做一个。

创建AOM组件的第一步工作是准备好客户端的控件实现,也就是说必须至少先用html和javascript在浏览器上实现这个控件。

在网上可以轻易找到解决这个需求的开源的Ext扩展,例如http://extjs.com/forum/showthread.php?t=21931。这个扩展的效果基本符合我们的要求:

但通过阅读源代码,我发现这个扩展如果要作为一个通用的组件,还至少需要加入以下功能:

  1. 多语言支持(原来代码中的“时”“分”等字符是硬编码的,从截图可以看出在英文环境下仍然显示为中文)。

  2. 允许设置小时和分钟的增量。例如按一下右箭头可以增加5分钟。

  3. 默认时刻取当前时刻,但应该对齐到增量间隔(如果设置了的话),而且应该可以设置是否允许超过当前时刻。例如增量是5分钟,当前时刻是49分,用户应该可以设置应该就近对齐到50分,还是保守点对齐到45分。

  4. 用箭头改变时刻时,应支持超界循环,且符合需求3。例如小时增加超过23后则应该变为0。分钟如果增量为5,减小超过0之后应变为55(不是59)。

  5. 允许设置过滤器,使时刻在可选范围内变动,且符合需求3和需求4。例如上班时间是8点至12点和14点至17点,应允许用户通过设置,使得12的下一位是14,17的下一位是8。

对脚本代码进行修改后,新组件的效果如下:

注意截图中隐藏了鼠标指针,此时鼠标正指向分钟的右箭头,我在箭头上加了点小效果,鼠标经过时会更改样式变成空心,鼠标停留时显示“加N分钟”的说明。

组件的客户端脚本用法是:

datetime = new Ext.form.DateField({
format:'Y-m-d H:i',
value:'',
width:300,
menu:new Ext.om.DatetimeMenu({minuteStep:5})
});

可以看出这个控件的核心是Ext.om.DatetimeMenu。改动之后加入了以下初始化参数(全部可选):

  • hourStep : 指定小时增量,默认为1;

  • minuteStep : 指定分钟增量,默认为5;

  • limitedAlign : 指定是否禁止默认时刻超过当前时刻对齐到最接近的时间间隔,默认为false;

  • hourFilter : 指定一个javascript函数对小时数进行过滤,返回true表示此时刻合法;

  • minuteFilter : 指定一个javascript函数对分钟数进行过滤,返回true表示此时刻合法。

现在把脚本代码文件改名为DateTime-debug.js,注意文件名中的“-debug”是有讲究的,原因下面会说明。这样,我们有了一个可用的客户端控件脚本,并且知道了它的用法(很重要)。现在,我们开始着手把它整合到AOM中去。

2. 引入脚本

为了在能让组件工作,最终渲染出来的页面(以下简称最终页面)必须正确引入 组件脚本代码文件(DateTime-debug.js),这包括两件事:

第一,最终页面上要(至少)包含这样一句:

<script type="text/javascript" src=".../DateTime-debug.js"></script>

第二,DateTime-debug.js文件必须位于在这个“...”的位置上。

第二点很容易办到,只需要把DateTime-debug.js和其他脚本资源放在一起就行了。因此我们把它复制到AOM工程的src/META-INF/resource/ext/om目录中,和其他AOM专有的脚本放在一起。

还需要考虑一点是,DateTime-debug.js是开发客户端控件时用的,包含了缩进格式。作为最终使用的脚本资源则浪费了不必要的带宽,应该考虑对其进行压缩。这里我使用了YUICompressor (http://www.julienlecomte.net/yuicompressor/),在AOM工程的src/META-INF/resource/ext/om目录中使用以下命令行对DateTime-debug.js进行压缩(假设YUICompressor.jar放在D盘根目录):

java -jar D:\YUICompressor.jar -v -o DateTime.js DateTime-debug.js

执行后得到DateTime.js,文件大小由19kb减小到11kb。这样,通常情况下我们可以引入较小的DateTime.js,在需要进行客户端调试时,通过在operamasks.xml中加入以下设置:

<debug-mode> 
<uncompressed-js>true</uncompressed-js>
</debug-mode>

AOM会自动在文件名后加上-debug,改为引入DateTime-debug.js。这正是我们把可编辑的脚本代码文件改名为XXX-debug.js的原因。

现在回过头来看看第一点,事实上它只解决了部分问题,从前一节的控件用法可以看出,这个控件除了需要在DateTime.js中定义的Ext.om.DatetimeMenu,还需要依赖Ext.form.DateField类。这是在ext/package/datemenu.js中定义的,自然也需要引入这个文件。天知道Ext.form.DateField还依赖什么类,还需要引入什么脚本文件?

为了解决脚本包依赖的问题,AOM中使用了一个叫ext-packages.xml的包依赖描述文件,它位于AOM工程的

src\org\operamasks\faces\render\widget\yuiext

目录下,在这个文件中找到以下声明:

<!--
Form - Date Field
Files:
DateField.js
Dependencies:
TriggerField.js Field.js TextField.js Component.js DateMenu.js DatePicker.js
DateItem.js Date.js Layer.js Shadow.js MenuMgr.js Menu.js BasicItem.js Adapter.js
KeyNav.js ClickRepeater.js
-->
<package name="Ext.form.DateField">
<requires name="Ext.form.Field"/>
<requires name="Ext.menu.DateMenu"/>
</package>

在后面加入(并不强制要求,只不过放在一起好看一点):

<!--
Form - DateTime Field
Files:
DateTime.js
Dependencies:
TriggerField.js Field.js TextField.js Component.js DateMenu.js DatePicker.js
DateItem.js Date.js Layer.js Shadow.js MenuMgr.js Menu.js BasicItem.js Adapter.js
KeyNav.js ClickRepeater.js
-->
<package name="Ext.form.DateTimeField" file="/ext/om/DateTime.js">
<requires name="Ext.form.DateField"/>
</package>

这里,我们把这个包命名为Ext.form.DateTimeField,这个名称在后面会用到。并且声明了使用这个包需要引入/ext/om/DateTime.js文件,同时这个包依赖Ext.form.DateField包(参考上面声明)。这样,引入Ext.form.DateTimeField包时,AOM框架会自动根据这里定义的依赖关系在最终页面中渲染出所有必须引入的脚本。

到这里,关于客户端脚本的工作就告一段落了,现在我们(终于)可以开始写点java代码了。

3. 创建组件类

显然,这个组件类应该从UIDateField继承。我们创建一个UIDateTimeFieldBase类,继承UIDateField:

package org.operamasks.faces.component.form.base;
...
public abstract class UIDateTimeFieldBase extends UIDateField {
/***
* 小时增量
*/
protected Integer hourStep;

/***
* 分钟增量
*/
protected Integer minuteStep;

/***
* 小时过滤器
*/
protected String hourFilter;

/***
* 分钟过滤器
*/
protected String minuteFilter;

/***
* 默认值对齐增量间隔时刻时是否允许超过当前时间。
* 此值为true时,默认值总是取小于当前时间的符合增量间隔的时刻;
* 此值为false时,默认值对齐增量间隔时刻时选择最接近的间隔,允许超过当前时间。
* 默认为false。
*/
protected Boolean limitedAlign;
}

慢着,你也许会问,为什么要一个UIDateTimeFieldBase类,而不直接用UIDateTimeField类呢?就这么几个属性多出来一个抽象类,这难道就是传说中的过度设计吗?其实,在真正的UIDateTimeField类中要写的远远不只这几个属性定义,而需要至少包括:

  • 设置自身的RendererType

  • 提供一个COMPONENT_TYPE常量(可选但推荐)

  • 为每个属性实现getter,必须处理好私有域与绑定的EL求值的优先关系。

  • 为每个属性实现setter,对于基础类型属性,应另行加入一个标志位表示此属性是否被显式设置了。(引用类型属性可以用null来表示未设置)

此外,还需要:

  • 在配置文件中注册组件标签,并配置COMPONENT_TYPE的对应关系

  • 在配置文件中配置COMPONENT_TYPE与组件类的对应关系

  • 在配置文件中进行渲染器的相关配置

  • 编写对应的Tag类(为Apusic Studio提供支持),等等

为了简化这些工作量,AOM提供了一种从组件基类自动生成组件类的机制。现在,你可以抛开以上列举的一大摊事,只需要做两件事:

第一,定义一个RendererHandler(后面会用到):

package org.operamasks.faces.render.form;
public class DateTimeFieldRenderHandler {
}

第二,在上面定义的UIDateTimeFieldBase类上加上两个标注:

@ComponentMeta(tagName = "dateTimeField")
@Component(renderHandler = DateTimeFieldRenderHandler.class)
public class UIDateTimeFieldBase extends UIDateField {
...

其中,@ComponentMeta标注告诉AOM这个组件的一些配置信息,这里关键是指定了这个组件类对应的页面标签名。@Component标注告诉AOM这是一个组件基类,并且指定了由DateTimeFieldRenderHandler类来负责这个组件类的渲染。

由于组件类一旦写好就比较稳定,因此生成组件类的行为通常是屏蔽的,为了打开自动生成组件类的功能,我们打开org.operamasks.faces.component.form.base包(UIDateTimeFieldBase所在的包)下的package-info.java文件。此文件非常简单:

@ComponentPackage(value="org.operamasks.faces.component.form.impl",
catalog="form",enable=false)
@ComponentTagPackage(value="org.operamasks.faces.webapp.form")
package org.operamasks.faces.component.form.base;

这里设置了这个包下的组件基类生成组件类时的配置信息,其中,@ComponentPackage标签的:

  • value属性指定了生成的组件类所在的包名。

  • catalog指定了页面标签所在的配置文件。dateTimeField标签的配置将输出到form.taglib.xml与form-config.xml中,这两个文件里配置了标签的命名空间。至此已经确定了w:dateTimeField标签与org.operamasks.faces.component.form.impl.UIDateTimeField类的对应关系。

  • enable属性决定了是否打开自动生成功能。

因此,我们要做的只是把enable属性设为true。当然,如果是在一个全新的包里编写组件基类,则应该为这个包编写一个package-info.java文件。

万事具备,现在在eclipse的ant视图中执行AOM工程里build.xml的process-component任务,完成后刷新一下工程,你会发现在org.operamasks.faces.component.form.impl包里已经生成了UIDateTimeField类。此外,工程中都某些配置文件被修改过,AOM已经自动完成了相关的配置,现在在xhtml页面中写<w:dateTimeField>就已经不再报错并能绑定到UIDateTimeField类上了。

但是如果现在运行带有<w:dateTimeField>的页面,是什么都看不到的,因为我们还没有为它编写渲染器。

4. 编写渲染器

在上一步中我们其实已经创建了渲染器类DateTimeFieldRenderHandler,并且指定了它与UIDateTimeField的对应关系,接下来我们需要填入渲染逻辑。

4.1. 确定继承关系

与编写组件类一样,我们不要去重新发明轮子,优先考虑从现有的渲染机制中继承。事实上AOM中对输入域类组件的渲染体系已经很成熟,从现有渲染类继承可以获得许多方便的特性。编写渲染逻辑最重要的参考资料是这个组件的客户端用法,因为这正是渲染的最终结果,我们来复习一下:

datetime = new Ext.form.DateField({
format:'Y-m-d H:i',
value:'',
width:300,
menu:new Ext.om.DatetimeMenu({minuteStep:5})
});

显然,这基本上和标准UIDateField的渲染结果是一样的,不同点是format从“Y-m-d”改成了“Y-m-d H:i”,并且多了menu的参数配置。因此,很自然地可以从DateFieldRendererHandler继承:

public class DateTimeFieldRenderHandler extends DateFieldRenderHandler {
...

4.2. 处理引入脚本

Section 2, “引入脚本”中说过,渲染最终页面时需要引入相关脚本,我们已经配置好了脚本文件的依赖关系,现在我们只需要告诉AOM框架如何引入,在DateTimeFieldRenderHandler类上加入以下标注

@ExtClass("Ext.form.DateField")
@DependPackages({"Ext.form","Ext.form.DateTimeField"})
public class DateTimeFieldRenderHandler extends DateFieldRenderHandler {

其中,@ExtClass标签告诉AOM两件事:

  1. 引入在ext-package.xml中配置的名为Ext.form.DateField的包的所有依赖脚本文件

  2. 通知AOM的输入框渲染体系,使用new Ext.form.DateField来创建客户端对象。

@DependPackages则进一步指明其他在ext-package.xml中定义的依赖包。注意这里由于我们需要使用new Ext.form.DateField来创建客户端对象,因此在@ExtClass中需要指定Ext.form.DateField,而这个组件的关键包Ext.form.DateTimeField(参考Section 2, “引入脚本”,对应DateTime.js脚本文件)则通过@DependPackages标注引入。

4.3. 渲染构造参数

事实上,到现在为止,如果在页面上放一个<w:dateTimeField>组件,已经能渲染出来一个与<w:dateField>一模一样的客户端控件了(因为在DateTimeFieldRenderHandler类里一行代码都还没写)。从dateTimeField的客户端用法来看,与dateField的差异就在于客户端对象的构造参数,Ext文档称之为“config”,因此在AOM的实现代码中有一个模型与之对应,名为ExtConfig类。

简单来说,ExtConfig类可以自动把组件基类中标注为@ExtConfigOption的属性自动整理为合法的构造参数文本,但在本例中不需要涉及此功能,在这里我们覆盖一个在基类AbstractFieldRenderHandler中定义的模板方法(Template设计模式)processExtConfig()来对构造参数进行定制,这个模板方法让子类在渲染构造参数前有机会修改ExtConfig对象:

public class DateTimeFieldRenderHandler extends DateFieldRenderHandler {

private static final String NEW_MENU_STATEMENT = "new Ext.om.DatetimeMenu(";

@Override
protected void processExtConfig(FacesContext context, UIComponent component, ExtConfig config) {
super.processExtConfig(context, component, config);
UIDateTimeField field = (UIDateTimeField) component;
StringBuilder buf = new StringBuilder();
buf.append(NEW_MENU_STATEMENT).append("{");
Integer hourStep = field.getHourStep();
if (hourStep != null && hourStep > 0 && hourStep < 24) {
buf.append("hourStep:").append(hourStep.toString()).append(",");
}
Integer minuteStep = field.getMinuteStep();
if (minuteStep != null && minuteStep > 0 && minuteStep < 60) {
buf.append("minuteStep:").append(minuteStep.toString()).append(",");
}
Boolean limitedAlign = field.getLimitedAlign();
if (Boolean.TRUE == limitedAlign) {
buf.append("limitedAlign:true,");
}
String hourFilter = field.getHourFilter();
if (hourFilter != null) {
buf.append("hourFilter:").append(hourFilter).append(",");
}
String minuteFilter = field.getMinuteFilter();
if (minuteFilter != null) {
buf.append("minuteFilter:").append(minuteFilter).append(",");
}
buf.deleteCharAt(buf.length() - 1);
if (buf.length() == NEW_MENU_STATEMENT.length()) {
buf.append(")");
} else {
buf.append("})");
}
config.set("menu", buf.toString(), true);
}

显然,这里负责构造menu参数值的文本,并通过config.set()方法填入到传入的ExtConfig对象里。值得注意的是,由于在构建Ext.om.DatetimeMenu的参数时无法使用ExtConfig机制,所以必须使用一堆的非null判断来保证正确性,而ExtConfig机制则大大简化了类似的重复工作。

对于另一个参数format,其实也可以在这里一起处理,但我们可以在DateTimeFieldRenderHandler的直接父类DateFieldRenderHandler添加一个更方便的模板方法getDefaultFormat(),用来获取缺省的日期显示格式:

原来DateFieldRenderHandler中获取默认格式模式的方式:

public class DateFieldRenderHandler extends TextFieldRenderHandler {
...
protected void processExtConfig(FacesContext context, UIComponent component, ExtConfig config) {
...
String format = DateTimeFormatUtils.DEFAUTL_DATE_FORMAT;

修改后的方式:

public class DateFieldRenderHandler extends TextFieldRenderHandler {
...
protected void processExtConfig(FacesContext context, UIComponent component, ExtConfig config) {
...
format = getDefaultFormat();
...
}

protected String getDefaultFormat() {
return DateTimeFormatUtils.DEFAUTL_DATE_FORMAT;
}

现在我们可以在DateTimeFieldRenderHandler中覆盖这个方法:

public class DateTimeFieldRenderHandler extends DateFieldRenderHandler {
...
@Override
protected String getDefaultFormat() {
return DateTimeFormatUtils.DEFAULT_DATETIME_FORMAT;
}
}

其中DateTimeFormatUtils.DEFAULT_DATETIME_FORMAT的值为"yyyy-MM-dd HH:mm"。注意AOM统一使用java中的日期格式转义模式,上面的模式在渲染时将被转换为等价的客户端格式模式:“Y-m-d H:i”。

5. 测试

至此,这个组件就大功告成了。对于其他某些输入域组件,还需要编写对应的转换器和校验器。但本例中由于继承的UIDateField所用的DefaultDateTimeConverter就有时间转换能力,因此不用另外编写。现在,我们可以编写一个简单的页面对这个新组件进行测试:

页面:

<w:page title="Insert title here">
<w:form>
<w:dateTimeField limitedAlign="true" hourFilter="chk_workhour"
format="yyyy年MM月dd日HH时mm分" width="250" />
<w:button id="action" />
</w:form>
</w:page>
<script>
<!--
function chk_workhour(v) {
return (v >= 8 && v <= 12) || (v >= 14 && v <= 18);
}
-->
</script>

Bean

import java.util.Date;

public class TestDateTimeFieldBean implements Serializable {
@Bind
Date datetime;

@Action
private void action() {
System.out.println(datetime.toString());
}
}

展现效果:

提交后控制台输出:

支持!希望多出这样的文章!!

张贴人: cainiao 2008-10-10 22:32

支持!希望多出这样的文章!!

强大的文章

张贴人: feitianbubu 2008-10-14 08:57

很好,学习了,这样利用AOM可以做更自由的事了

能有源代码就好了。

张贴人: FriedChicken 2008-11-05 19:12

能有源代码就好了。