前面的帖子里曾说过如何使用布局,当时主要集中在ToolbarLayout和FlowLayout(统称OrderedLayout),还有很多应用程序使用的是可以自由拖动子图形的布局,在GEF里称为XYLayout,而且这样的应用多半会需要在图形之间建立一些连接线,比如下图所示的情景。连接的出现在一定程度上增加了模型的复杂度,连接线的刷新也是GEF关注的一个问题,这里就主要讨论这类应用的实现,并将特别讨论一下展开/折叠(expand/collapse)功能的实现。请点这里下载本篇示例代码。
图1 使用XYLayout的应用程序
还是从模型开始说起,使用XYLayout时,每个子图形对应的模型要维护自身的坐标和尺寸信息,这就在模型里引入了一些与实际业务无关的成员变量。为了解决这个问题,一般我们是让所有需要具有这些界面信息的模型元素继承自一个抽象类(如Node),而这个类里提供如point、dimension等变量和getter/setter方法:
protected Point location = new Point(0, 0);//位置
protected Dimension size = new Dimension(100, 150);//尺寸
protected String name = "Node";//标签
protected List outputs = new ArrayList(5);//节点作为起点的连接
protected List inputs = new ArrayList(5);//节点作为终点的连接
…
}
EditPart方面也是一样的,如果你的应用程序里有多个需要自由拖动和改变大小的EditPart,那么最好提供一个抽象的EditPart(如NodePart),在这个类里实现propertyChange()、createEditPolicy()、active()、deactive()和refreshVisuals()等常用方法的缺省实现,如果子类需要扩展某个方法,只要先调用super()再写自己的扩展代码即可,典型的NodePart代码如下所示,注意它是NodeEditPart的子类,后者是GEF专为具有连接功能的节点提供的EditPart:
public void propertyChange(PropertyChangeEvent evt) {
if (evt.getPropertyName().equals(Node.PROP_LOCATION))
refreshVisuals();
else if (evt.getPropertyName().equals(Node.PROP_SIZE))
refreshVisuals();
else if (evt.getPropertyName().equals(Node.PROP_INPUTS))
refreshTargetConnections();
else if (evt.getPropertyName().equals(Node.PROP_OUTPUTS))
refreshSourceConnections();
}
protected void createEditPolicies() {
installEditPolicy(EditPolicy.COMPONENT_ROLE, new NodeEditPolicy());
installEditPolicy(EditPolicy.GRAPHICAL_NODE_ROLE, new NodeGraphicalNodeEditPolicy());
}
public void activate() {…}
public void deactivate() {…}
protected void refreshVisuals() {
Node node = (Node) getModel();
Point loc = node.getLocation();
Dimension size = new Dimension(node.getSize());
Rectangle rectangle = new Rectangle(loc, size);
((GraphicalEditPart) getParent()).setLayoutConstraint(this, getFigure(), rectangle);
}
//以下是NodeEditPart中抽象方法的实现
public ConnectionAnchor getSourceConnectionAnchor(ConnectionEditPart connection) {
return new ChopBoxAnchor (getFigure());
}
public ConnectionAnchor getSourceConnectionAnchor(Request request) {
return new ChopBoxAnchor (getFigure());
}
public ConnectionAnchor getTargetConnectionAnchor(ConnectionEditPart connection) {
return new ChopBoxAnchor (getFigure());
}
public ConnectionAnchor getTargetConnectionAnchor(Request request) {
return new ChopBoxAnchor(getFigure());
}
protected List getModelSourceConnections() {
return ((Node) this.getModel()).getOutgoingConnections();
}
protected List getModelTargetConnections() {
return ((Node) this.getModel()).getIncomingConnections();
}
}
从代码里可以看到,NodePart已经通过安装两个EditPolicy实现关于图形删除、移动和改变尺寸的功能,所以具体的NodePart只要继承这个类就自动拥有了这些功能,当然模型得是Node的子类才可以。在GEF应用程序里我们应该善于利用继承的方式来简化开发工作。代码后半部分中的几个getXXXAnchor()方法是用来规定连接线锚点(Anchor)的,这里我们使用了在Draw2D那篇帖子里介绍过的ChopBoxAnchor作为锚点,它是Draw2D自带的。而代码最后两个方法的返回值则规定了以这个EditPart为起点和终点的连接列表,列表中每一个元素都应该是Connection类型,这个类是模型的一部分,接下来就要说到。
在GEF里,节点间的连接线也需要有自己的模型和对应的EditPart,所以这里我们需要定义Connection和ConnectionPart这两个类,前者和其他模型元素没有什么区别,它维护source和target两个节点变量,代表连接的起点和终点;ConnectionPart继承于GEF的AbstractConnectionPart类,请看下面的代码:
protected IFigure createFigure() {
PolylineConnection conn = new PolylineConnection();
conn.setTargetDecoration(new PolygonDecoration());
conn.setConnectionRouter(new BendpointConnectionRouter());
return conn;
}
protected void createEditPolicies() {
installEditPolicy(EditPolicy.COMPONENT_ROLE, new ConnectionEditPolicy());
installEditPolicy(EditPolicy.CONNECTION_ENDPOINTS_ROLE, new ConnectionEndpointEditPolicy());
}
protected void refreshVisuals() {
}
public void setSelected(int value) {
super.setSelected(value);
if (value != EditPart.SELECTED_NONE)
((PolylineConnection) getFigure()).setLineWidth(2);
else
((PolylineConnection) getFigure()).setLineWidth(1);
}
}
在getFigure()里可以指定你想要的连接线类型,箭头的样式,以及连接线的路由(走线)方式,例如走直线或是直角折线等等。我们为ConnectionPart安装了一个角色为EditPolicy.CONNECTION_ENDPOINTS_ROLE的ConnectionEndpointEditPolicy,安装它的目的是提供连接线的选择、端点改变等功能,注意这个类是GEF内置的。另外,我们并没有把ConnectionPart作为监听器,在refreshVisuals()里也没有做任何事情,因为连接线的刷新是在与它连接的节点的刷新里通过调用refreshSourceConnections()和refreshTargetConnections()方法完成的。最后,通过覆盖setSelected()方法,我们可以定义连接线被选中后的外观,上面代码可以让被选中的连接线变粗。
看完了模型和Editpart,现在来说说EditPolicy。我们知道,GEF提供的每种GraphicalEditPolicy都是与布局有关的,你在容器图形(比如画布)里使用了哪种布局,一般就应该选择对应的EditPolicy,因为这些EditPolicy需要对布局有所了解,这样才能提供拖动feedback等功能。使用XYLayout作为布局时,子元素被称为节点(Node),对应的EditPolicy是GraphicalNodeEditPolicy,在前面NodePart的代码中我们给它安装的角色为EditPolicy.GRAPHICAL_NODE_ROLE的NodeGraphicalNodeEditPolicy就是这个类的一个子类。和所有EditPolicy一样,NodeGraphicalNodeEditPolicy里也有一系列getXXXCommand()方法,提供了用于实现各种编辑目的的命令:
protected Command getConnectionCompleteCommand(CreateConnectionRequest request) {
ConnectionCreateCommand command = (ConnectionCreateCommand) request.getStartCommand();
command.setTarget((Node) getHost().getModel());
return command;
}
protected Command getConnectionCreateCommand(CreateConnectionRequest request) {
ConnectionCreateCommand command = new ConnectionCreateCommand();
command.setSource((Node) getHost().getModel());
request.setStartCommand(command);
return command;
}
protected Command getReconnectSourceCommand(ReconnectRequest request) {
return null;
}
protected Command getReconnectTargetCommand(ReconnectRequest request) {
return null;
}
}
因为是针对节点的,所以这里面都是和连接线有关的方法,因为只有节点才需要连接线。这些方法名称的意义都很明显:getConnectionCreateCommand()是当用户选择了连接线工具并点中一个节点时调用,getConnectionCompleteCommand()是在用户选择了连接终点时调用,getReconnectSourceCommand()和getReconnectTargetCommand()则分别是在用户拖动一个连接线的起点/终点到其他节点上时调用,这里我们返回null表示不提供改变连接端点的功能。关于命令(Command)本身,我想没有必要做详细说明了,基本上只要搞清了模型之间的关系,命令就很容易写出来,请下载例子后自己查看。
下面应郭奕朋友的要求说一说如何实现容器(Container)的折叠/展开功能。在有些应用里,画布中的图形还能够包含子图形,这种图形称为容器(画布本身当然也是容器),为了让画布看起来更简洁,可以让容器具有"折叠"和"展开"两种状态,当折叠时只显示部分信息,不显示子图形,展开时则显示完整的容器和子图形,见图2和图3,本例中各模型元素的包含关系是Diagram->Subject->Attribute。
图2 容器Subject3处于展开状态
要为Subject增加展开/折叠功能主要存在两个问题需要考虑:一是如何隐藏容器里的子图形,并改变容器的外观,我采取的方法是在需要折叠/展开的时候改变容器图形,将contentPane也就是包含子图形的那个图形隐藏起来,从而达到隐藏子图形的目的;二是与容器包含的子图形相连的连接线的处理,因为子图形有可能与其他容器或容器中的子图形之间存在连接线,例如图2中Attribute4与Attribute6之间的连接线,这些连接线在折叠状态下应该连接到子图形所在容器上才符合逻辑(例如在Subject3折叠后,原来从Attribute4到Attribute6的连接应该变成从Subject3到Atribute6的连接,见图3)。
图3 容器Subject3处于折叠状态
现在一个一个来解决。首先,不论容器处于什么状态,都应该只是视图上的变化,而不是模型中的变化(例如折叠后的容器中没有显示子图形不代表模型中的容器不包含子图形),但在容器模型中要有一个表示状态的布尔型变量collapsed(初始值为false),用来指示EditPart刷新视图。假设我们希望用户双击一个容器可以改变它的展开/折叠状态,那么在容器的EditPart(例子里的SubjectPart)里要覆盖performRequest()方法改变容器的状态值:
if (req.getType() == RequestConstants.REQ_OPEN)
getSubject().setCollapsed(!getSubject().isCollapsed());
}
注意这个状态值的改变是会触发所有监听器的propertyChange()方法的,而SubjectPart正是这样一个监听器,所以在它的propertyChange()方法里要增加对这个新属性变化事件的处理代码,判断当前状态隐藏或显示contantPane:
if (Subject.PROP_COLLAPSED.equals(evt.getPropertyName())) {
SubjectFigure figure = ((SubjectFigure) getFigure());
if (!getSubject().isCollapsed()) {
figure.add(getContentPane());
} else {
figure.remove(getContentPane());
}
refreshVisuals();
refreshSourceConnections();
refreshTargetConnections();
}
if (Subject.PROP_STRUCTURE.equals(evt.getPropertyName()))
refreshChildren();
super.propertyChange(evt);
}
为了让容器显示不同的图标以反应折叠状态,在SubjectPart的refreshVisuals()方法里要做额外的工作,如下所示:
super.refreshVisuals();
SubjectFigure figure = (SubjectFigure) getFigure();
figure.setName(((Node) this.getModel()).getName());
if (!getSubject().isCollapsed()) {
figure.setIcon(SubjectPlugin.getImage(IConstants.IMG_FILE));
} else {
figure.setIcon(SubjectPlugin.getImage(IConstants.IMG_FOLDER));
}
}
因为折叠后的容器图形应该变小,所以我让Subject对象覆盖了Node对象的getSize()方法,在折叠状态时返回一个固定的Dimension对象,该值就决定了Subject折叠状态的图形尺寸,如下所示:
public Dimension getSize() {
if (!isCollapsed())
return super.getSize();
else
return collapsedDimension;
}
上面的几段代码更改解决了第一个问题,第二个问题要稍微麻烦一些。为了在不同状态下返回正确的连接,我们要修改getModelSourceConnections()方法和getModelTargetConnections()方法,前面已经说过,这两个方法的作用是返回与节点相关的连接对象列表,我们要做的就是让它们根据节点的当前状态返回正确的连接,所以作为容器的SubjectPart要做这样的修改:
if (!getSubject().isCollapsed()) {
return getSubject().getOutgoingConnections();
} else {
List l = new ArrayList();
l.addAll(getSubject().getOutgoingConnections());
for (Iterator iter = getSubject().getAttributes().iterator(); iter.hasNext();) {
Attribute attribute = (Attribute) iter.next();
l.addAll(attribute.getOutgoingConnections());
}
return l;
}
}
也就是说,当处于展开状态时,正常返回自己作为起点的那些连接;否则除了这些连接以外,还要包括子图形对应的那些连接。作为子图形的AttributePart也要修改,因为当所在容器折叠后,它们对应的连接也要隐藏,修改后的代码如下所示:
Attribute attribute = (Attribute) getModel();
Subject subject = (Subject) ((SubjectPart) getParent()).getModel();
if (!subject.isCollapsed()) {
return attribute.getOutgoingConnections();
} else {
return Collections.EMPTY_LIST;
}
}
由于getModelTargetConnections()的代码和getModelSourceConnections()非常类似,这里就不列出其内容了。在一般情况下,我们只让一个EditPart监听一个模型的变化,但是请记住,GEF框架并没有规定EditPart与被监听的模型一一对应(实际上GEF中的很多设计就是为了减少对开发人员的限制),因此在必要时我们大可以根据自己的需要灵活运用。在实现展开/折叠功能时,子元素的EditPart应该能够监听所在容器的状态变化,当collapsed值改变时更新与子图形相关的连接线(若不进行更新则这些连接线会变成"无头线")。让子元素EditPart监听容器模型的变化很简单,只要在AttributePart的activate()里把自己作为监听器加到容器模型的监听器列表即可,注意别忘记在deactivate()里注销掉,而propertyChange()方法里是事件发生时的处理,代码如下:
super.activate();
((Attribute) getModel()).addPropertyChangeListener(this);
((Subject) getParent().getModel()).addPropertyChangeListener(this);
}
public void deactivate() {
super.deactivate();
((Attribute) getModel()).removePropertyChangeListener(this);
((Subject) getParent().getModel()).removePropertyChangeListener(this);
}
public void propertyChange(PropertyChangeEvent evt) {
if (evt.getPropertyName().equals(Subject.PROP_COLLAPSED)) {
refreshSourceConnections();
refreshTargetConnections();
}
super.propertyChange(evt);
}
这样,基本上就实现了容器的展开/折叠功能,之所以说"基本上",是因为我没有做仔细的测试(时间关系),目前的代码有可能会存在问题,特别是在Undo/Redo以及多重选择这些情况下;另外,这种方法只适用于容器里的子元素不是容器的情况,如果有多层的容器关系,则每一层都要做类似的处理才可以。