[ActionScript 3.0] Flashでバウンディングボックスを表示して自由変形を実現してみる

[ActionScript 3.0] Flashでバウンディングボックスを表示して自由変形を実現してみる

Photoshopのように、Flashでバウンディングボックスを表示して自由変形させるのに良い方法ないかなと思っていたら、Transform Tool というのを見つけた。

Transform Tool 2を試してみる

最初に自分がガチで作ってたのがあったけど、プログラムのデザインパターンはこっちのが良かったので、これを調査。

ちなみに、これはバージョン2のようで、まだ開発中みたい。
まぁ、これを理解して自分なりに調整すれば、なんとか実践可能かも。

基本的な使い方

とりあえず、リファレンスに則ってTransform Toolを試してみます。

基本的な使い方は、自分で用意した自由変形したいオブジェクトを普通に表示リストに追加し、同じ表示リスト上にTransformToolクラスを追加します。TransformToolクラスはSpriteクラスの派生クラスなので、ステージにaddChildすると、変形対象のオブジェクトに重なるようになります。

変形対象のオブジェクトにMOUSE_DOWN時のイベントリスナーを登録し、TransformToolクラスのselectメソッドを呼び出すと、バウンディングボックスが表示され選択状態となります。

また、stageMOUSE_DOWNしたときに、deselectメソッドを呼び出すためのイベントリスナーを登録しておきます。
deselectメソッドを呼び出すと選択されているオブジェクトが全て解除されます。

package {
    import com.senocular.display.transform.*;

    import flash.display.Bitmap;
    import flash.display.BitmapData;
    import flash.display.DisplayObject;
    import flash.display.Sprite;
    import flash.events.Event;
    import flash.events.MouseEvent;
    import flash.geom.Matrix;

    public class TransformToolExample extends Sprite {
        [Embed(source = "../res/syake.png")]
        private static const IconBitmap:Class;

        /**
         * constructor
         */
        public function TransformToolExample() {
            if (stage) init();
            else addEventListener(Event.ADDED_TO_STAGE, init);
        }

        private function init(e:Event = null):void {
            removeEventListener(Event.ADDED_TO_STAGE, init);

            var icon:DisplayObject = createBitmapChild(new IconBitmap() as Bitmap);
            addChild(child);

            // TransformTool生成
            var tool:TransformTool = new TransformTool(new ControlSetStandard);
            addChild(tool);

            // イベント登録
            child.addEventListener(MouseEvent.MOUSE_DOWN, tool.select);
            stage.addEventListener(MouseEvent.MOUSE_DOWN, tool.deselect);
        }

        /**
         * ビットマップからDisplayObjectを生成します。
         * @param   icon    ビットマップ
         * @return  DisplayObject
         */
        private function createBitmapChild(icon:Bitmap):DisplayObject {
            // ビットマップを生成
            var bitmap:BitmapData = new BitmapData(icon.width, icon.height, true, 0x0);
            bitmap.draw(icon);

            // 中央配置
            var matrix:Matrix = new Matrix;
            matrix.tx = -Math.round(bitmap.width / 2);
            matrix.ty = -Math.round(bitmap.height / 2);

            // オブジェクトを追加
            var child:Sprite = new Sprite;
            child.graphics.beginBitmapFill(bitmap, matrix, false, true);
            child.graphics.drawRect(matrix.tx, matrix.ty, bitmap.width, bitmap.height);
            child.graphics.endFill();
            return child;
        }
    }
}

コントロールのカスタマイズ

変形方法を設定するときはクラス生成時に設定するか、controlsプロパティにコントロール設定用の値を渡します。
あらかじめ用意されているコントロールセットがありますが、常に比率固定で拡大縮小をしたかったのでコントロールセットをカスタマイズしました。

コントロールセットはArrayクラスの派生クラスになっていて、必要なコントロールパーツを追加しておきます。
例えば、ドラッグ可能にしたい場合はControlMoveクラスのインスタンスを生成して追加。
拡大縮小させた場合はControlUVScaleクラスのインスタンスを生成して追加します。

試しにサンプルとして、以下の条件を満たすコントロールセットを生成してみます。

  • ボーダー表示
  • 拡大縮小可能・縦横比固定
  • 回転可能・回転軸の変更可能
  • 移動可能・移動時にゴーストラインを表示
  • 回転ポインターを上部に追加

ControlSetCustom

package {
    import com.senocular.display.transform.*;
    import flash.geom.Point;

    public dynamic class ControlSetCustom extends Array {

        /**
         * Constructor for creating new ControlSetCustom instances.
         */
        public function ControlSetCustom(){

            var moveCursor:CursorMove = new CursorMove();
            var rotateCursor:CursorRotate = new CursorRotate();
            var scaleCursorB:CursorScale = new CursorScale(ControlUVScale.UNIFORM, 0);
            var scaleCursorB90:CursorScale = new CursorScale(ControlUVScale.UNIFORM, 90);
            var registrationCursor:CursorRegistration = new CursorRegistration();

            var rotateHandle:ControlUVRotate = new ControlUVRotate(.5, 0, rotateCursor);
            rotateHandle.offset = new Point(0, -20);
            var connectorHandle:ControlUV = new ControlUV(.5, 0);
            var handle:ControlConnector = new ControlConnector(rotateHandle, connectorHandle);

            var rotate00:ControlUVRotate = new ControlUVRotate(0, 0, rotateCursor);
            rotate00.scaleX = 3;
            rotate00.scaleY = 3;
            rotate00.alpha = 0;
            var rotate01:ControlUVRotate = new ControlUVRotate(0, 1, rotateCursor);
            rotate01.scaleX = 3;
            rotate01.scaleY = 3;
            rotate01.alpha = 0;
            var rotate10:ControlUVRotate = new ControlUVRotate(1, 0, rotateCursor);
            rotate10.scaleX = 3;
            rotate10.scaleY = 3;
            rotate10.alpha = 0;
            var rotate11:ControlUVRotate = new ControlUVRotate(1, 1, rotateCursor);
            rotate11.scaleX = 3;
            rotate11.scaleY = 3;
            rotate11.alpha = 0;

            super(
                new ControlBorder(),
                new ControlOrigin(),
                new ControlGhostOutline(),
                handle,
                rotateHandle,
                connectorHandle,
                new ControlMove(moveCursor),
                rotate00,
                rotate01,
                rotate10,
                rotate11,
                new ControlUVScale(0, 0, ControlUVScale.UNIFORM, scaleCursorB),
                new ControlUVScale(0, 1, ControlUVScale.UNIFORM, scaleCursorB90),
                new ControlUVScale(1, 0, ControlUVScale.UNIFORM, scaleCursorB90),
                new ControlUVScale(1, 1, ControlUVScale.UNIFORM, scaleCursorB),
                new ControlRegistration(registrationCursor),
                new ControlCursor()
            );
        }
    }
}

サンプルコード

確認用に、最初から用意されているコントロールセットとカスタマイズしたコントロールセットを切り替えられるようにしてみました。他にも、プロパティの条件を切り替える場合、右上にあるそれぞれのチェックボックスを選択してください。

デフォルトでは、シフトキーを押しながら拡大すると縦横比固定になります。
シフトキーを押しながら回転すると45°ずつ角度が変わるようになっているみたいです。

オリジナル機能として、キーボードの上下左右キーの操作で、選択中のオブジェクトが1pxずつ移動するようにしています。
これも、シフトキーを押しながら移動すると10pxずつ移動するようになっています。

ソースコードはFlashエリア上を右クリックで [View Source] からどうぞ。

プロパティについて

livePreview
バウンディングボックスのマウスドラッグ中に、それに合わせてオブジェクトが一緒に変化するかどうか。
autoRaise
2つ以上のオブジェクトがあった場合、選択されたオブジェクトが表示リストのトップに変更されるかどうか。

Transform Tool 2を使ってカスタマイズしてみる

機能として、もう少しなんとかしたいと思ったので、ちょっとだけカスタマイズしてみます。

追加した内容

  • ドラッグ&ドロップにゴーストを残す
  • シフトキーを押しながら回転したときの角度を15°づつに変更
  • 移動時と拡大時の値の端数処理
  • インフォメーションボタンを追加
  • 重なり順を最前面にするコントロールパーツを追加

ドラッグ&ドロップにゴーストを残す

まず、ドラッグ&ドロップですが、オブジェクトが MOUSE_DOWN されたときに、対象オブジェクトの動かす前の状態を残して、ドラッグ中のオブジェクトは半透明にしたかったので、「移動できるエリア」と「オブジェクトが配置されているエリア」を分けました。
その際、オブジェクトが選択されたときは、オブジェクトをコピーしたゴーストを下のエリアに配置し、ドラッグするオブジェクトを最前面のエリアへ移動しています。

init

holder = new Sprite;
addChild(holder);

fore = new Sprite;
addChild(fore);

// ゴースト生成
ghost = new Sprite;

select

/**
 * オブジェクトを選択します。
 * @param event
 */
private function select(event:MouseEvent):void {
    var child:DisplayObject = event.currentTarget as DisplayObject;
    if (holder.contains(ghost)) return;

    // プロパティを引き継ぐ
    ghost.x = child.x;
    ghost.y = child.y;
    ghost.rotation = child.rotation;
    ghost.transform.matrix = child.transform.matrix;
    holder.addChildAt(ghost, (child.parent != null) ? child.parent.getChildIndex(child) : 0);

    // 最前面のステージに追加
    child.alpha = 0;
    fore.addChild(child);

    // ドラッグ移動時に半透明にする
    tool.addEventListener(TransformTool.RESTRICT, restrict, false, 0, true);
}

/**
 * オブジェクトを半透明にします。
 * @param event
 */
private function restrict(event:Event):void {
    var child:CustomChild = tool.target as CustomChild;
    if (child == null) return;
    if (ghost.x == child.x && ghost.y == child.y) return;
    tool.removeEventListener(TransformTool.RESTRICT, restrict);
    child.alpha = 0.5;
}

drop

/**
 * オブジェクトを配置します。
 * @param event
 */
private function drop(event:MouseEvent):void {
    tool.removeEventListener(TransformTool.RESTRICT, restrict);
    var child:DisplayObject = event.currentTarget as DisplayObject;

    // 元のステージへ戻す
    child.alpha = 1;
    holder.addChild(child);

    // ゴースト解除
    if (holder.contains(ghost)) {
        holder.removeChild(ghost);
    }
}

更に、移動した先がステージ外だった場合、元の場所に戻るようにアニメーションを付けました。
このアニメーションは Tweener を使っています。

reset

/**
 * オブジェクトのドラッグをリセットします。
 * @param event
 */
private function reset(event:MouseEvent):void {
    var child:DisplayObject = tool.target as DisplayObject;
    if (child == null) return;

    // 元のステージへ戻す
    Tweener.removeTweens(child);
    Tweener.addTween(child, {x:ghost.x, y:ghost.y, time:0.3, onComplete:function():void {
        child.alpha = 1;
        holder.addChildAt(child, (ghost.parent != null) ? ghost.parent.getChildIndex(ghost) : 0);

        // ゴースト解除
        if (holder.contains(ghost)) {
            holder.removeChild(ghost);
        }
    }});
}

シフトキーを押しながら回転したときの角度を15°づつに変更

回転したときの角度を一定の倍率にスナップさせるため、ControlUVRotateクラスにそこの処理があったのでその一部を書き換えました。

変更前は45°づつ回転になっていたので、もう少し細かく15°づつ回転するように変更を加えています。

変更前(45°づつ回転)

if (activeMouseEvent.shiftKey){
    snap = Math.PI/4;
}

変更後(15°づつ回転)

if (activeMouseEvent.shiftKey){
    snap = Math.PI/12;
}

移動時と拡大時の値の端数処理

後は、移動時と拡大時の小数点が細かすぎるのが気持ち悪かったので、変更が加えられたときのイベントを拾って、丸め演算しました。

変更イベントは TransformToolイベントの TransformTool.COMMIT でとれるっぽいので、TransformToolのインスタンスにCOMMITイベントのイベントリスナーを登録します。

イベントリスナー登録

tool.addEventListener(TransformTool.COMMIT, commit);

commit

/**
 * オブジェクトのプロパティを端数演算します。
 * @param event
 */
private function commit(event:Event):void {
    var child:DisplayObject = tool.target as DisplayObject;
    if (child == null) return;

    child.x = Math.round(child.x);
    child.y = Math.round(child.y);
    child.scaleX = Math.round(child.scaleX * 10000) / 10000;
    child.scaleY = Math.round(child.scaleY * 10000) / 10000;
    child.rotation = Math.round(child.rotation);
}

インフォメーションボタンを追加

バウンディングボックスが表示されたときに、同時に右上の方にインフォメーションアイコンを追加しました。

このアイコンを固定させるために ControlUVButton クラスを作成して、オブジェクトが回転したときでも指定されたビットマップの位置が固定されるような処理が加わっています。

インフォメーションボタンのクリックイベントは、現在選択されているオブジェクトのプロパティ情報を出力しています。

info.png

アイコンの埋め込み

[Embed(source = "../res/info.png", compression = true, quality = "100")]
private static const InfoBitmap:Class;

init

// カスタムコントロールセット生成
var controls:Array = new ControlSetCustom();
var btn:ControlUVButton = new ControlUVButton(1, 0, new InfoBitmap() as Bitmap);
btn.offset = new Point(14, -14);
controls.push(btn);

// TransformTool生成
tool = new TransformTool(controls);

// イベントリスナーに登録
btn.addEventListener(MouseEvent.CLICK, info);

info

/**
 * オブジェクトの詳細を表示します。
 * @param event
 */
private function info(event:MouseEvent):void {
    var child:CustomChild = tool.target as CustomChild;
    if (child == null) return;

    // 出力
    var value:String = "";
    value +=   "name     : " + child.name;
    value += "\nx        : " + child.x;
    value += "\ny        : " + child.y;
    value += "\nscaleX   : " + child.scaleX;
    value += "\nscaleY   : " + child.scaleY;
    value += "\nrotation : " + child.rotation;
    output.text = value;
    output.width = output.textWidth + 10;
}

ControlUVButton

package {
    import com.senocular.display.transform.*;
    import flash.display.Bitmap;
    import flash.display.BitmapData;
    import flash.events.Event;
    import flash.geom.Matrix;

    public class ControlUVButton extends ControlUV {
        private const OFFSET_X:int = 12;
        private const OFFSET_Y:int = -12;
        private var bitmap:BitmapData;
        private var matrix:Matrix;

        /**
         * Constructor for creating new ControlButton instances.
         * @param    u The U value for positioning the control in the x axis.
         * @param    v The V value for positioning the control in the y axis.
         * @param    bitmap The bitmap to be used while interacting with the
         * control instance.
         */
        public function ControlUVButton(u:Number = 1, v:Number = 1, b:Bitmap = null){
            super(u, v);
            buttonMode = true;
            useHandCursor = true;
            if (b != null) {
                bitmap = new BitmapData(b.width, b.height, true, 0x0);
                bitmap.draw(b);
                matrix = new Matrix();
                matrix.tx = -b.width / 2;
                matrix.ty = -b.height / 2;
            } else {
                this.fillColor = 0xFFFFFF;
                this.lineColor = 0x0;
            }
        }

        /**
         * @inheritDoc
         */
        override public function draw():void {
            super.draw();
            graphics.clear();

            // don't draw anything if something
            // has been added as a child to
            // this display object as a "skin"
            if (numChildren) return;

            with (graphics){
                if (bitmap != null) {
                    beginBitmapFill(bitmap, matrix);
                    drawRect(matrix.tx, matrix.ty, bitmap.width, bitmap.height);
                } else {
                    beginFill(this.fillColor, this.fillAlpha);
                    lineStyle(this.lineThickness, this.lineColor, this.lineAlpha);
                    drawCircle(0, 0, 8);
                }
            }
            this.cacheAsBitmap = true;
        }

        /**
         * @inheritDoc
         */
        override public function redraw(event:Event):void {
            super.redraw(event);

            var tool:TransformTool = this.tool;
            if (tool == null){
                return;
            }

            var maxX:Number = tool.topLeft.x;
            if (tool.topRight.x > maxX) maxX = tool.topRight.x;
            if (tool.bottomRight.x > maxX) maxX = tool.bottomRight.x;
            if (tool.bottomLeft.x > maxX) maxX = tool.bottomLeft.x;

            var minX:Number = tool.topLeft.x;
            if (tool.topRight.x < minX) minX = tool.topRight.x;
            if (tool.bottomRight.x < minX) minX = tool.bottomRight.x;
            if (tool.bottomLeft.x < minX) minX = tool.bottomLeft.x;

            var maxY:Number = tool.bottomLeft.y;
            if (tool.bottomRight.y > maxY) maxY = tool.bottomRight.y;
            if (tool.topRight.y > maxY) maxY = tool.topRight.y;
            if (tool.topLeft.y > maxY) maxY = tool.topLeft.y;

            var minY:Number = tool.bottomLeft.y;
            if (tool.bottomRight.y < minY) minY = tool.bottomRight.y;
            if (tool.topRight.y < minY) minY = tool.topRight.y;
            if (tool.topLeft.y < minY) minY = tool.topLeft.y;

            var w:Number = Math.sqrt(Math.pow((tool.topRight.x - tool.topLeft.x), 2) + Math.pow((tool.topRight.y - tool.topLeft.y), 2));
            var h:Number = Math.sqrt(Math.pow((tool.bottomLeft.x - tool.topLeft.x), 2) + Math.pow((tool.bottomLeft.y - tool.topLeft.y), 2));

            if (offset != null) {
                x = Math.round(offset.x + minX + (maxX - minX) / 2 + w * (u - .5));
                y = Math.round(offset.y + minY + (maxY - minY) / 2 + h * (v - .5));
            } else {
                x = Math.round(OFFSET_X + minX + (maxX - minX) / 2 + w * (u - .5));
                y = Math.round(OFFSET_Y + minY + (maxY - minY) / 2 + h * (v - .5));
            }
        }
    }
}

重なり順を最前面にするコントロールパーツを追加

バウンディングボックス内に、重なり順を最前面にするパーツを追加しました。

ControlReset クラスをベースにして、描画の変更と、クリックイベントのところで「tool.raise()」を呼び出しています。

ControlRaise

package {
    import com.senocular.display.transform.*;
    import flash.events.Event;
    import flash.events.MouseEvent;

    public class ControlRaise extends Control {
        private const OFFSET_X:int = 12;
        private const OFFSET_Y:int = 12;

        /**
         * Constructor for creating new ControlRaise instances.
         */
        public function ControlRaise() {
            super();
            buttonMode = true;
            useHandCursor = true;
            lineThickness = 2;
            addEventListener(MouseEvent.CLICK, click);
        }

        /**
         * @inheritDoc
         */
        override public function draw():void {
            super.draw();
            graphics.clear();

            // don't draw anything if something
            // has been added as a child to
            // this display object as a "skin"
            if (numChildren) return;

            with (graphics){
                beginFill(this.fillColor, this.fillAlpha);
                lineStyle(this.lineThickness, this.lineColor, this.lineAlpha);
                moveTo(-3, 0);
                lineTo(-5, 0);
                lineTo(0, -6);
                lineTo(5, 0);
                lineTo(3, 0);
                lineTo(3, 3);
                lineTo(-3, 3);
                lineTo(-3, 0);
                endFill();
            }
        }

        /**
         * @inheritDoc
         */
        override public function redraw(event:Event):void {
            super.redraw(event);

            var tool:TransformTool = this.tool;
            if (tool == null){
                return;
            }

            var maxX:Number = tool.topLeft.x;
            if (tool.topRight.x > maxX) maxX = tool.topRight.x;
            if (tool.bottomRight.x > maxX) maxX = tool.bottomRight.x;
            if (tool.bottomLeft.x > maxX) maxX = tool.bottomLeft.x;

            var minX:Number = tool.topLeft.x;
            if (tool.topRight.x < minX) minX = tool.topRight.x;
            if (tool.bottomRight.x < minX) minX = tool.bottomRight.x;
            if (tool.bottomLeft.x < minX) minX = tool.bottomLeft.x;

            var maxY:Number = tool.bottomLeft.y;
            if (tool.bottomRight.y > maxY) maxY = tool.bottomRight.y;
            if (tool.topRight.y > maxY) maxY = tool.topRight.y;
            if (tool.topLeft.y > maxY) maxY = tool.topLeft.y;

            var minY:Number = tool.bottomLeft.y;
            if (tool.bottomRight.y < minY) minY = tool.bottomRight.y;
            if (tool.topRight.y < minY) minY = tool.topRight.y;
            if (tool.topLeft.y < minY) minY = tool.topLeft.y;

            var w:Number = Math.sqrt(Math.pow((tool.topRight.x - tool.topLeft.x), 2) + Math.pow((tool.topRight.y - tool.topLeft.y), 2));
            var h:Number = Math.sqrt(Math.pow((tool.bottomLeft.x - tool.topLeft.x), 2) + Math.pow((tool.bottomLeft.y - tool.topLeft.y), 2));

            x = Math.round(OFFSET_X + minX + (maxX - minX) / 2 + w * .5);
            y = Math.round(OFFSET_Y + minY + (maxY - minY) / 2 + h * .5);
        }

        /**
         * Handler for the MouseEvent.CLICK event. When clicked, the
         * ControlRaise instance raise it.
         */
        protected function click(event:MouseEvent):void {
            tool.raise();
        }
    }
}

サンプルコード

最終的段階はこちら。

ソースコードはFlashエリア上を右クリックで [View Source] からどうぞ。

* 縦横比を固定してるのに、最小値まで縮小したときに比率が変わってしまうバグを追加で修正しました。