GridViewで作成したヘッダを固定化して欲しいという要望を多く受けることが多いのではないでしょうか。今回はASP.NET AJAXのエクステンダーコントロールとして、GridViewやTableクラスで作成されるテーブルを固定化するように実装してみました。自力で1から作成した+javascriptは詳しくないので、ハードコードしていたりjavascriptが稚拙な箇所がいくつかあると思いますが、とりあえず(意外としっかり?)動くようになったので掲載します。フィードバック(あるのか?)、つっこみ、こうしたらいいなどの提案大歓迎です。

確認環境

  • 確認ブラウザ IE 7, IE 6 
  • 開発環境: Visual Studio 2008 Professional (英語版)
  • .NET 3.5 (Ajax Control Toolkit は使っていないです)
  • 確認WebControl:GridView, Table

 結果のサンプル画像です。左が左上のスクロール状態。右が右下にスクロールした状態。

 1.実装方法

GridViewはtableタグを出力します。今回は出力されるtableの1行目をヘッダとして別のtableとして取り出します。2行目以降をボディとして、divで囲ってスクロールできるようにすることで、ヘッダ固定化を実現するようにするエクステンダを作成しました。

2.ソリューションの作成

Visual Studio 2008を起動して、新規の空のソリューションを作成します。ここでは、ComponentGeek.Extenderというソリューション名で作成したとします。

3.エクステンダーの作成

ソリューションエクスプローラ上のソリューションを右クリック→[Add]→[New Project]でAdd New Projectダイアログを表示し、Project typeにWebを選択、TemplateにASP.NET AJAX Server Control Extenderを選択し、プロジェクト名をComponentGeek.FreezeHeaderExtenderとして、[OK]ボタンをクリックして、プロジェクトを新規作成します。

作成されたjs,cs,resxファイルをそれぞれ、FreezeHeaderBehavior.js,FreezeHeaderBehavior.resx,FreezeHeaderExtender.csという名前にリネームします。

3.1 Extenderの編集

FreezeHeaderExtender.csを開き、次のように編集します。ヘッダ部のCss,ボディ部のCssをオプションで設定できるようにしています。プロパティのWidth,Heightはボディの幅、高さを表します。

using System;
using System.Collections.Generic;
using System.Configuration;
using System.Linq;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;
using System.Xml.Linq;

namespace ComponentGeek.FreezeHeaderExtender
{
    /// <summary>
    /// Summary description for FreezeHeaderExtender
    /// </summary>
    [TargetControlType(typeof(Control))]
    public class FreezeHeaderExtender : ExtenderControl
    {
        public FreezeHeaderExtender()
        {
        }
        /// <summary>
        /// ヘッダテーブルのCssClass
        /// </summary>
        public string HeaderCssClass { get; set; }
        /// <summary>
        /// ボディテーブルのCssClass
        /// </summary>
        public string BodyCssClass { get; set; }
        /// <summary>
        /// ボディ部の幅
        /// </summary>
        public Unit Width { get; set; }
        /// <summary>
        /// ボディ部の高さ
        /// </summary>
        public Unit Height { get; set; }
        protected override IEnumerable<ScriptDescriptor> GetScriptDescriptors(System.Web.UI.Control targetControl)
        {
            if (!targetControl.HasControls()) return new ScriptBehaviorDescriptor[]{};

            ScriptBehaviorDescriptor descriptor = new ScriptBehaviorDescriptor("ComponentGeek.FreezeHeaderBehavior", targetControl.ClientID);
            if (!string.IsNullOrEmpty(HeaderCssClass))
            {
                descriptor.AddProperty("headerCssClass", HeaderCssClass);
            }
            if (!string.IsNullOrEmpty(BodyCssClass))
            {
                descriptor.AddProperty("bodyCssClass", BodyCssClass);
            }
            if (Height != null)
            {
                descriptor.AddProperty("height", Height.ToString());
            }
            if (Width != null)
            {
                descriptor.AddProperty("width", Width.ToString());
            }
            Page.Request.Browser.Browser.GetTypeCode();
            return new ScriptBehaviorDescriptor[]{descriptor};
        }

        protected override IEnumerable<ScriptReference> GetScriptReferences()
        {
            yield return new ScriptReference("ComponentGeek.FreezeHeaderExtender.FreezeHeaderBehavior.js", this.GetType().Assembly.FullName);
        }
    }
}

3.2 javascriptの編集

FreezeHeaderBehavior.jsを開いて次のように編集します。

/// <reference name="MicrosoftAjax.js"/>

Type.registerNamespace("ComponentGeek");

ComponentGeek.FreezeHeaderBehavior = function(element) {
    ComponentGeek.FreezeHeaderBehavior.initializeBase(this, [element]);
    
    this._headerCssClass = null;
    this._bodyCssClass = null;
    this._headerDiv = null;
    this._headerTable = null;
    this._bodyDiv = null;
    this._bodyTable = null;
    this._rootDiv = null;
    this._width = 400;
    this._height = 200;
    this._vScrollBarWidth = 17; // browser depend
}

ComponentGeek.FreezeHeaderBehavior.prototype = {
    initialize: function() {
        ComponentGeek.FreezeHeaderBehavior.callBaseMethod(this, 'initialize');
        
        // Add custom initialization here
        this.createFreezeTable();
        this.adjustColWidth();
        
        $addHandler(this._bodyDiv, "scroll", Function.createDelegate(this, this._onScroll));
    },
    dispose: function() {        
        //Add custom dispose actions here
        if(this._bodyDiv){
          $clearHandlers(this._bodyDiv);
        }
        ComponentGeek.FreezeHeaderBehavior.callBaseMethod(this, 'dispose');
    },
    
    get_headerCssClass : function(){
        return this._headerCssClass;
    },
    set_headerCssClass : function(value){
        this._headerCssClass = value;
    },
    get_bodyCssClass : function(){
        return this._bodyCssClass;
    },
    set_bodyCssClass : function(value){
        this._bodyCssClass = value;
    },
    get_width : function(){
        return this._width;
    },
    set_width : function(value){
        this._width = value;
    },
    get_height : function(){
        return this._height;
    },
    set_height : function(value){
        this._height = value;
    },
    get_vScrollBarWidth : function(){
        return this._vScrollBarWidth;
    },
    set_vScrollBarWidth : function(value){
        this._vScrollBarWidth = value;
    },
    _onScroll : function(e){
        this._headerDiv.scrollLeft = this._bodyDiv.scrollLeft;
    },
    populateHeader : ComponentGeek$FreezeHeaderBehavior$populateHeader,
    populateBody : ComponentGeek$FreezeHeaderBehavior$populateBody,
    createFreezeTable : ComponentGeek$FreezeHeaderBehavior$createFreezeTable,
    adjustColWidth : ComponentGeek$FreezeHeaderBehavior$adjustColWidth
}

function ComponentGeek$FreezeHeaderBehavior$createFreezeTable(){
    // テーブルを作成する
    var table;
    if(this.get_element().tagName == "TABLE") table = this.get_element();
    else{
        var tables = get_element().getElementByTagName("table");
        if(tables.length == 0) throw Error.argument('element', 'table is not found');
        
        table = tables[0];
    }
    this._rootDiv = document.createElement("div");
    table.parentElement.insertBefore(this._rootDiv, table);    
    this.populateHeader(table);
    this.populateBody(table);
    
    this._rootDiv.appendChild(this._headerDiv);
    this._rootDiv.appendChild(this._bodyDiv);

    //$get('traceConsole').value = this._rootDiv.outerHTML;
}
function ComponentGeek$FreezeHeaderBehavior$populateHeader(orgTable){
    // create header div
    this._headerDiv = document.createElement("div");
    this._headerDiv.style.width = this._width;
    this._headerDiv.style.overflow = "hidden";
    this._headerDiv.style.position = "relative";
    // create header table
    this._headerTable = orgTable.cloneNode();
    this._headerTable.style.height = "";  // 高さは引き継がない
    if(this._headerCssClass && this._headerCssClass.length > 0){ 
    debugger;
        this._headerTable.className = this._headerCssClass;        
    }
    this._headerTable.style.tableLayout  = "fixed";
    this._headerTable.style.marginRight = this._vScrollBarWidth;


    // create header content
    var headerBody = document.createElement("tbody");
    if(orgTable.rows.length > 0){
        var headerRow = orgTable.rows(0);
        headerBody.appendChild(headerRow);
    }
    this._headerTable.appendChild(headerBody);
    this._headerDiv.appendChild(this._headerTable);
}
function ComponentGeek$FreezeHeaderBehavior$populateBody(bodyTable){
    // create body div
    this._bodyDiv = document.createElement("div");
    this._bodyDiv.style.height = this._height;
    this._bodyDiv.style.width = this._width;
    this._bodyDiv.style.overflow = "scroll";
    this._bodyDiv.appendChild(bodyTable);
    this._bodyTable = bodyTable;
    this._bodyTable.style.tableLayout  = "fixed";
    if(this._bodyCssClass && this._bodyCssClass.length > 0){ 
        this._bodyTable.className = this._bodyCssClass;        
    }
}
function ComponentGeek$FreezeHeaderBehavior$adjustColWidth(){
    /// <summary>列幅を一致させる</summary>
    if(this._headerTable.rows.length == 0 || this._bodyTable.rows.length == 0) return;
    var row = this._headerTable.rows(0); // ヘッダ
    for(var i=0;i< row.cells.length;++i){
        var hw = row.cells(i).offsetWidth;
        var bw = this._bodyTable.rows(0).cells(i).offsetWidth;
        if(hw > bw){
            this._bodyTable.rows(0).cells(i).style.width = hw;
            row.cells(i).style.width = hw;
        }else{
            this._bodyTable.rows(0).cells(i).style.width = bw;
            row.cells(i).style.width = bw;
        }
    }
}
ComponentGeek.FreezeHeaderBehavior.registerClass('ComponentGeek.FreezeHeaderBehavior', Sys.UI.Behavior);

if (typeof(Sys) !== 'undefined') Sys.Application.notifyScriptLoaded();

3.3 AssemblyInfoの編集

プロジェクトのAssemblyInfo.cs(Properties内にあります)を開いて、末尾のWebResourceAttribute,ScriptResourceAttributeを今回編集したファイル名に一致するように既定で生成された内容を編集します。編集箇所を青色にしています。

....(略)
[assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: WebResource("ComponentGeek.FreezeHeaderExtender.FreezeHeaderBehavior.js", "text/javascript")]
[assembly: ScriptResource("ComponentGeek.FreezeHeaderExtender.FreezeHeaderBehavior.js",
   "ComponentGeek.FreezeHeaderExtender.FreezeHeaderBehavior", "ComponentGeek.FreezeHeaderExtender.Resource")]

4. サンプルプロジェクトの作成

使用例を掲載します。テスト用のWebサイトを作成し、プロジェクトの参照に3で作成したプロジェクトを追加して、Default.aspxを表示したときにToolboxにFreezeHeaderExtenderが表示されるようにします。

あとは、次のようにDefault.aspx,Default.aspx.csを開いて編集します。

Default.aspx

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Defaut.aspx.cs" Inherits="WebSite.Defaut" %>

<%@ Register Assembly="ComponentGeek.FreezeHeaderExtender" Namespace="ComponentGeek.FreezeHeaderExtender"
    TagPrefix="cc1" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>Untitled Page</title>
    <style type="text/css">
        .SelectStyle
        {
        	background-color:Red;
        }
        .BackColor
        {
        	background-color:#FFFFAA;
        }
    </style>
</head>
<body>
    <form id="form1" runat="server">
    <asp:ScriptManager ID="ScriptManager1" runat="server">
    </asp:ScriptManager>
    <div>
        <asp:GridView ID="GridView1" runat="server" AutoGenerateColumns="false" AutoGenerateSelectButton="true" SelectedRowStyle-CssClass="SelectStyle">
            <HeaderStyle BackColor="LightBlue" />
            <Columns>
                <asp:BoundField DataField="col1" HeaderText="1" SortExpression="col1">
                    <ItemStyle Wrap="false" />
                    <HeaderStyle Width="60px" />
                </asp:BoundField>
                <asp:BoundField DataField="col2" HeaderText="2" SortExpression="col1">
                    <ItemStyle Wrap="false" />
                    <HeaderStyle Width="80px" />
                </asp:BoundField>
                <asp:BoundField DataField="col3" HeaderText="3" SortExpression="col1">
                    <ItemStyle Wrap="false" />
                    <HeaderStyle Width="100px" />
                </asp:BoundField>
            </Columns>
        </asp:GridView>
        <cc1:FreezeHeaderExtender ID="FreezeHeaderExtender1" runat="server" TargetControlID="GridView1" Width="200px" Height="100px" BodyCssClass="BackColor" />
    </div>
    <textarea id="traceConsole" cols="100" rows="20"></textarea>
    </form>
</body>
</html> 

Default.aspx.cs

using System;
using System.Collections;
using System.Configuration;
using System.Data;
using System.Linq;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.HtmlControls;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Xml.Linq;

namespace WebSite
{
    public partial class Defaut2 : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
            //if (Page.IsPostBack)
            //{
                DataTable tbl = new DataTable();
                tbl.Columns.Add("col1");
                tbl.Columns.Add("col2");
                tbl.Columns.Add("col3");

                tbl.LoadDataRow(new object[] { "cell11", "cell12", "cell13" }, false);
                tbl.LoadDataRow(new object[] { "cell21", "cell22", "cell23" }, false);
                tbl.LoadDataRow(new object[] { "cell31", "cell32", "cell33" }, false);
                tbl.LoadDataRow(new object[] { "cell41", "cell42", "cell43" }, false);
                tbl.LoadDataRow(new object[] { "cell51", "cell52", "cell53" }, false);
                tbl.LoadDataRow(new object[] { "cell61", "cell62", "cell63" }, false);
                tbl.LoadDataRow(new object[] { "cell71", "cell72", "cell73" }, false);
                tbl.LoadDataRow(new object[] { "cell81", "cell82", "cell83" }, false);
                tbl.LoadDataRow(new object[] { "cell91", "cell92", "cell93" }, false);
                tbl.AcceptChanges();
                GridView1.DataSource = tbl;
                GridView1.DataBind();
            //}

        }
    }
}

5. まとめ

説明は以上です(説明はほとんどしていませんが。)。突っ込み、アドバイス、要望大歓迎です。今回は仕事でASP.NET AJAXを使った関係で昔から欲しいと思っていた行ヘッダ固定化を俺式ではありますが、作ってみました。お試しになられた場合、スタイルシートの設定によってうまく表示されないかも知れませんが、ご容赦ください。もし、コードの詳細な説明等が欲しいなどのご要望があれば、説明を加えたいと思います。

今後の実装としては、せっかく作成したテーブルの縦横幅変更機能を組み込むようにしてみようと思います。また、需要があるようなら列ヘッダ固定もできるようにしたいです。 

エクステンダーは掲載内容どおり作ればできるので、コンパイル済みdllのみをダウンロードできるようにしました。ここからダウンロードして下さい。

注意:エクステンダーを適用しても、一瞬標準サイズのテーブルが表示されます。その動きがいやな場合は、GridViewなどのスタイルをdisplayをnoneに設定しておき、スクリプトのcreateFreezeTableメソッドの最後で、displayのnoneを解除するように変更すれば、標準サイズのテーブルが表示されなくなります。