ASP.NET WebForms 時代,自定義服務器控件是封裝可復用 UI 邏輯的核心手段。本文實戰演示:繼承 WebControl、實現 IPostBackEventHandler、構建復合控件、生命周期管理、ViewState 持久化、設計器屬性、渲染輸出、客戶端腳本協作,打造一個功能完整的下拉日歷選擇器。
public class DatePicker : WebControl, IPostBackEventHandler
{
// 必須實現 IPostBackEventHandler 接收按鈕回發
}
private Calendar _calendar; // 子日歷控件
private const string BtnDefaultStyle = "border:1px solid gray;cursor:pointer;";
private const string BtnDefaultText = "...";
protected override void CreateChildControls()
{
Controls.Clear();
_calendar = new Calendar
{
ID = CalendarID,
SelectedDate = DateTime.Parse(Text),
TitleFormat = TitleFormat.MonthYear,
NextPrevFormat = NextPrevFormat.CustomText,
NextMonthText = "下月",
PrevMonthText = "上月",
CellSpacing = 0,
Font = { Name = "Verdana", Size = FontUnit.Parse("9pt") },
SelectedDayStyle = { BackColor = Color.FromArgb(0x333399), ForeColor = Color.White },
DayStyle = { BackColor = Color.FromArgb(0xCCCCCC) },
TodayDayStyle = { BackColor = Color.FromArgb(0x999999), ForeColor = Color.Aqua },
DayHeaderStyle = { Font = { Bold = true, Size = FontUnit.Parse("8pt") }, ForeColor = Color.FromArgb(0x333333), Height = Unit.Parse("8pt") },
NextPrevStyle = { Font = { Bold = true, Size = FontUnit.Parse("8pt") }, ForeColor = Color.White },
TitleStyle = { Font = { Bold = true, Size = FontUnit.Parse("12pt") }, ForeColor = Color.White, BackColor = Color.FromArgb(0x333399), Height = Unit.Parse("12pt") },
OtherMonthDayStyle = { ForeColor = Color.FromArgb(0x999999) }
};
_calendar.Style.Add("display", "none"); // 初始隱藏
_calendar.SelectionChanged += Calendar_SelectionChanged;
Controls.Add(_calendar);
}
[DefaultProperty("Text")]
[ToolboxData("<{0}:DatePicker runat=server>")]
public class DatePicker : WebControl, IPostBackEventHandler
{
// 文本屬性:日期字符串
public string Text
{
get
{
EnsureChildControls();
object o = ViewState["Text"];
return o?.ToString() ?? DateTime.Today.ToString("yyyy-MM-dd");
}
set
{
EnsureChildControls();
if (DateTime.TryParse(value, out var dt))
ViewState["Text"] = (DateFormat == CalendarEnum.LongDateTime) ? dt.ToString("yyyy-MM-dd") : dt.ToString("yyyy-M-d");
else
throw new ArgumentOutOfRangeException("請輸入有效日期字符串,如 2024-01-15");
}
}
// 格式枚舉
public enum CalendarEnum { LongDateTime, ShortDateTime }
public CalendarEnum DateFormat
{
get => (CalendarEnum)(ViewState["DateFormat"] ?? CalendarEnum.LongDateTime);
set { ViewState["DateFormat"] = value; Text = Text; } // 重新格式化
}
// 按鈕樣式/文本
public string ButtonStyle { get => (string)ViewState["ButtonStyle"] ?? BtnDefaultStyle; set => ViewState["ButtonStyle"] = value; }
public string ButtonText { get => (string)ViewState["ButtonText"] ?? BtnDefaultText; set => ViewState["ButtonText"] = value; }
// Enabled 重寫:同步禁用按鈕
public override bool Enabled
{
get => (bool?)(ViewState["Enabled"]) ?? true;
set => ViewState["Enabled"] = value;
}
// 內部 ID 生成(確保唯一)
public string CalendarID => ClientID + "_Cal";
public string InputID => ClientID + "_Input";
public string ButtonID => ClientID + "_Btn";
}
protected override void Render(HtmlTextWriter w)
{
EnsureChildControls();
// 外層 Table 定位
w.AddStyleAttribute("position", "absolute");
w.AddStyleAttribute("left", Style["LEFT"]);
w.AddStyleAttribute("top", Style["TOP"]);
if (Width != Unit.Empty) w.AddStyleAttribute(HtmlTextWriterStyle.Width, Width.ToString());
else w.AddStyleAttribute(HtmlTextWriterStyle.Width, "200px");
w.RenderBeginTag(HtmlTextWriterTag.Table);
w.RenderBeginTag(HtmlTextWriterTag.Tr);
// 第一行:輸入框 (90%) + 按鈕 (10%)
w.AddAttribute(HtmlTextWriterAttribute.Width, "90%");
w.RenderBeginTag(HtmlTextWriterTag.Td);
RenderInput(w);
w.RenderEndTag(); // Td
w.AddAttribute(HtmlTextWriterAttribute.Width, "*");
w.RenderBeginTag(HtmlTextWriterTag.Td);
RenderButton(w);
w.RenderEndTag(); // Td
w.RenderEndTag(); // Tr
// 第二行:日歷跨兩列
w.RenderBeginTag(HtmlTextWriterTag.Tr);
w.AddAttribute(HtmlTextWriterAttribute.ColSpan, "2");
w.RenderBeginTag(HtmlTextWriterTag.Td);
_calendar.RenderControl(w); // 渲染子控件
w.RenderEndTag(); // Td
w.RenderEndTag(); // Tr
w.RenderEndTag(); // Table
}
private void RenderInput(HtmlTextWriter w)
{
if (!Enabled) w.AddAttribute(HtmlTextWriterAttribute.ReadOnly, "true");
w.AddAttribute(HtmlTextWriterAttribute.Type, "text");
w.AddAttribute(HtmlTextWriterAttribute.Id, InputID);
w.AddAttribute(HtmlTextWriterAttribute.Name, InputID);
w.AddAttribute(HtmlTextWriterAttribute.Value, Text);
w.AddStyleAttribute(HtmlTextWriterStyle.Width, "100%");
w.AddStyleAttribute(HtmlTextWriterStyle.FontFamily, Font.Name);
w.AddStyleAttribute(HtmlTextWriterStyle.FontSize, Font.Size.ToString());
w.AddStyleAttribute(HtmlTextWriterStyle.FontWeight, Font.Bold ? "bold" : "");
w.AddStyleAttribute(HtmlTextWriterStyle.BackgroundColor, ColorTranslator.ToHtml(BackColor));
w.AddStyleAttribute(HtmlTextWriterStyle.Color, ColorTranslator.ToHtml(ForeColor));
w.RenderBeginTag(HtmlTextWriterTag.Input);
w.RenderEndTag();
}
private void RenderButton(HtmlTextWriter w)
{
if (!Enabled) w.AddAttribute(HtmlTextWriterAttribute.Disabled, "true");
w.AddAttribute(HtmlTextWriterAttribute.Type, "submit");
w.AddAttribute(HtmlTextWriterAttribute.Id, ButtonID);
w.AddAttribute(HtmlTextWriterAttribute.Name, ButtonID);
w.AddAttribute(HtmlTextWriterAttribute.Value, ButtonText);
w.AddStyleAttribute(HtmlTextWriterStyle.Width, "100%");
w.AddAttribute(HtmlTextWriterAttribute.Style, ButtonStyle);
// 關鍵:點擊按鈕觸發回發,服務端 RaisePostBackEvent
w.AddAttribute(HtmlTextWriterAttribute.OnClick, Page.GetPostBackEventReference(this));
w.RenderBeginTag(HtmlTextWriterTag.Input);
w.RenderEndTag();
}
// IPostBackEventHandler 實現
public void RaisePostBackEvent(string eventArgument)
{
OnClick(EventArgs.Empty);
}
protected virtual void OnClick(EventArgs e)
{
EnsureChildControls();
// 點擊按鈕:切換日歷顯示狀態
if (_calendar.Style["display"] == "none")
{
_calendar.SelectedDate = DateTime.Parse(Text);
_calendar.Style["display"] = ""; // 顯示
}
else
{
_calendar.Style["display"] = "none"; // 隱藏
}
}
// 日歷選擇日期事件
private void Calendar_SelectionChanged(object sender, EventArgs e)
{
Text = _calendar.SelectedDate.ToString(DateFormat == CalendarEnum.LongDateTime ? "yyyy-MM-dd" : "yyyy-M-d");
_calendar.Style["display"] = "none"; // 選完自動隱藏
}
| 技術點 | 說明 |
|---|---|
| 復合控件 | 包含 Input、Button、Calendar 三個子控件 |
| ViewState | 所有屬性值存入 ViewState,跨回發保持 |
| IPostBackEventHandler | 接收按鈕回發,服務端控制日歷顯隱 |
| EnsureChildControls | 屬性存取前確保子控件已創建 |
| Style[display]=none | 隱藏日歷但保留 HTML,避免佈局跳動 |
| ClientID/UniqueID | 生成唯一 ID/Name,避免多實例衝突 |
<%@ Register Assembly="AquaCalendar" Namespace="AquaCalendar" TagPrefix="aq" %>
<aq:DatePicker ID="dp1" runat="server" Text="2024-01-15" DateFormat="LongDateTime"
Width="220px" ButtonText="選擇" ButtonStyle="background:#333399;color:white;border:none;" />
| 方案 | 優點 | 缺點 |
|---|---|---|
| 本文 WebControl | 服務端完整控制、ViewState 自動、設計器支持 | WebForms 過時、回發開銷大、移動端體驗差 |
| ASP.NET Core + TagHelper | 現代、輕量、Razor 原生 | 無 ViewState、需手動 JS 交互 |
| jQuery UI / Flatpickr / Bootstrap Datepicker | 純客戶端、體驗佳、生態豐富 | 需手動綁定、驗證、本地化 |
| Blazor 組件 | C# 全棧、組件化、WebAssembly | 學習曲線、體積較大 |
自定義服務器控件 = WebControl + 子控件構建 + ViewState 屬性 + Render 渲染 + PostBack 事件。本文 DatePicker 完整展示了這一經典模式。雖然 WebForms 已退出歷史舞台,但理解其控件生命周期、狀態管理、渲染管道,對掌握 ASP.NET Core TagHelper、Blazor 組件、甚至 React 組件設計思想都有極大啟發。