FlexのDataGridでソートの挙動をカスタマイズした際にもソート矢印を表示する方法

ここ2年近くいわゆるRIA開発を中心にやっているのですが、特にFlexを使う機会が多いです。
Flexには最初から豊富なコンポーネントを用意してくれていますが、中々高機能な反面、融通の効かない場面も多く、頭を悩ませることもしばしばです。

そんなFlexコンポーネントの中でも個人的にダントツにやりにくいと思っているのが mx.controls.DataGrid です。
これは典型的なグリッドコンポーネントで、何もしなくてもソートや列のDnDによる入れ替えをサポートしていたりと中々強力なのですが、列や行の移動がセル単位になるなど癖も強く、今回テーマに上げるようにカスタマイズに対しても融通が効かないところもあり、厄介なやつであったりもします。とは言え、これと同等のものを自作するのは非常に骨が折れるので、こいつとなんとかつきあっていく必要があります。
ちなみに、Flexの最新安定版は4.1ですが、DataGrid についてはSparkコンポーネントで代替コンポーネントが提供されておらず、引き続きFlex3時代のものを使い続けることになります。

Flexの DataGrid はデフォルトでヘッダをクリックしたときに、クリックした列で自動的のソートを行ってくれます。(Swingの JTable とかJDK5になるまでソート機能がついてませんでしたっけ)
ソート条件は列ごとに sortCompareFunction を指定することでカスタマイズが可能ですが、次のような複雑なカスタマイズを行いたい場合は DataGrid の headerRelease イベントを拾って自分でソート処理を実装する必要があります。

  • ページネーションと組み合わせている場合など、サーバーサイドでソートを行いたい
  • 第2、第3ソート条件を付けたい

この方法はFlexのヘルプにも書かれています。以下、そこでの説明と同じ内容の実装を示します。(そのまま貼り付けると問題あるかもしれないのでちょっと変えてます)

<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" layout="absolute" initialize="initDP();" width="550" height="400">
   <mx:Script>
      <![CDATA[
         import mx.events.DataGridEvent;
         import mx.collections.*;
         private var _myDPColl:ArrayCollection;
         [Bindable]
         private var _sortA:Sort;
         private var _sortByInStock:SortField;
         private var _sortByAuthor:SortField;
         private var _sortByBook:SortField;
         private var _sortByPrice:SortField;
         private var _myDP:Array = [
            {author:'Bさん', book:'Bさんの著書1番目', price:11.99, inStock: true},
            {author:'Bさん', book:'Bさんの著書2番目', price:10.99, inStock: false},
            {author:'Bさん', book:'Bさんの著書3番目', price:12.99, inStock: true},
            {author:'Aさん', book:'Aさんの著書1番目', price:11.99, inStock: false},
            {author:'Aさん', book:'Aさんの著書2番目', price:11.99, inStock: true},
            {author:'Aさん', book:'Aさんの著書3番目', price:14.99, inStock: true},
            {author:'他の人', book:'Other', price:5.99, inStock: true}
         ];
         private function initDP():void
         {
            _myDPColl = new ArrayCollection(_myDP);
            _sortA = new Sort();
            _sortByInStock = new SortField("inStock", true, true);
            _sortByAuthor = new SortField("author", true);
            _sortByBook = new SortField("book", true);
            _sortByPrice = new SortField("price", true, false, true);
            _sortA.fields=[_sortByInStock, _sortByAuthor, _sortByBook];
            _myDPColl.sort=_sortA;
            _myDPColl.refresh();
            tSort0.text = "First Sort Field: inStock";
            tSort1.text = "Second Sort Field: author";
            tSort2.text = "Third Sort Field: book";
            myGrid.dataProvider=_myDPColl;
            myGrid.rowCount=_myDPColl.length + 1; 
         }  
         private function headRelEvt(event:DataGridEvent):void
         {
            _sortA.fields[2] = _sortA.fields[1];
            tSort2.text = "Third Sort Field: " + _sortA.fields[2].name;
            _sortA.fields[1] = _sortA.fields[0];
            tSort1.text = "Second Sort Field: " + _sortA.fields[1].name;
            if (event.columnIndex == 0) {
               _sortA.fields[0] = _sortByAuthor;
            } else if (event.columnIndex==1) {
               _sortA.fields[0] = _sortByBook;
            } else if (event.columnIndex==2) {
               _sortA.fields[0] = _sortByPrice;
            } else {
               _sortA.fields[0] = _sortByInStock;
            }
            tSort0.text = "First Sort Field: " + _sortA.fields[0].name;
            _myDPColl.sort = _sortA;
            _myDPColl.refresh();
            event.preventDefault();
         }
      ]]>
   </mx:Script>
   <mx:DataGrid id="myGrid" width="100%" headerRelease="headRelEvt(event);">
      <mx:columns>
            <mx:DataGridColumn minWidth="120" dataField="author" headerText="著者"/>
            <mx:DataGridColumn minWidth="200" dataField="book" headerText="著書"/>
            <mx:DataGridColumn width="75" dataField="price" headerText="価格"/>
            <mx:DataGridColumn width="75" dataField="inStock" headerText="在庫状況"/>
      </mx:columns>
   </mx:DataGrid>
   <mx:VBox y="{myGrid.y + myGrid.height}">
      <mx:Label id="tSort0" text="First Sort Field: "/>
      <mx:Label id="tSort1" text="Second Sort Field: "/>
      <mx:Label id="tSort2" text="Third Sort Field: "/>
   </mx:VBox>
</mx:Application>

この例では、最初在庫状況、著者、著書の順でソートしていますが、ヘッダをクリックするとその列がソートの第1条件に、それまでの第1条件は第2条件に、第2条件は第3条件に移ります。(ソート条件が分かり易いようにGridの下にソート条件を表示しています)
この例で示しているように、Gridのヘッダをクリックした際のソート処理をオーバーライドしたい場合は DataGrid の headerRelease イベントをハンドルします。ICollectionView に対するソート情報を提供する、mx.collections.Sort オブジェクトを生成し、ソート条件として mx.collections.SortField オブジェクトの配列を渡します。このSortオブジェクトをGridのデータプロバイダとして使用しているICollectionView 実装オブジェクトの sort プロパティに渡し、refresh() メソッドをコールするとソートが実行されます。そして最後に Event#preventDefault() をコールしてデフォルトのソート処理が実行されるのを防ぎます。

この方法で実装すると確かにカスタムのソートを実行出来るようになりますが、それまで表示されていたソート矢印が表示されなくなります。これではどこをクリックしたのか分からなくなります。

Flexのドキュメントを探し回ってもソート矢印の表示に関する説明は見当たりません。リファレンスを見るとDataGridのprotectedなメソッドとして、placeSortArrow() というそれらしいメソッドがありました。説明にはこのように書いてあります。

ソート矢印グラフィックを、現在のソートキーとなっている列に描画します。この実装では、sortArrowSkin スタイルプロパティで指定されたスキンのインスタンスを作成または再使用し、適切な列ヘッダーに配置します。

ならば DataGrid を継承して、このメソッドを外からコールできるようにラップすれば、データプロバイダのソート条件を見て矢印を描画してくれることが期待できます。
ということでまずは DataGrid の拡張から。

    public class CustomDataGrid extends DataGrid
    {
        public function refreshSortArrow():void
        {
            super.placeSortArrow();
        }
    }

refreshSortArrow() を外から呼べるようにします。
そして、これを headerRelease のハンドラで呼び出します。

     private function headRelEvt(event:DataGridEvent):void
     {
        sortA.fields[2] = sortA.fields[1];

        // (省略)

        _myDPColl.sort = sortA;
        _myDPColl.refresh();
        event.preventDefault();
        myGrid.refreshSortArrow();
     }

最後にMXML側で今作ったカスタムの DataGrid を使うように変更します。

   <custom:CustomDataGrid id="myGrid" width="100%" headerRelease="headRelEvt(event);">
      <custom:columns>
            <mx:DataGridColumn minWidth="120" dataField="author" headerText="著者"/>
            <mx:DataGridColumn minWidth="200" dataField="book" headerText="著書"/>
            <mx:DataGridColumn width="75" dataField="price" headerText="価格"/>
            <mx:DataGridColumn width="75" dataField="inStock" headerText="在庫状況"/>
      </custom:columns>
   </custom:CustomDataGrid>

これでソート矢印が表示され...ませんでした。

こうなったら DataGrid#placeSortArrow() の中で何をやっているのか調べるしかありません。ソースコードを覗き込んでみることにします。*1

    protected function placeSortArrow():void
    {
        DataGridHeader(header)._placeSortArrow();
        if (lockedColumnHeader)
            DataGridHeader(lockedColumnHeader)._placeSortArrow();
    }

mx.controls.dataGridClasses.DataGridHeader の _placeSortArrow() を呼んでいます。これを覗き込んでみます。

    mx_internal function _placeSortArrow():void
    {
        placeSortArrow();
    }

mx_internal 名前空間で隠されたメソッドとなっています。この中で呼ばれている placeSortArrow() メソッドの実装を追いかけていると、次のような記述が出てきます。

    if (dataGrid.sortIndex == -1 && dataGrid.lastSortIndex == -1)
    {
        if (sortArrow)
            sortArrow.visible = false;
        if (sortArrowHitArea)
            sortArrowHitArea.visible = false;
        return;
    }

DataGrid の sortIndex というメンバ変数 (これも mx_internal) に設定されたカラムのインデックスを見て、どの列がソート対象としてクリックしたかを判定していることが分かります。
次にソート方向の判定です。placeSortArrow() メソッドの最後の方に次のような実装が出てきます。

    var d:Boolean = (dataGrid.sortDirection == "ASC");

DataGrid の sortDirection というメンバ変数にソート方向を設定しているようですが、これハードコーディングなんですけど...。
他の sortDirection プロパティにアクセスしている箇所をチェックしましたが、"ASC"、"DESC" という文字列をセットすることでソート方向の設定を行っていました。定数定義はされておらず、全ての箇所でハードコードされていました。うーむ...。

ともかく、ソート矢印を表示する方法はこれで分かりました。DataGrid の mx_internal 名前空間に属するメンバ変数 sortIndex、sortDirection を設定すれば良さそうです。先ほどの refreshSortArrow() メソッドを次のように変えてみます。

    public function refreshSortArrow(columnIndex:int, isDesc:Boolean):void
    {
        // ここで渡している文字列は現在のFlex実装に合わせているので注意すること
        mx_internal::sortDirection = isDesc ? "DESC" : "ASC";
        mx_internal::sortIndex = columnIndex;

        super.placeSortArrow();
    }

そして headerRelease のハンドラの実装で次のように呼び出します。

     private function headRelEvt(event:DataGridEvent):void
     {
        sortA.fields[2] = sortA.fields[1];

        // (省略)

        _myDPColl.sort = sortA;
        _myDPColl.refresh();
        event.preventDefault();
        myGrid.refreshSortArrow(event.columnIndex, SortField(_sortA.fields[0]).descending);
     }

これでようやく表示されました!

結局 DataGrid のソート処理をカスタム実装した場合にソート矢印を表示したければ、Flexの内部実装に依存した実装を行わざるを得ないようです。placeSortArrow() というインターフェースを切っておきながら、ソート矢印の描画基準を設定する処理が mx_internal 名前空間で隠蔽された実装の中に閉じられてしまっているので、フレームワークの内部実装に依存した実装を行わない限り、拡張が不可能な状態になっています。何とも中途半端なことです。

なお、次期Flex SDKの "Hero" ではようやく新しい DataGrid が登場するようです。現在の DataGrid からどれだけ改善されているか、暇を見てチェックしたいです。

*1:Flexフレームワークオープンソースです。FlashBuilder、FlexBuiderのソースエディタでFlex提供のクラスやメンバを Ctrl (Cmd) + クリック、もしくは F3 キーでソースを辿ることができます。