设计模式之可取消的选择 UI Pattern: Cancellable Selection

softwareD Pattern name 模式名: Cancellable Selection   Classification 分类: Behavioral

Motivation: 在多对象共享同一个编辑器(editor)或者在任何时间仅允许一个编辑器的情况下, 如果当前对象尚未保存, 界面不应该直接转换到新的对象 - 至少应该给用户警告并提示选择的机会. 例如: 多个tabs, 每个tab页面有可编辑的对象; 一个tree, table或者list上显示所有文件, 旁边为唯一共享的编辑器.

Approach:

将selection明确分成两个阶段 - 一是试图选择的阶段, 此阶段的任务是询问所有listeners是否同意选择; 如果第一阶段一致同意, 第二阶段才是实施真正的选择.

Implementation (Flex): 

import mx.controls.Tree;
import mx.core.EventPriority;
import mx.events.ListEvent;

/**
 * Dispatched when a selecting is attempted and also when it is confirmed.
 * @eventType com.insprise.common.ui.EventCancellableSelection.EVENT_EVENT_CANCELLABLE_SELECTION
 */
[Event(name="eventCancellableSelection", type="com.insprise.common.ui.EventCancellableSelection")]

/**
 * A tree that supports cancellable node selection.
 * You should use property currentItem to retrieve the confirmed selected item.
 * Events:
 * add listeners to EventCancellableSelection to cancel default or monitor initial selecting when its kind is SELECTING;
 * and retrieve current value when its kind is SELECTED.
 */
public class TreeCancellableSelect extends Tree {
  protected var _currentItem:*;

  // Constructor.
  public function TreeCancellableSelect() {
    super();
    addEventListener(ListEvent.CHANGE, onTreeSelChange);
    addEventListener(EventCancellableSelection.EVENT_CANCELLABLE_SELECTION, handleNodeSelecting, false, EventPriority.DEFAULT_HANDLER); // low priority
    // Ref: "Creating Default, Cancelable Event Handlers" - http://www.darronschall.com/weblog/2008/01/creating-default-cancelable-e...
  }

  // Called when user selected a OU from the tree.
  protected function onTreeSelChange(event:ListEvent):void {
    dispatchEvent(new EventCancellableSelection(true, EventCancellableSelection.KIND_SELECTING, selectedItem, _currentItem)); // cancellable.
  }

  // Called when EventCancellableSelection.KIND_SELECTING and all other handlers have already been invoked to check if action cancelled.
  protected function handleNodeSelecting(event:EventCancellableSelection):void {
    if(! event.isKindSelecting()) { // only handle selecting
      return;
    }
    if(event.isDefaultPrevented()) { // cancelled.
      selectedItem = _currentItem; // restore
    }else{ // ok
      doSetCurrentItem(selectedItem);
      dispatchEvent(new EventCancellableSelection(false, EventCancellableSelection.KIND_SELECTED, _currentItem, _currentItem));
    }
  }

  /**
   * The current selected item (confirmed).
   * When set, it dispatches cancellable EVENT_ITEM_SELECTING event; if set is done, it dispatches EVENT_ITEM_SELECTED event.
   */
  public function get currentItem():* {
    return _currentItem;
  }

  public function set currentItem(value:*):void {
    selectedItem = value;
    onTreeSelChange(null);
  }

  /**
   * Directly set the current item.
   * A subclass may override this method to handle actual selection - remember to call super.doSetCurrentItem in the first line.
   */
  protected function doSetCurrentItem(value:*):void {
    this._currentItem = value;
  }

} // End class

  import flash.events.Event;

/**
 * Repesenting a two staging selection with first stage cancellable: stage 1: selecting; stage 2: selected (if stage 1 is not cancelled).
 */
public class EventCancellableSelection extends Event
{
  /** Repesenting a two staging selection with first stage cancellable: stage 1: selecting; stage 2: selected (if stage 1 is not cancelled). */
  public static var EVENT_CANCELLABLE_SELECTION:String = "eventCancellableSelection";

  /** Event dispatched when an selecting attempt is made; cancellable */
  public static var KIND_SELECTING:String = "eventSelecting";
  /** Event dispatched when selection is confirmed. */
  public static var KIND_SELECTED:String = "eventSelected";

  /** Either of KIND_SELECTING or KIND_SELECTED */
  public var kind:String;
  /** The attempted selection */
  public var selectingValue:*;
  /** The existing value - previously selected value. */
  public var existingValue:*;

  /** Constructor */
  public function EventCancellableSelection(cancelable:Boolean, kind_:String, selectingValue_:*, existingValue_:* = null) {
    super(EVENT_CANCELLABLE_SELECTION, bubbles, cancelable);
    if(kind_ != KIND_SELECTING && kind_ != KIND_SELECTED) {
      throw new UnsupportedError(kind_);
    }
    this.kind = kind_;
    this.selectingValue = selectingValue_;
    this.existingValue = existingValue_;
    if(kind_ == KIND_SELECTING && !cancelable) {
      throw new Error("KIND_SELECTING should be cancelable. ");
    }
    if(kind_ == KIND_SELECTED && cancelable) {
      throw new Error("KIND_SELECTED should not be cancelable. ");
    }
  }

  public function isKindSelecting():Boolean {
    return kind == KIND_SELECTING;
  }

  public function isKindSelected():Boolean {
    return kind == KIND_SELECTED;
  }

  override public function clone():Event {
    return new EventCancellableSelection(cancelable, kind, selectingValue, existingValue);
  }

  override public function toString():String {
    return formatToString("EventCancellableSelection", "kind", "selectingValue", "existingValue");
  }

} // End class

// Usage:
var tree:TreeCancellableSelect = ...;
tree.addEventListener(EventCancellableSelection.EVENT_CANCELLABLE_SELECTION, onTreeSelection);
/**
 * Called when node selection on the tree is attempted or confirmed.
 */
protected function onTreeSelection(event:EventCancellableSelection):void {
  if(event.isKindSelecting()) { // attempting.
    if(editor.isDirty()) {
      // stop the default action and warn the user.
      event.preventDefault();
      Alert.show("Discard unsaved changes? ", "Warn", Alert.YES|Alert.CANCEL, null, onWarnUnsavedChangesAlertClose).data = event;
    }
  }else{ // selection changed.
    if(event.selectingValue === event.existingValue) { // same, do not care.
      return;
    }
    var myObject:* = event.selectingValue as OrgUnit;
    editor.setModel(myObject);
  }
}

// 为保存警告关闭响应
protected function onWarnUnsavedChangesAlertClose(e:CloseEvent):void {
  if(e.detail != Alert.YES) { // cancel selecting on tree
    tree.currentItem = EventCancellableSelection(Alert(e.target).data).existingValue;
    return;
  }else { // proceeds selections and discard unsaved changes
    ouTree.currentItem = EventCancellableSelection(Alert(e.target).data).selectingValue; // will trigger EventCancellableSelection
  }
}

 

Flex's Event.preventDefault()可参考: "Creating Default, Cancelable Event Handlers".

Implementation (Java):

Java实现更为简单, 跟Flex使用特别的asynchronized event dispatching不同, Java就是一般的同线程代码运行. 有两种方法, 一是直接用代码for each listener, call listener.method检查是否每个赞同, 然后再考虑下一步; 而是使用vetoable listeners, 详情可Google 'vetoable listeners java'. 下面为第二中方法启发性的例子, 而非真正的实现:

// Register for property change events on the bean
bean.addVetoableChangeListener(new MyVetoableChangeListener());

class MyVetoableChangeListener implements VetoableChangeListener {
    // This method is called every time the property value is changed
    public void vetoableChange(PropertyChangeEvent evt) throws PropertyVetoException {
        // Get the old value of the property
        Object oldValue = evt.getOldValue();

        // Get the new value of the property
        Object newValue = evt.getNewValue();

        // Determine if the change should be vetoed, thereby preventing the change
        boolean veto = false;
        if (veto) {
            throw new PropertyVetoException("the reason for the veto", evt);
        }
    }
}

 

优点

Cancellable Selection带来的好处是它清晰将选择过程分为两段, 将使用者从必须创建子类解脱出来, 转而允许使用简单的listener. 代码更加清晰.

缺点

Flex的标准Tree/Table等control使用直接选择, 用上面的TreeCancellableSelect需要一定的学习时间 - 需要将习惯的selectItem转而使用currentItem.