LayoutBaseを拡張してカスタムレイアウトを作成する #3

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

弧の軌跡上に子エレメントを配置するレイアウト

前回、弧の軌跡上に子エレメントを配置するにあたっての問題とその対策を検討し、1回目に作成した直線上に配置するレイアウトクラスに反映させてみました。今回は前回までの実装を基に、弧の軌跡上に子エレメントを配置するレイアウトを作成します。

なお、今回は角度の単位としてラジアンを使用し、角度の計算で三角関数を利用しますが、これらについての解説が必要な場合はこちらを参照してください。

弧の軌跡上に子エレメントを配置するレイアウトクラスのソースコードです。

VerticalArcLayout.as

package jp.classmethod.layouts
{
    import flash.geom.Rectangle;
    
    import mx.core.IVisualElement;
    import mx.core.UIComponent;
    
    import spark.components.supportClasses.GroupBase;
    import spark.layouts.supportClasses.LayoutBase;
    
    [Exclude(name="useVirtualLayout", kind="property")]
    
    /**
     * レイアウトターゲットの子エレメントを弧の軌跡上に一定の間隔で配置するレイアウトクラスです。
     */
    public class VerticalArcLayout extends LayoutBase
    {
        //--------------------------------------------------------------------------
        //
        //  Constructor
        //
        //--------------------------------------------------------------------------
        
        public function VerticalArcLayout()
        {
            super();
        }
        
        //--------------------------------------------------------------------------
        //
        //  Overridden properties
        //
        //--------------------------------------------------------------------------
        
        //----------------------------------
        //  useVirtualLayout
        //----------------------------------
        
        /**
         * @inheritDoc
         */
        override public function get useVirtualLayout():Boolean
        {
            return true;
        }
        
        /**
         * @private
         */
        override public function set useVirtualLayout(value:Boolean):void
        {
        }
        
        //--------------------------------------------------------------------------
        //
        //  Properties
        //
        //--------------------------------------------------------------------------
        
        //----------------------------------
        //  horizontalCenter
        //----------------------------------
        
        private var _horizontalCenter:Number;
        
        [Inspectable(category="General", defaultValue="0")]
        
        /**
         * 水平位置のオフセットです。
         * <p>この値が0の場合、レイアウトターゲットの可視領域内で
         * 垂直位置が中央であるエレメントは水平位置も中央になります。</p>
         * 
         * @default 0
         */
        public function get horizontalCenter():Number
        {
            return _horizontalCenter;
        }
        
        /**
         * @private
         */
        public function set horizontalCenter(value:Number):void
        {            
            _horizontalCenter = value;
            if (target)
            {
                target.invalidateDisplayList();
            }
        }
        
        //----------------------------------
        //  itemCountPerRadian
        //----------------------------------
        
        private var _itemCountPerRadian:int = 10;
        
        [Inspectable(category="General", defaultValue="10")]
        
        /**
         * 半円の軌道上にいくつのエレメントが配置されるかを示します。
         * 
         * @default 10
         */
        public function get itemCountPerRadian():int
        {
            return _itemCountPerRadian;
        }
        
        /**
         * @private
         */
        public function set itemCountPerRadian(value:int):void
        {            
            _itemCountPerRadian = value;
            if (target)
            {
                target.invalidateDisplayList();
            }
        }
        
        //----------------------------------
        //  radius
        //----------------------------------
        
        private var _radius:Number = 300;
        
        [Inspectable(category="General", defaultValue="300")]
        
        /**
         * エレメントを配置する弧の半径です。
         * 
         * @default 300
         */
        public function get radius():Number
        {
            return _radius;
        }
        
        /**
         * @private
         */
        public function set radius(value:Number):void
        {            
            _radius = value;
            if (target)
            {
                target.invalidateDisplayList();
            }
        }
        
        //--------------------------------------------------------------------------
        //
        //  Overridden methods
        //
        //--------------------------------------------------------------------------
        
        /**
         * @inheritDoc
         */
        override public function updateDisplayList(width:Number, height:Number):void
        {            
            updateDisplayListVirtual(width, height);
        }
        
        /**
         * @inheritDoc
         */
        override protected function scrollPositionChanged():void
        {
            if (target)
            {
                target.invalidateDisplayList();
            }
        }
        
        /**
         * @inheritDoc
         */
        override public function updateScrollRect(w:Number, h:Number):void
        {
            var g:GroupBase = target;
            if (!g)
            {
                return;
            }
            
            if (clipAndEnableScrolling)
            {
                g.scrollRect = new Rectangle(0, 0, w, h);
            }
            else
            {
                g.scrollRect = null;
            }
        }
        
        //--------------------------------------------------------------------------
        //
        //  Methods
        //
        //--------------------------------------------------------------------------
        
        /**
         * 仮想レイアウトを使用する場合の子エレメントレイアウト処理です。
         */
        private function updateDisplayListVirtual(width:Number, height:Number):void
        {            
            var triangleHeight:Number = height / 2;
            var visibleRadian:Number = Math.asin(triangleHeight / radius) * 2;
            var totalRadian:Number = (target.numElements - 1) / (itemCountPerRadian - 1) * Math.PI;
            
            var maxVerticalScrollPosition:Number = (totalRadian / visibleRadian) * height;
            var virtualContentHeight:Number = maxVerticalScrollPosition + height;
            target.setContentSize(width, virtualContentHeight);
            
            var scrollPositionInRadian:Number = totalRadian * (verticalScrollPosition / maxVerticalScrollPosition);
            var elementDistanceInRadian:Number = Math.PI / (itemCountPerRadian - 1);
            var centerElementIndex:Number = Math.round(scrollPositionInRadian / elementDistanceInRadian);
            var visibleElementRange:int = Math.ceil((_itemCountPerRadian - 1) / 2) ;
            var startIndex:int = centerElementIndex - visibleElementRange;
            startIndex = Math.max(startIndex, 0);
            startIndex = Math.min(startIndex, target.numElements - 1);
            var endIndex:int = centerElementIndex + visibleElementRange;
            endIndex = Math.max(endIndex, 0);
            endIndex = Math.min(endIndex, target.numElements - 1);
            
            for (var i:int = startIndex; i <= endIndex; i++)
            {
                var element:IVisualElement = target.getVirtualElementAt(i);
                if (!element)
                {
                    continue;
                }
                
                var elementOffsetInRadian:Number = i * (1 / (_itemCountPerRadian - 1)) * Math.PI;
                var elementPositionInRadian:Number = Math.PI - elementOffsetInRadian + scrollPositionInRadian;
                var elementCenterX:Number = target.width / 2 + radius + radius  * Math.cos(elementPositionInRadian) + _horizontalCenter;
                var elementCenterY:Number = target.height / 2 + radius * Math.sin(elementPositionInRadian);
                var x:Number = elementCenterX - element.width / 2;
                var y:Number = elementCenterY - element.height / 2;
                element.setLayoutBoundsPosition(x, y, false);
                
                element.depth = -Math.abs(centerElementIndex - i);            
            }
        }
    }
}
[/as3]</p>
<p>ソースコードを順に見ていきます。</p>

<h2 id="toc-usevirtuallayout">useVirtualLayoutの無効化</h2>
<p>まずはuserVirtualLayoutプロパティの実装部分です。</p>
<p>
override public function get useVirtualLayout():Boolean
{
    return true;
}

override public function set useVirtualLayout(value:Boolean):void
{
}

このレイアウトクラスでは、前回挙げた子エレメント同士の重なりの問題があるため仮想レイアウトのみを使用します。したがって、useVirtualLayoutプロパティは必ずtrueになるようにします。それに伴い、updateDisplayList・scrollPositionChanged・updateScrollRectの実装は、VerticalLinearLayoutの仮想レイアウト使用時の処理のみに変更しています。

verticalScrollPositionと角度

次にupdateDisplayListVirtualメソッドを見ていきますが、その前にverticalScrollPositionと配置位置の関係について考えます。

前回作成したVerticalLinearLayoutは直線上に子エレメントが配置されており、verticalScrollPositionの値をそのまま垂直方向の移動距離として解釈していました。したがって、子エレメントのY座標で位置を調整すればスクロールが実現できていました。今回作成するレイアウトクラスでは、「垂直方向の移動距離(高さ)」として解釈していたverticalScrollPositionを「円周上の移動角度」として解釈することにしたいと思います。

下図は、verticalScrollPositionを「移動角度」として解釈した場合のverticalScrollPositionと子エレメントの位置の関係を表しています。星アイコンがついているのが先頭エレメントで、これが初期位置から移動した角度がverticalScrollPositionと対応しています。

下図はスクロールしていない状態です。

下図はスクロールした状態です。初期位置から円周に沿って移動した角度がverticalScrollPositionに対応します。

下図は末尾までスクロールした状態です。この例でのmaxVerticalScrollPositionは、角度に換算すると1ラジアンということになります。

子エレメントの配置

では実際にupdateDisplayListVirtualメソッドの内容を確認していきます。

仮想的なコンテンツの高さ

VerticalLinearLayoutと同様に、このレイアウトクラスではクリッピング矩形が全く移動しないので、実質的なコンテンツ領域はコンテナの可視領域のみとなります。しかし、スクロールコントロールとの連携を取るために、ここでもやはり末尾までのスクロール量に応じた仮想的なコンテンツの高さを設定する必要があります。

仮想的なコンテンツの高さを算出する部分のコードです。

var triangleHeight:Number = height / 2;
var visibleRadian:Number = Math.asin(triangleHeight / radius) * 2;
var totalRadian:Number = (target.numElements - 1) / (itemCountPerRadian - 1) * Math.PI;

var maxVerticalScrollPosition:Number = (totalRadian / visibleRadian) * height;
var virtualContentHeight:Number = maxVerticalScrollPosition + height;
target.setContentSize(width, virtualContentHeight);

まず、角度と高さの換算について考えます。これは、クリッピング矩形の高さを可視領域内の弧の角度と対応させ、この比率を利用することによって、先頭の子エレメントから末尾の子エレメントまでの角度を垂直方向のスクロール量に換算することができそうです。クリッピング矩形の高さはupdateDisplayListメソッドで引数heightとして渡されているので、クリッピング矩形内における弧の角度を求めます。クリッピング矩形内における弧の角度visibleRadianは下図で表される部分になります。

クリッピング矩形内における弧の角度を求めるために、図のピンク色の線の三角形について考えます。

1行目でこの三角形の高さを求めています。これはクリッピング矩形の高さの半分になります。2行目で三角形の円心側の角の角度を求めています。これは、三角形の高さを斜辺の長さで割った値のアークサインで求めることができます。三角形の斜辺の長さは弧の半径です。求められた角度を2倍すると、クリッピング矩形内における弧の角度が算出されます。

角度と高さの比率が分かったので、これを利用してコンテンツの仮想的な高さを求めていきます。そのためには垂直方向の最大スクロール量を求める必要があります。垂直方向の最大スクロール量は、先頭のエレメントから末尾のエレメントまでの角度を高さに換算した値に対応しますので、先頭のエレメントから末尾のエレメントまでの角度を求め、その角度を高さに換算して求めたいと思います。

3行目で先頭のエレメントから末尾のエレメントまでの角度を求めています。5行目でこれを高さに換算して垂直方向の最大スクロール量を求めています。これにクリッピング矩形の高さを加えることでコンテンツの仮想的な高さが算出されます。最後に、求めたコンテンツの大きさをコンテナにセットしています。

表示する子エレメントの範囲

次に、表示する子エレメントの範囲を求める部分のコードを見ます。

var scrollPositionInRadian:Number = totalRadian * (verticalScrollPosition / maxVerticalScrollPosition);
var elementDistanceInRadian:Number = Math.PI / (itemCountPerRadian - 1);
var centerElementIndex:Number = Math.round(scrollPositionInRadian / elementDistanceInRadian);

var visibleElementRange:int = Math.ceil((_itemCountPerRadian - 1) / 2) ;
var startIndex:int = centerElementIndex - visibleElementRange;
startIndex = Math.max(startIndex, 0);
startIndex = Math.min(startIndex, target.numElements - 1);
var endIndex:int = centerElementIndex + visibleElementRange;
endIndex = Math.max(endIndex, 0);
endIndex = Math.min(endIndex, target.numElements - 1);

まず1行目でスクロールポジションを角度に換算しています。2行目で子エレメント同士の角度を求めて、3行目で現在コンテナ内で垂直方向において一番中央に近い子エレメントのインデックスを求めています。

5~11行目では配置対象となる子エレメントの範囲を求めています。この部分の処理はVerticalLinearLayoutとほぼ同じです。

子エレメントの配置処理

子エレメントの配置処理を見る前に、角度の値と円周上の向きについて確認しておきます。

通常三角関数を扱う上では、X軸正方向と交わる点を始点として、角度が大きくなると円周を反時計回りに回る形になります。しかし、FlashPlayerの座標系はY軸が下が正の方向となる関係上、X軸正方向と交わる点を始点として、角度が大きくなると円周を時計回りに回る形になります。

さて、このことを踏まえた上で子エレメントの配置処理の部分のコードを見ていきましょう。

for (var i:int = startIndex; i <= endIndex; i++) { var element:IVisualElement = target.getVirtualElementAt(i); if (!element) { continue; } var elementOffsetInRadian:Number = i * (1 / (_itemCountPerRadian - 1)) * Math.PI; var elementPositionInRadian:Number = Math.PI - elementOffsetInRadian + scrollPositionInRadian; var elementCenterX:Number = target.width / 2 + radius + radius * Math.cos(elementPositionInRadian) + _horizontalCenter; var elementCenterY:Number = target.height / 2 + radius * Math.sin(elementPositionInRadian); var x:Number = elementCenterX - element.width / 2; var y:Number = elementCenterY - element.height / 2; element.setLayoutBoundsPosition(x, y, false); element.depth = -Math.abs(centerElementIndex - i); } [/as3]

9行目で対象子エレメントの先頭子エレメントからの相対的な角度を求めています。

10行目では対象子エレメントの位置の円周上の角度を求めています。今回作成するレイアウトは、円とX軸負方向(左側)が交わる点を始点として円周上を反時計回りに子エレメントを配置する形となります。スクロールポジションが0の場合、最初の子エレメントの配置位置を角度で表すと1ラジアンの位置になるため、最初に1ラジアン加算しています。子エレメントは反時計回り、つまり角度では負の方向に整列するため、最初の子エレメントの配置位置の角度からelementOffsetInRadianを引き、さらにスクロールされた分の角度であるscrollPositionInRadianを加算することによって、対象子エレメントの円周上の角度を求めています。

この角度をもとに、11行目~15行目で子エレメントの座標を求め、レイアウトしています。

動作確認

さて、これでレイアウトクラスの実装は完了しました。前回、前々回と使ったサンプルアプリケーションで動作を確認しましょう。DataGroupのlayoutプロパティの設定をVerticalArcLayoutを使用するよう、以下のように修正して下さい。

LayoutTest.mxml

<s:layout>
    <layouts:VerticalArcLayout horizontalCenter="-40" itemCountPerRadian="10" radius="300"/>
</s:layout>

実行結果です。

This movie requires Flash Player 9

アイテムレンダラが弧の軌跡上に並んでいますね。スクロールバーでのスクロールもきちんと動作し、末尾までスクロールすると最後のアイテムが中央にきたところで止まることが確認できると思います。

まとめ

Sparkコンポーネントのコンテナはレイアウト処理がデリゲートされる仕組みになっているため、今回のようなレイアウトを作成しても比較的簡単にすっきりと実装することができました。また、今回作成したレイアウトクラスはスクロールに関して多少特殊な実装をしているものの、きちんとレイアウトコンポーネントとして部品化された状態で実装できました。こういった特殊なレイアウトを実装する場合には、ついついレイアウトクラス外部(主に利用するアプリケーション側)からパラメータを操作するような実装をしてしまいがちですが、きちんとコンポーネントとして独立させることによって再利用が容易になりますので、気を付けたいところですね。