為了讓大家更深入的了解和使用C#,我們將開始這一系列的主題為“C#發現之旅 ”的技術講座。考慮到各位大多是進行WEB數據庫開發的,而所謂發現就是發現我們所 不熟悉的領域,因此本系列講座內容將是C#在WEB數據庫開發以外的應用。目前規劃的主要內 容是圖形開發和XML開發,並計劃編排了多個課程。在未來的C#發現之旅中,我們按照由淺入 深,循序漸進的步驟,一起探索和發現C#的其他未知的領域,更深入的理解和掌握使用C#進 行軟件開發,拓寬我們的視野,增強我們的軟件開發綜合能力。
本文配套源碼,其中的 CellViewLib.zip 就是本課程的演示代碼。
課程說明
在上一次課程中,我們 一起研究了使用C#開發一個比較簡單的橢圓形按鈕的控件,初步接觸了C#圖形開發,在本次 課程中我們將繼續深入研究C#圖形開發,嘗試使用C#開發一個稍微復雜點的數據網格控件。
功能需求
現客戶要求開發一個圖形軟件,其軟件功能需求是
用一個網 格式界面顯示一個數據表的文本內容。
可以設置網格行的高度,單元格的寬度自動 適應文本內容的大小。當顯示的內容比較多時顯示滾動條。
用戶可以使用鼠標點擊 操作來選擇一個單元格,也可以鼠標拖拽選擇多個單元格。
可以復制選擇的單元格 的文本。
最後開發的軟件其用戶界面如圖所示
軟件設計
根據軟件 功能需求,其界面類似DataGrid控件,這是一種界面比較復雜的圖形軟件組件,需要采用文 檔-視圖的軟件設計模式。
所謂文檔-視圖模式,就是將用戶界面中要顯示的數據並 不直接放置到控件代碼中,而是按照邏輯層次關系建立一種文檔對象模型,使用一個個對象 來影射到要顯示的數據中的某些部分。此時控件根據這個文檔對象模型來顯示用戶界面,並 響應用戶界面事件來操作一個個文檔對象。這種軟件設計模式通常用於比較復雜的圖形用戶 界面軟件的設計中。
文檔對象模型-單元格,表格行,表格
我個人的軟件開 發風格是從底層到界面,當然也有人是喜歡從界面到底層的。要開發這個軟件,首先是設計 文檔模型,此處文檔結構比較簡單,就是一個二維表格,因此很自然的設計出表格式的文檔 對象模型,無非就是單元格,表格行和表格三種對象,這三種對象構成一個具有三個層次的 對象樹狀結構,這有點類型HTML文檔中的表格對象結構。此處為了簡化設計就沒有定義表格 列對象了。
視圖控件
本軟件的關鍵是建立一個自定義用戶控件,它能根據表 格文檔對象模型來繪制網格,並實現一些操作特性。該控件開發主要實現的功能有
數 據的加載,可以將一個二維表格數據結構設置到表格文檔對象模型中供控件顯示。此處測試 時使用了一個DataTable對象填充表格文檔對象。為了開發方便,事先查詢數據庫獲得一個 DataTable並序列化到一個文件中。測試程序將從這個序列化文件獲得一個DataTable然後填 充到控件中。
控件成功加載數據後,還需要進行內容排版,計算表格中每一個單元格 的位置和大小。由於客戶要求單元格的寬度自動適應文本內容的寬度,因此首先計算所有單 元格文本的顯示所需的寬度,然後獲得某個表格列所有單元格的最大寬度,然後設置該網格 列的寬度為這個最大寬度。網格內容排版後還要獲得整個網格的顯示大小,並根據需要設置 控件的滾動條狀態。
我們可以使用Graphics對象的MeasureString 方法來計算字符串 的顯示寬度。若文檔視圖比較大時用戶控件還要顯示滾動條來滾動顯示整個文檔視圖。
控件還需要鼠標點擊選擇一個單元格,或者鼠標拖拽選擇多個單元格,因此還需要處 理鼠標事件。當用戶按下鼠標按鍵時,設置鼠標光標下面的單位格為選中狀態,當用戶鼠標 拖拽時,將動態的形成一個從拖拽起點到當前鼠標光標位置的選擇區域矩形,若單元格和選 擇矩形相交,則設置單元格為選中狀態。
軟件代碼說明
根據軟件設計,開發 出了這個網格數據顯示軟件,現按照軟件的運行過程對代碼進行詳細說明。
使用 VS.NET2003打開這個工程,按下F5運行,可以看到主窗體上有一個“顯示數據” 按鈕,按下該按鈕,然後進行單步跟蹤狀態,可以看到程序首先從程序資源文件 DataTable.dat 中反序列化加載一個DataTable對象,這個DataTable.dat 文件是事先生成的 ,這樣做是為了簡化這個軟件的代碼,我們也可以連接數據庫查詢數據獲得一個DataTable對 象,其效果是一樣的。
FillDataTable
獲得DataTable後程序調用控件的 FillDataTable方法向控件填充數據。這個方法的代碼如下
/// <summary>
/// 根據一個DataTable 填充網格
/// </summary>
/// <param name="table">數據表對象</param>
public void FillDataTable( DataTable table )
{
if( table == null )
throw new ArgumentNullException("table");
myDocument.Clear();
CellRow row = new CellRow();
myDocument.Add( row );
foreach( DataColumn col in table.Columns )
{
row.Add( col.ColumnName );
}
foreach( DataRow drow in table.Rows )
{
row = new CellRow();
foreach( DataColumn col in table.Columns )
{
object v = drow[ col ] ;
string txt = "";
if( v == null || DBNull.Value.Equals( v ))
txt = "[NULL]";
else
txt = Convert.ToString( v );
row.Add( txt );
}
myDocument.Add( row );
}
using( System.Drawing.Graphics g = this.CreateGraphics())
{
this.RefreshSize( g );
this.Invalidate();
}
}
進入FillDataTable方法可以看到 程序是根據DataTable填充網格文檔對象 myDocument 。程序中實現了由Cell , CellRow 和 CellDocument 三種類型組成的網格文檔對象。
大家可以看看這三個類的代碼,它們 是相當簡單的。Cell 類定義了網格單元格對象,包括單元格顯示的文本,位置和大小等信息 。CellRow類定義了表格行對象,它本身也是單元格列表,可以添加單元格對象。 CellDocument定義了表格文檔對象,它本身是表格行列表,可以添加表格行,還提供Cells屬 性返回文檔中所有的單元格對象組成的數組。
我們回到FillDataTable 函數,首先是 清空文檔,然後遍歷DataTable的標題欄信息,生成網格文檔的第一行單元格,然後遍歷 DataTable所有的數據行對象,對每一個數據行新增一個表格行對象,然後添加到 myDocument 中。
RefreshSize
程序使用一個DataTable填充網格文檔後,需要 調用 RefreshSize 進行內容事先排版,為顯示文檔內容做准備。這個方法的代碼為
/// <summary>
/// 計算單元格大小,進行內容排版
/// </summary>
/// <param name="g">計算文本大小使用的圖形 繪制對象</param>
public void RefreshSize( System.Drawing.Graphics g )
{
ArrayList cells = new ArrayList();
System.Drawing.Size ViewSize = Size.Empty ;
int LeftCount = 0 ;
for( int iCount = 0 ; iCount < 1000 ; iCount ++ )
{
// 遍歷所有的表格列,獲得 指定的列的單元格對象
// 此處允許最大的表格列有1000列
cells.Clear();
for( int RowIndex = 0 ; RowIndex < myDocument.Count ; RowIndex ++ )
{
CellRow row = myDocument[ RowIndex ] ;
if( iCount < row.Count )
{
Cell cell = row[ iCount ] ;
// 設置單元格的位置
cell.intLeft = LeftCount ;
cell.intTop = RowIndex * this.RowHeight ;
cells.Add( cell );
}
}
if( cells.Count == 0 )
break;
// 計算當前列的單元格的最大寬度
int MaxWidth = 40 ;
foreach( Cell cell in cells )
{
string txt = cell.Text ;
if( txt != null && txt.Length > 0 )
{
System.Drawing.SizeF size = g.MeasureString(
txt ,
this.Font ,
1000 ,
System.Drawing.StringFormat.GenericDefault );
if( MaxWidth < ( int ) size.Width )
MaxWidth = ( int ) size.Width ;
}
}
MaxWidth += 10 ;
// 設置單元格的大小
foreach( Cell cell in cells )
{
cell.intWidth = MaxWidth ;
cell.intHeight = this.RowHeight ;
if( cell.Left + cell.Width > ViewSize.Width )
ViewSize.Width = cell.Left + cell.Width ;
if( cell.Top + cell.Height > ViewSize.Height )
ViewSize.Height = cell.Top + cell.Height ;
}
LeftCount += MaxWidth ;
}
ViewSize.Width += 10 ;
ViewSize.Height += 10 ;
if( this.AutoScrollMinSize.Equals( ViewSize ) == false )
{
this.AutoScrollMinSize = ViewSize ;
this.Invalidate();
}
}//public void RefreshSize( System.Drawing.Graphics g )
由於其中要計算單元格文本的顯示寬度,需要使用 Graphics對象,因此這裡使用用戶控件的 CreateGraphics 方法獲得一個 Graphics對象。 Graphics對象不能使用new 語句直接實例化,必須使用某個控件的CreateGraphics方法或從 一個圖片中創建Graphics 對象。現在我們隨著代碼的流程進入到RefreshSize 函數。
在這裡我們定義了一個LeftCount變量,該變量保存了當前表格列的左邊緣位置。定 義了ViewSize變量,用於保存整個文檔的顯示大小。首先我們需要計算各個表格列的寬度, 由於我們沒有定義表格列對象,因此采用遍歷的手段來獲得所有屬於指定列號的單元格對象 。並設置這些單元格的頂端位置和左端位置。
然後遍歷所有同一列的單元格,計算它 們的文本顯示寬度,並獲得其最大值。則該最大值就是當前表格列的寬度,然後設置這些單 元格的寬度為列寬。並修正整個文檔的顯示大小。
處理了所有的單元格後,文檔視圖 排版完畢,可以顯示了。程序還計算了整個文檔視圖的大小,並根據需要設置控件的 AutoScrollMinSize 屬性用來設置滾動狀態。
UserControl支持自動設置滾動狀態。 當設置用戶控件的AutoScroll屬性時,就啟用自動滾動設置。此時我們可以設置 AutoScrollMinSize屬性來控制滾動狀態,當用戶控件的客戶區ClientSize的寬度或高度小於 這個值時就會自動顯示橫向或縱向滾動條,若客戶區大小足夠容納這個AutoScrollMinSize時 ,就不會顯示滾動條,當用戶控件大小改變時會自動進行這樣的判斷。在此我們設置 AutoScrollMinSize為文檔視圖的大小,因此程序也就自動維護滾動狀態。
當程序完 成文檔內容排版後,我們就調用Invalidate函數來通知系統重新繪制控件的用戶界面。
OnPaint
數據加載了,文檔視圖也完成的排版,接下來就是繪制用戶界面了, 我們就很自然的重寫控件的OnPaint函數來繪制網格了。這個方法代碼為
/// <summary>
/// 繪制控件內容
/// </summary>
/// <param name="e">繪制圖形參數</param>
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint (e);
System.Drawing.Rectangle ClipRect = e.ClipRectangle ;
ClipRect.Offset( - this.AutoScrollPosition.X , - this.AutoScrollPosition.Y );
// 進行坐標 轉換
e.Graphics.TranslateTransform( this.AutoScrollPosition.X , this.AutoScrollPosition.Y );
// 繪制網格的畫筆對象
Pen GridPen = null;
if( intGridColor.A != 0 )
{
GridPen = new Pen( intGridColor );
}
// 填充網格的畫刷對象
SolidBrush GridBrush = null;
if( intGridBackColor.A != 0 )
{
GridBrush = new SolidBrush( intGridBackColor );
}
// 繪制文本的畫刷對象
SolidBrush TextBrush = new SolidBrush( this.ForeColor );
// 輸出文本使用的格式化對象
StringFormat TextFormat = new StringFormat();
TextFormat.Alignment = System.Drawing.StringAlignment.Near ;
TextFormat.LineAlignment = System.Drawing.StringAlignment.Center ;
TextFormat.FormatFlags = System.Drawing.StringFormatFlags.NoWrap ;
try
{
foreach( CellRow row in myDocument )
{
foreach( Cell cell in row )
{
// 遍歷所有表格行的單 元格對象,對單元格進行逐個繪制
Rectangle bounds = cell.Bounds ;
// 若單元格和剪切矩形不相交,則單元格無需繪制,轉而處理下一 個單元格.
if( ClipRect.IntersectsWith( bounds ) == false )
{
continue ;
}
if( cell.Selected )
{
// 若單元格處於選擇狀態則顯示高亮度背景色
e.Graphics.FillRectangle( SystemBrushes.Highlight , bounds );
}
else
{
// 繪制單元格背景
if( GridBrush != null )
{
e.Graphics.FillRectangle( GridBrush , bounds );
}
}
if( GridPen != null )
{
// 繪制單元 格邊框
e.Graphics.DrawRectangle( GridPen , bounds );
}
if( cell.Text != null )
{
// 繪制單元格文本
e.Graphics.DrawString(
cell.Text ,
this.Font ,
cell.Selected ? SystemBrushes.HighlightText : TextBrush ,
new RectangleF(
cell.Left ,
cell.Top ,
cell.Width ,
cell.Height ) ,
TextFormat );
}
}
}
}
finally
{
if( GridPen != null )
GridPen.Dispose();
if( GridBrush != null )
GridBrush.Dispose();
TextBrush.Dispose();
TextFormat.Dispose();
}
}
由於我們已經計算了所有單元格的位置和大小,因此繪制網格的過程不復雜,就 是遍歷所有的單元格,繪制一個矩形邊框和單元格文本而已。
由於這個用戶界面是可 能發生滾動的,形成一種折射效應,繪制時需要進行坐標轉換,這就增加了一些復雜度。程 序首先獲得剪切矩形ClipRect,並進行移位,然後設置圖形繪制對象e.Graphics進行坐標轉 換,然後遍歷所有的單元格對象,針對每一個單元格,若剪切矩形和單元格的邊框相交,則 繪制單元格,否則不繪制該單元格。
單元格對象有一個 Selected 屬性,表示單元格 是否處於選擇狀態,若單元格處於選擇狀態則使用高亮度背景畫刷 SystemBrushes.Highlight繪制單元格背景,否則使用控件的網格背景色繪制單元格背景。然 後使用控件的網格線顏色來繪制單元格的邊框。繪制控件的邊框後使用Graphics的 DrawString成員來繪制單元格文本,而且當單元格處於選擇狀態則使用高系統定義的高亮度 文本顏色,否則使用控件文本顏色。
這裡創建繪制網格的畫筆對象和繪制背景的畫刷 對象時進行了一些判斷,若顏色值的屬性A為0則不創建對象。在C#中使用類型 System.Drawing.Color來表示顏色值,它有4個屬性來表示顏色特性,也就是A,R,G,B四個 屬性值,其中屬性R,G,B分別表示顏色的紅綠藍的顏色分量,而屬性A表示顏色透明度,若A 等於255則表示純色,不透明,若等於0則表示完全透明,此時繪制圖形也就無意義了,若A的 值在1到254之間則表示半透明,這個值越小,顏色就越透明。
鼠標事件處理
用於客戶要求能用鼠標點擊或拖拽操作來選擇單元格,因此我們需要處理控件的鼠標事件來 實現選擇單元格效果。其代碼為
/// <summary>
/// 上一次鼠標 按鍵按下時鼠標光標位置
/// </summary>
private Point LastMousePosition = new Point( -1 , -1);
/// <summary>
/// 處理鼠 標按鍵按下事件
/// </summary>
/// <param name="e"></param>
protected override void OnMouseDown (MouseEventArgs e)
{
base.OnMouseDown (e);
// 鼠標光標位置 坐標轉換
Point p = new Point( e.X , e.Y );
p.Offset( - this.AutoScrollPosition.X , - this.AutoScrollPosition.Y );
LastMousePosition = p ;
Cell[] cells = myDocument.Cells ;
foreach( Cell cell in cells )
{
Rectangle bounds = cell.Bounds ;
bool select = bounds.Contains( p );
if( cell.Selected != select )
{
InvalidateCell( cell );
cell.Selected = select ;
}
}
}
/// <summary>
/// 處理鼠標移動事件
/// </summary>
/// <param name="e"></param>
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove (e);
if( LastMousePosition.X >= 0 )
{
// 鼠標光標位置坐標轉換
Point p = new Point( e.X , e.Y );
p.Offset( - this.AutoScrollPosition.X , - this.AutoScrollPosition.Y );
// 根據 p 和 LastMousePosition 兩點坐標獲得一個矩形選擇區域
Rectangle SelectRect = Rectangle.Empty ;
if( p.X > LastMousePosition.X )
{
SelectRect.X = LastMousePosition.X ;
SelectRect.Width = p.X - LastMousePosition.X ;
}
else
{
SelectRect.X = p.X ;
SelectRect.Width = LastMousePosition.X - p.X ;
}
if( p.Y > LastMousePosition.Y )
{
SelectRect.Y = LastMousePosition.Y ;
SelectRect.Height = p.Y - LastMousePosition.Y ;
}
else
{
SelectRect.Y = p.Y ;
SelectRect.Height = LastMousePosition.Y - p.Y ;
}
foreach( Cell cell in myDocument.Cells )
{
bool flag = SelectRect.IntersectsWith( cell.Bounds );
if( cell.Selected != flag )
{
cell.Selected = flag ;
this.InvalidateCell( cell );
}
}
}
}
/// <summary>
/// 處理鼠標按鍵松開事件
/// </summary>
/// <param name="e"></param>
protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp (e);
LastMousePosition = new Point( -1 , -1 );
}
首先重寫OnMouseDown方法,由於剛剛說到的折射效果,需要將鼠標光標位置 從控件客戶區坐標轉換為文檔視圖坐標。然後設置LastMousePosition變量,該變量服務於鼠 標拖拽操作。遍歷表格文檔的所有的單元格對象,判斷單元格的邊框是否包含鼠標光標所在 位置,然後根據需要設置單元格的選擇狀態,若單元格的選擇狀態發生改變,則調用 InvalidateCell方法聲明該單元格樣式無效,准備重新繪制該單元格。
然後重寫 OnMouseMove方法,首先進行鼠標光標位置坐標轉換,若LastMousePosition有效,則說明用 戶正在拖拽鼠標,然後根據LastMousePosition坐標和當前鼠標光標坐標獲得一個選擇區域矩 形,然後遍歷所有單元格,判斷選擇矩形和單元格邊框是否相交,並設置單元格的選擇狀態 ,若單元格的選擇狀態發生改變則聲明該單元格樣式無效,准備重新繪制界面。
重寫 OnMouseUp方法,設置LastMousePosition變量無效,結束鼠標拖拽操作。
復制數據
程序主窗體上有一個復制按鈕,按下該按鈕可以進入到控件的Copy方法。代碼為
/// <summary>
/// 復制選擇的單元格的文本
/// </summary>
public void Copy()
{
System.Text.StringBuilder myStr = new System.Text.StringBuilder();
foreach( CellRow row in myDocument )
{
bool find = false;
foreach( Cell cell in row )
{
if( cell.Selected )
{
myStr.Append( cell.Text );
myStr.Append( " " );
find = true ;
}
}
if( find )
myStr.Append( System.Environment.NewLine );
}
if( myStr.Length > 0 )
{
System.Windows.Forms.DataObject data = new DataObject();
data.SetData( myStr.ToString());
System.Windows.Forms.Clipboard.SetDataObject( data , true );
}
}
該方法也比較簡單,遍歷所有的單元格,若該單元格處於選擇狀態,則獲得 它的文本,然後將所有選擇單元格的文本拼湊起來,並設置到Windows系統剪切板中。
大量的程序使用Windows剪切板交流數據。在.NET中操作剪切板是比較方便的,當我 們進行復制操作時,首先是創建一個DataObject對象,使用它的SetData方法設置數據,然後 使用ClipBoard的SetDataObject方法來向Windows剪切板設置數據。我們可以同時向 DataObject設置多種格式的數據,比如可以同時設置純文本數據,RTF文檔或圖片數據,其他 應用程序會檢索剪切板中的數據格式,從而利用可處理的數據。
當我們進行粘貼操作 時,使用System.Windows.Forms.ClipBoard的GetDataObject方法獲得一個 System.Windows.Forms.IDataObject對象,然後使用IDataObject的GetFormats方法檢索可用 的數據格式,類型System.Windows.Forms.DataFormats的靜態字段預定義了一些數據格式的 名稱,然後可以使用IDataObject的GetData 方法獲得指定格式的數據,如此可以根據獲得的 數據繼續進行操作。
系統預定義顏色
類型SystemBrushes定義了一些系統顏色 的畫刷對象,系統顏色是指Windows操作系統預先定義的標准顏色,包括桌面背景色,窗體顏 色,菜單控件文本顏色,3D邊框中的亮邊框顏色,暗邊框顏色,提示文本顏色和背景色,高 亮度選擇狀態的文本顏色和背景色等等。打開操作系統桌面屬性,可以進入這些系統顏色定 義對話框,該對話框樣式如圖所示。
在進行圖形開發時有時候需 要使用這種系統預定義顏色,這樣使得應用系統的顏色風格和Windows操作系統的整體風格保 持一致,這樣可以獲得和操作系統一致的用戶體驗。
在.NET中,類型SystemBrushes 的一些靜態屬性提供了具有這種系統預定義顏色的畫刷對象,類似的SystemPens的靜態屬性 提供了具有系統預定義顏色的畫筆對象,而SystemColors則提供了這些預定義顏色值。
折射效應
由於該控件可能存在滾動,這就造成一種折射效應。這加大了程序 的復雜度。
在空氣中,光線是直線傳播的,因此手迎著光線直線移動必然能接觸到物 體。但若一個物體在水中,由於折射作用,手迎著光線直線移動也不一定能接觸到物體,因 此人的動作要根據折射的因素進行修正,才能准確的抓住物體。
當用戶界面發生滾動 時也會有類似的折射效應。控件客戶區中顯示了一個圖形,由於發生了滾動,則該圖形在文 檔視圖中的位置不等於在控件客戶區中的位置,兩者存在一個偏移量,這個偏移量就是控件 的滾動量。
在繪圖圖形時,需要將圖形在文檔視圖中的坐標轉換為控件客戶區中的坐 標來模擬這種折射效果,在OnPaint方法的開頭,就調用Graphics的TranslateTrnasform方法 進行坐標轉換,這樣就整體實現了文檔視圖坐標向控件客戶區坐標的轉換。
在本控件 的處理鼠標事件時,需要判斷鼠標光標下的單元格對象,事件參數提供的鼠標光標坐標是在 控件客戶區中的坐標,若直接根據這個控件客戶區坐標位置查找單元格對象,當控件發生滾 動時,這樣的操作過程是錯誤的。因此需要將鼠標的控件客戶區坐標轉換為視圖坐標,轉換 後再搜索單元格才是正確的。
控件中定義了一個InvalidateCell方法,參數是Cell類 型,該方法的功能是聲明某個單元格樣式無效,需要重新繪制。由於聲明控件部分界面無效 的方法Invalidate的參數是采用控件客戶區坐標的,而單元格位置是采用文檔視圖坐標的, 因此需要進行坐標轉換。
折射效果在圖形開發中是會經常遇到的,此處的折射效果是 比較簡單的,只是簡單的整體移位。在一些復雜的圖形用戶界面中還可能發生圖形的縮放和 旋轉,文檔視圖的不同的部分發生了不同的折射效應,此時程序處理折射效應是比較復雜的 。
完成 開發
為了開發方便,我們設置該程序為WinForm應用程序模式,編譯生成一個EXE文件 ,我們可以修改工程類型為類庫,編譯生成一個DLL文件,我們就可以把這個DLL提交給客戶 使用了。
小結
在本課程中,我們一起研究了一個稍微復雜的C#開發的圖形軟 件,相對於上一個演示軟件,這個軟件展示了更多的C#圖形編程技術,包括圖形文檔的排版 ,使用剪切矩形優化圖形繪制,理解了用戶界面的折射效應。相信大家認真學習後能身體力 行,開始能編寫一些自己的圖形軟件了。
在下一個課程中,我們將探索更為復雜的C# 圖形開發,開始學習高級圖形軟件所用到的一些開發技術。使得大家能在C#圖形開發的世界 中更自在的探索研究。