Flex でダイナミックにフォームアイテムの並び順とサイズをカスタマイズ可能にしたデモ

遡ること二年前、http://d.hatena.ne.jp/tilfin/20070224/1172319344 というエントリを書きました。
これを強化して公開することにしました。

デモは http://hatena.tilfin.net/CustomizeForm/ です。
左上にフロートするように並べられているフォームです。カスタマイズモードにすると

  • フォームのアイテムをドラッグアンドドロップで並び替えることが可能。
  • フォームアイテムの右端をドラッグして横幅サイズを変えることが可能。(サイズは最大横幅を 1 として 1/4, 1/3, 1/2, 2/3, 3/4, 1 のいずれか)

が可能になります。
特に目的があって作ったわけではないのでコンポーネントとしてきっちり作りこんではいません。ソースはべた貼りしておきます。改変などはご自由にどうぞ。
以前は Flex 2 で今回は Flex 3 です。設計が微妙なのは除いておいて(わかっています)、「ここはもっと簡略して書けるよ!」的なコメントお待ちしています(用意されているプロパティを使っていないとか)。

ファイル構成

src/
  net/tilfin/custom/
               images/hresize.gif // 横幅変更カーソル
               DraggingRegion.as
               DynamicFormExtender.as
               FormItemsLayoutManager.as
  Main.mxml

メイン MXML

<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" layout="vertical" xmlns:custom="net.tilfin.custom.*">
  <mx:Panel title="Demo / customize form items" x="0" y="0" width="100%" height="100%" cornerRadius="0">
    <mx:VBox width="100%">
      <mx:VBox width="100%">
        <mx:HBox>
          <mx:Button id="buttonToggle" toggle="true" label="Customize Mode" click="dfe.customizeMode = buttonToggle.selected"/>
        </mx:HBox>
      </mx:VBox>    
      <mx:Panel width="100%" height="100%" layout="absolute" title="Music Track Data">
        <mx:Form id="form" width="100%" height="100%" backgroundColor="#f9fbfe">
          <mx:VBox width="100%">
            <mx:HBox width="100%">
              <mx:FormItem label="Title" width="50%" labelWidth="80">
                <mx:TextInput id="textTitle" text="GIFT" width="100%"></mx:TextInput>
              </mx:FormItem>
              <mx:FormItem label="Artist" width="50%" labelWidth="80">
                <mx:TextInput id="textArtist" text="Mr.Children" width="100%"></mx:TextInput>
              </mx:FormItem>
            </mx:HBox>
          </mx:VBox>
          <mx:VBox width="100%">
            <mx:HBox width="100%">
              <mx:FormItem label="リリース日" width="50%" labelWidth="80">
                <mx:DateField id="dateRelease" formatString="YYYY/MM/DD" selectedDate="{new Date(2008, 12, 10)}" width="100%"/>
              </mx:FormItem>
              <mx:FormItem label="Album" width="50%" labelWidth="80">
                <mx:TextInput text="SUPERMARKET FANTASY" width="100%"></mx:TextInput>
              </mx:FormItem>
            </mx:HBox>
          </mx:VBox>
          <mx:VBox width="100%">
            <mx:HBox width="100%">
              <mx:FormItem label="Stars" width="33%" labelWidth="80">
                <mx:TextInput text="☆☆☆☆☆" width="100%"></mx:TextInput>
              </mx:FormItem>
              <mx:FormItem label="Track" width="33%" labelWidth="80">
                <mx:TextInput text="13" width="100%"></mx:TextInput>
              </mx:FormItem>
              <mx:FormItem label="Length" width="33%" labelWidth="80">
                <mx:TextInput text="5:51" width="100%"></mx:TextInput>
              </mx:FormItem>
            </mx:HBox>
          </mx:VBox>
          <mx:VBox width="100%">
            <mx:HBox width="100%">
              <mx:FormItem label="Memo" width="100%" labelWidth="80">
                <mx:TextInput text="TOY'S FACTORY Inc.(VAP)" width="100%"></mx:TextInput>
              </mx:FormItem>
            </mx:HBox>
          </mx:VBox>
        </mx:Form>
        <custom:DynamicFormExtender id="dfe" x="0" y="0" width="100%" height="100%" backgroundAlpha="0.12" backgroundColor="#222222"
          targetForm="{form}">
          <custom:effectOnSelect>
            <mx:Fade duration="180" alphaFrom="0.0" alphaTo="1.0"/>
          </custom:effectOnSelect>
          <custom:effectOnDrop>
            <mx:Glow duration="720" alphaFrom="0.6" alphaTo="0.0" blurXFrom="30" blurXTo="15" blurYFrom="30" blurYTo="15" color="#ff22aa"/>
          </custom:effectOnDrop>
        </custom:DynamicFormExtender>
      </mx:Panel>
    </mx:VBox>
  </mx:Panel>
</mx:Application>

DraggingRegion

package net.tilfin.custom
{
  import mx.containers.Canvas;
  import mx.controls.Label;

  internal class DraggingRegion extends Canvas
  {
    private var labelSelectItemDisplay:Label;
    
    public function DraggingRegion() {
      labelSelectItemDisplay = new Label();
      this.addChild(labelSelectItemDisplay);
      labelSelectItemDisplay.move(0, 0);
      this.width = 0;
      this.height = 0;
    }
    
    public function set textColor(color:String):void {
      labelSelectItemDisplay.setStyle("color", color);
    }
    
    public function setItemName(itemname:String):void {
      labelSelectItemDisplay.text = itemname;
    }
  }
}

DynamicFormExtender

package net.tilfin.custom
{
  import flash.display.*;
  import flash.events.*;
  import flash.geom.Point;
  
  import mx.containers.*;
  import mx.core.*;
  import mx.effects.*;
  import mx.events.FlexEvent;
  import mx.events.MoveEvent;
  import mx.events.ResizeEvent;
    
  /**
   * 動的にフォームアイテムのレイアウトを変更可能にするアタッチメントクラス
   * 
   * @author toshi
   */
  [Event(name="targetItemChanged", type="flash.events.Event")]
  public class DynamicFormExtender extends Container
  {
    [Embed("images/hresize.gif")]
    private var hResizeCursor:Class;
    
    private var manager:FormItemsLayoutManager;
    private var _targetForm:Form;
    
    private var targetMaker:Canvas;
    private var dropLine:Canvas;
    private var draggingRegion:DraggingRegion;
    private var operation:Canvas;
    
    private var formInnerWidth:Number;
    private var offsetMouseX:int = 0;
    private var offsetMouseY:int = 0;
    private var draging:Boolean = false;
    private var downObject:DisplayObject = null;
    private var isClicked:Boolean = false;
    private var selectedItem:FormItem;
    private var dropAt:Number;
    private var selItemAt:Number;
    
    private var selecteffect:Effect;
    private var dropeffect:Effect;
    
    /**
     * コンストラクタ 
     */
    public function DynamicFormExtender() {
      this.visible = false;
      
      dropLine = new Canvas();
      dropLine.width = 2;
      dropLine.height = 30;
      dropLine.setStyle("backgroundColor", "#ff1122");
      dropLine.setStyle("backgroundAlpha", "0.8");
      dropLine.visible = false;
      
      targetMaker = new Canvas();
      targetMaker.setStyle("cornerRadius", "3");
      targetMaker.setStyle("borderStyle", "solid");
      targetMaker.setStyle("borderThickness", "1");
      targetMaker.setStyle("borderColor", "#776699");
      targetMaker.setStyle("backgroundColor", "#9988aa");
      targetMaker.setStyle("backgroundAlpha", "0.3");
      targetMaker.visible = false;
      
      draggingRegion = new DraggingRegion();
      draggingRegion.setStyle("cornerRadius", 3);
      draggingRegion.setStyle("borderStyle", "solid");
      draggingRegion.setStyle("backgroundAlpha", "0.5");
      draggingRegion.setStyle("backgroundColor", "#e6c6fb");
      draggingRegion.textColor = "#000066";
      
      operation = new Canvas();
      operation.addEventListener(MouseEvent.MOUSE_DOWN, operation_mouseDown);
      operation.addEventListener(MouseEvent.MOUSE_MOVE, operation_mouseMove);
      operation.addEventListener(MouseEvent.MOUSE_UP, operation_mouseUp);
    }
    
    /**
     * 変更対象アイテムを選択したときに選択範囲を示すブロックに施すエフェクトをセット
     * 
     * @param effect エフェクト
     */
    public function set effectOnSelect(effect:Effect):void {
      selecteffect = effect;
      selecteffect.target = targetMaker;
    }
    
    /**
     * アイテムの位置を変更したときに起こすエフェクトをセット
     * 
     * @param effect エフェクト
     */
    public function set effectOnDrop(effect:Effect):void {
      dropeffect = effect;
    }
    
    /**
     * カスタマイズモードを切り替える。
     * 
     * @param value trueならオン
     */
    public function set customizeMode(value:Boolean):void {
      this.visible = value;
      if (!value) {
        selectedItem = null;
            invisible(targetMaker);
         }
    }
    
    /**
     * @return 変更対象のアイテムを取得できる
     */
    public function get targetItem():DisplayObject {
      return selectedItem;
    }
    
    /**
     * カスタマイズ可能にするフォームをセットする。
     * 
     * @param target 対象フォーム
     */
    public function set targetForm(target:Form):void {
            _targetForm = target;
            _targetForm.addEventListener(FlexEvent.CREATION_COMPLETE, customForm_creationComplete);
        }
        
        // ターゲットフォーム初期化後のアイテムセットアップ
        private function customForm_creationComplete(event:FlexEvent):void { 
            this.addChild(targetMaker);
            this.addChild(dropLine);
            this.addChild(draggingRegion);
       draggingRegion.visible = false;
       
      this.addChild(operation);
      this.move(_targetForm.x, _targetForm.y);
      
      operation.width = _targetForm.width - operation.x;
      operation.height = _targetForm.height - operation.y;
      operation.visible = true;
      
      _targetForm.addEventListener(ResizeEvent.RESIZE, _targetForm_resize);
      _targetForm.addEventListener(MoveEvent.MOVE, _targetForm_resize);
      
      manager = new FormItemsLayoutManager(_targetForm);
      calcFormInnerWidth();
        }
        
        private function changeItemPercentWidth(percent:Number):void {
          invisible(targetMaker);
      
      manager.restructWithResizing(selectedItem, percent);
      
      dropeffect.target = selectedItem;
          dropeffect.play();
        }
        
        private function calcFormInnerWidth():void {
      formInnerWidth = _targetForm.width - Number(_targetForm.getStyle("paddingLeft"))
                   - Number(_targetForm.getStyle("paddingRight"));
        }
        
        private function _targetForm_resize(e:ResizeEvent):void {
      this.width = _targetForm.width - _targetForm.x;
      this.height = _targetForm.height - _targetForm.y;
      operation.width = this.width;
      operation.height = this.height;
      calcFormInnerWidth();
        }
        
    private function operation_mouseDown(e:MouseEvent):void {
      if (!e.buttonDown) return;
    
      var returnPt:Point = new Point(0, 0);
      downObject = getChildTargetItem(_targetForm, new Point(e.localX, e.localY), returnPt);
      
      if (downObject != null) {
        offsetMouseX = e.localX - returnPt.x;
        offsetMouseY = e.localY - returnPt.y;
        if (downObject != selectedItem) {
          setSelectedItem(downObject, returnPt);
        } else {
          isClicked = true;
        }
      } else if (!resizing) {
        draggingRegion.visible = false;
        targetMaker.visible = false;
        selectedItem = null;
      }
    }
    
    private var resizing:Boolean;
    private var perwid:Number;
    
    private function operation_mouseMove(e:MouseEvent):void {
      if (selectedItem == null) return;
      
      if (!e.buttonDown) {
        var xpos:Number = selectedItem.contentMouseX;
        if (xpos >= selectedItem.width - 4 && xpos <= selectedItem.width + 2) {
          cursorManager.setCursor(hResizeCursor, 4, -10);
          resizing = true;
        } else {
          cursorManager.removeAllCursors();
          resizing = false;
        }
        return;
      }
      
      if (resizing) {
        // サイズ変更
        var x:Number = _targetForm.contentMouseX - selectedItem.x;
        if (x < formInnerWidth * 0.29) {
          perwid = 25;
          targetMaker.width = formInnerWidth * 0.25;
        } else if (x < formInnerWidth * 0.42) {
          perwid = 33;
          targetMaker.width = formInnerWidth * 0.33;
        } else if (x < formInnerWidth * 0.58) {
          perwid = 50;
          targetMaker.width = formInnerWidth * 0.50;
        } else if (x < formInnerWidth * 0.71) {
          perwid = 67;
          targetMaker.width = formInnerWidth * 0.67;
        } else if (x < formInnerWidth * 0.87) {
          perwid = 75;
          targetMaker.width = formInnerWidth * 0.75;
        } else {
          perwid = 100;
          targetMaker.width = formInnerWidth;
        }
        return;
      }
      
      if (!draging) {
        // ドラッグ開始
        draging = true;
        draggingRegion.width = selectedItem.width;
        draggingRegion.height = selectedItem.height;
        draggingRegion.visible = true;
      } else {
        var ret:Array;
        ret = findInsertTargetItem(_targetForm, new Point(e.stageX, e.stageY));
        dropAt = ret[0];
        if (ret[1] == 1) {
          dropLine.visible = true;
        } else if (selItemAt < dropAt) {
          dropLine.visible = (dropAt - 1 != selItemAt);
        } else {
          dropLine.visible = (dropAt != selItemAt);
        }
      }
      
      draggingRegion.move(e.localX - offsetMouseX, e.localY - offsetMouseY);
    }
    
    private function operation_mouseUp(e:MouseEvent):void {
      if (resizing) {
        changeItemPercentWidth(perwid);
        resizing = false;
      } else if (draging) {
        invisible(draggingRegion);
        dropLine.visible = false;
        draging = false;
        invisible(targetMaker);
        
        var perwid:Number = -1;
        // 段の間に挿入するときは幅をいっぱいにする
        if (dropLine.width == formInnerWidth) perwid = 100;
        
        if (manager.insertItemAt(selectedItem, dropAt, perwid)) {
          dropeffect.target = selectedItem;
          dropeffect.play();
        }
        selectedItem = null;
        selItemAt = -1;
        dropAt = -1;
      } else if (isClicked) {
        invisible(targetMaker);
        selectedItem = null;
      }
      isClicked = false;
      downObject = null;
    }
    
    // 再帰的に ptの位置にある FormItem を探して返す。returnptにはフォームアイテムの相対的位置がセットされる。
    private function getChildTargetItem(parent:Container, pt:Point, returnpt:Point):DisplayObject {
      for each (var element:DisplayObject in parent.getChildren()) {
        if (getObjectContains(element, pt)) {
          pt.x -= element.x;
          pt.y -= element.y;
          returnpt.x += element.x;
          returnpt.y += element.y;
          
          if (element is FormItem) {
            return element;
          } else {
            return getChildTargetItem(Container(element), pt, returnpt);
          }
        }
      }
      return null;
    }
    
    // StagePointの位置から FormItem の挿入位置を探す。
    // ドロップマーカーの位置も同時にセット。
    private function findInsertTargetItem(parent:Container, sPt:Point):Array {
      var zeroPt:Point = new Point(0, 0);
      var gFormPt:Point = _targetForm.contentToGlobal(zeroPt);
      var pre_item:FormItem = null;
      var x:Number;
      var y:Number;
      var i:Number = 0;
      
      for each (var item:FormItem in manager.itemList) {
        var gPt:Point = item.localToGlobal(zeroPt);
        
        if (sPt.y < gPt.y) {
          if (pre_item) {
            y = pre_item.localToGlobal(zeroPt).y;
            if (sPt.y < y + pre_item.height) {
              dropLine.move(pre_item.localToGlobal(zeroPt).x - gFormPt.x + pre_item.width, y - gFormPt.y);
              dropLine.setActualSize(2, pre_item.height + 2);
              return [i, 0];
            }
          }
          dropLine.move(Number(_targetForm.getStyle("paddingLeft")), gPt.y - gFormPt.y - 3);
          dropLine.setActualSize(formInnerWidth, 2);
          return [i, 1];
        }
        
        if (sPt.y <= gPt.y + item.height) {
          if (sPt.x <= gPt.x + item.width * 0.5) {
            dropLine.move(gPt.x - gFormPt.x, gPt.y - gFormPt.y);
            dropLine.setActualSize(2, item.height + 2);
            return [i, 0];
          }
        }
        
        pre_item = item;
        i++;
      }
      
      if (pre_item) {
        y = pre_item.localToGlobal(zeroPt).y;
        if (sPt.y < y + pre_item.height) {
          dropLine.move(pre_item.localToGlobal(zeroPt).x - gFormPt.x + pre_item.width, y - gFormPt.y);
          dropLine.setActualSize(2, pre_item.height + 2);
          return [i, 0];
        }
        dropLine.move(Number(_targetForm.getStyle("paddingLeft")), gPt.y - gFormPt.y + pre_item.height + 2);
      } else {
        dropLine.move(0, 0);
      }
      dropLine.setActualSize(formInnerWidth, 2);
      return [i, 1];
    }
    
    /**
    private function findInsertTargetItem(parent:Container, pt:Point, returnpt:Point):DisplayObject {
      for each (var element:DisplayObject in parent.getChildren()) {
        if (getObjectContains(element, pt)) {
          pt.x -= element.x;
          pt.y -= element.y;
          returnpt.x += element.x;
          returnpt.y += element.y;
          
          if (element is HBox) {
            return findInsertTargetItemFromHBox(Container(element), pt, returnpt);
          } else {
            return findInsertTargetItem(Container(element), pt, returnpt);
          }
        }
      }
      return null;
    }
    
    private function findInsertTargetItemFromHBox(hbox:Container, pt:Point, returnpt:Point):DisplayObject {
      var item:DisplayObject = null;
      for each (item in hbox.getChildren()) {
        if (pt.x <= item.x + item.width * 0.5) {
          returnpt.x += item.x;
          return FormItem(item);
        }
      }
      if (item == null) return null;
      returnpt.x += item.x + item.width;
      return FormItem(item);
    }
    */
    
    private function getObjectContains(object:DisplayObject, pt:Point):Boolean {
      return (pt.x >= object.x && pt.x < object.x + object.width &&
          pt.y >= object.y && pt.y < object.y + object.height);
    }
    
    private function setSelectedItem(target:DisplayObject, pt:Point):void {
      selectedItem = FormItem(target);
      selItemAt = manager.itemList.getItemIndex(selectedItem);

      draggingRegion.setItemName(selectedItem.label);
      
      targetMaker.move(pt.x, pt.y);
      targetMaker.width = target.width + 4;
      targetMaker.height = target.height + 4;
      targetMaker.visible = true;
      selecteffect.play();
      
            dispatchEvent(new Event("targetItemChanged"));
    }
    
    private function invisible(object:DisplayObject):void {
      object.width = 0;
      object.height = 0;
      object.visible = false;
    }
  }
}

FormItemsLayoutManager

package net.tilfin.custom
{
  import flash.display.*;
  import flash.events.*;
  
  import mx.collections.ArrayCollection;
  import mx.containers.*;
  import mx.core.*;
  
  /**
   * フォームアイテムの配置を管理するクラス
   * 
   * @author toshi
   */
  internal class FormItemsLayoutManager
  {
    private var form:Form;
    private var itemlist:ArrayCollection;
    
    /**
     * コンストラクタ
     * 
     * @param target 対象フォーム
     */
    public function FormItemsLayoutManager(target:Form) {
      this.form = target;
      
      findItems();
    }
    
    /**
     * dest の位置に src を 挿入します。
     * 
     * @param src 移動するアイテム
     * @param dest 挿入位置のアイテム。nullの場合は最後に追加。
     */
    public function insertItemAt(src:FormItem, destIndex:Number, perwid:Number = -1):Boolean {
      var obj:Object;
      var removedIdx:int = itemlist.getItemIndex(src);
      if (removedIdx < destIndex) destIndex--;
      if (perwid == -1 && removedIdx == destIndex) return false;
      
      if (destIndex < itemList.length) {
        obj = itemlist.removeItemAt(removedIdx);
        itemlist.addItemAt(obj, destIndex);
      } else {
        obj = itemlist.removeItemAt(removedIdx);
        itemlist.addItem(obj);
      }
      
      if (perwid != -1) {
        FormItem(obj).percentWidth = perwid;
      }
      restruct();
      return true;
    }
    
    public function isMakerAfter(src:FormItem, dest:FormItem):Boolean {
      return (itemlist.getItemIndex(src) + 1 <= itemlist.getItemIndex(dest));
    }
    
    public function get itemList():ArrayCollection {
      return itemlist;
    }
    
    /**
     * フォームのアイテム配置を再構築します。 
     */
    public function restruct():void {
      restructWithResizing(null, 0);
    }
    
    /**
     * 幅を変更してフォーム内のアイテム配置を再構築します。
     * 段組みにするための HBox を自動的に組み入れます。
     * 
     * @param item 幅を変更するアイテム
     * @param newwid 新しいPercentWidth
     */
    public function restructWithResizing(item:FormItem, newwid:Number):void {
      form.removeAllChildren();
      
      var len:Number = 0;
      var box:HBox = createBox();
      
      for each (var fi:FormItem in itemlist) {
        var itempw:Number;
        if (fi == item) {
          itempw = newwid;
        } else {
          itempw = fi.percentWidth;
        }
        
        if (len + itempw > 100) {
          box = createBox();
          len = 0;
        }
        
        box.addChild(fi);
        if (fi == item) {
          fi.percentWidth = itempw;
        }
        
        len += itempw;
        if (len >= 90) {
          len = 0;
          box = createBox();
        }
      }
    }
    
    private function findItems():void {
      itemlist = new ArrayCollection();
      findItemsRecusively(form);
    }
    
    private function findItemsRecusively(parent:Container):void {
      for each (var element:DisplayObject in parent.getChildren()) {
        if (element is FormItem) {
          itemlist.addItem(element);
        } else {
          findItemsRecusively(Container(element));
        }
      }
    }
    
    private function createBox():HBox {
      var vbox:VBox = new VBox();
      vbox.percentWidth = 100;
      
      var hbox:HBox = new HBox();
      hbox.percentWidth = 100;
      
      form.addChild(vbox);
      vbox.addChild(hbox);

      vbox.visible = true;
      hbox.visible = true;
      
      return hbox;
    }    
  }
}