Windows 系統托盤的「氣球提示」風格視窗(帶三角箭頭的圓角矩形)常用於工具提示、表單驗證錯誤顯示。本文完整解析如何用 WinForms 繪製不規則表單區域、動態計算箭頭朝向、自動避開螢幕邊緣,附帶核心繪圖邏輯與使用範例。
public enum ArrowLocation { TopLeft, TopRight, BottomLeft, BottomRight }
private int _arcRadius = 8; // 圓角半徑
private int _arrowHeight = 12; // 箭頭高度
private GraphicsPath _path; // 視窗幾何路徑
private ArrowLocation _arrowLoc; // 當前箭頭方向
private void SetInfoWindowRegion()
{
if (!IsHandleCreated) return;
Size sz = Size;
Point[] arrowPts = new Point[3];
Point tl = Point.Empty;
Point br = new Point(sz.Width, sz.Height);
int arcDia = _arcRadius * 2;
int rectX1 = tl.X, rectY1 = tl.Y;
int rectX2 = br.X, rectY2 = br.Y;
// 1. 計算箭頭三個頂點座標
switch (_arrowLoc)
{
case ArrowLocation.TopLeft:
arrowPts[0] = new Point(_arcRadius, 0);
arrowPts[1] = new Point(_arcRadius + _arrowHeight, _arrowHeight);
arrowPts[2] = new Point(_arcRadius + 2 * _arrowHeight, 0);
break;
case ArrowLocation.TopRight:
arrowPts[0] = new Point(rectX2 - _arcRadius - 2 * _arrowHeight, 0);
arrowPts[1] = new Point(rectX2 - _arcRadius - _arrowHeight, _arrowHeight);
arrowPts[2] = new Point(rectX2 - _arcRadius, 0);
break;
case ArrowLocation.BottomLeft:
arrowPts[0] = new Point(_arcRadius, rectY2);
arrowPts[1] = new Point(_arcRadius + _arrowHeight, rectY2 - _arrowHeight);
arrowPts[2] = new Point(_arcRadius + 2 * _arrowHeight, rectY2);
break;
case ArrowLocation.BottomRight:
arrowPts[0] = new Point(rectX2 - _arcRadius - 2 * _arrowHeight, rectY2);
arrowPts[1] = new Point(rectX2 - _arcRadius - _arrowHeight, rectY2 - _arrowHeight);
arrowPts[2] = new Point(rectX2 - _arcRadius, rectY2);
break;
}
// 2. 構建 GraphicsPath(順序決定填充方向)
_path = new GraphicsPath();
if (_arrowLoc == ArrowLocation.TopLeft || _arrowLoc == ArrowLocation.TopRight)
{
// 箭頭在上:從左上角順時針繞行
_path.AddArc(rectX1, rectY1, arcDia, arcDia, 180, 90); // 左上圓弧
_path.AddLine(rectX1 + _arcRadius, rectY1, arrowPts[0].X, rectY1); // 上邊到箭頭起點
_path.AddLines(arrowPts); // 箭頭三邊
_path.AddLine(arrowPts[2].X, rectY1, rectX2 - _arcRadius, rectY1); // 箭頭終點到右上
_path.AddArc(rectX2 - arcDia, rectY1, arcDia, arcDia, 270, 90); // 右上圓弧
_path.AddLine(rectX2, rectY1 + _arcRadius, rectX2, rectY2 - _arcRadius); // 右邊
_path.AddArc(rectX2 - arcDia, rectY2 - arcDia, arcDia, arcDia, 0, 90); // 右下圓弧
_path.AddLine(rectX2 - _arcRadius, rectY2, rectX1 + _arcRadius, rectY2); // 下邊
_path.AddArc(rectX1, rectY2 - arcDia, arcDia, arcDia, 90, 90); // 左下圓弧
_path.AddLine(rectX1, rectY2 - _arcRadius, rectX1, rectY1 + _arcRadius); // 左邊
}
else
{
// 箭頭在下:類似邏輯,順序調整
_path.AddArc(rectX1, rectY1, arcDia, arcDia, 180, 90);
_path.AddLine(rectX1 + _arcRadius, rectY1, rectX2 - _arcRadius, rectY1);
_path.AddArc(rectX2 - arcDia, rectY1, arcDia, arcDia, 270, 90);
_path.AddLine(rectX2, rectY1 + _arcRadius, rectX2, rectY2 - _arcRadius);
_path.AddArc(rectX2 - arcDia, rectY2 - arcDia, arcDia, arcDia, 0, 90);
_path.AddLine(rectX2 - _arcRadius, rectY2, arrowPts[2].X, rectY2);
_path.AddLines(arrowPts);
_path.AddLine(arrowPts[0].X, rectY2, rectX1 + _arcRadius, rectY2);
_path.AddArc(rectX1, rectY2 - arcDia, arcDia, arcDia, 90, 90);
_path.AddLine(rectX1, rectY2 - _arcRadius, rectX1, rectY1 + _arcRadius);
}
_path.CloseFigure();
Region = new Region(_path); // 套用不規則區域
}
// 依錨點控件螢幕位置決定顯示位置與箭頭方向
public ArrowLocation GetArrowLocation(Control anchor)
{
Point anchorScreen = AnchorPointFromControl(anchor);
Rectangle screenBounds = Screen.FromControl(anchor).WorkingArea;
Size winSize = Size;
// 嘗試順序:右下 → 左下 → 右上 → 左上(可自訂優先級)
// 這裡簡化:若錨點在螢幕下半部 → 顯示在上方 (BottomLeft/Right)
bool anchorInLowerHalf = anchorScreen.Y > screenBounds.Height / 2;
bool anchorInRightHalf = anchorScreen.X > screenBounds.Width / 2;
if (anchorInLowerHalf)
{
// 視窗顯示在錨點上方
Location = new Point(
Math.Min(Math.Max(anchorScreen.X - winSize.Width / 2, screenBounds.Left), screenBounds.Right - winSize.Width),
anchorScreen.Y - winSize.Height - _arrowHeight
);
return anchorInRightHalf ? ArrowLocation.BottomRight : ArrowLocation.BottomLeft;
}
else
{
// 視窗顯示在錨點下方
Location = new Point(
Math.Min(Math.Max(anchorScreen.X - winSize.Width / 2, screenBounds.Left), screenBounds.Right - winSize.Width),
anchorScreen.Y + _arrowHeight
);
return anchorInRightHalf ? ArrowLocation.TopRight : ArrowLocation.TopLeft;
}
}
public static Point AnchorPointFromControl(Control c)
{
if (c == null) throw new ArgumentNullException(nameof(c));
Point loc = c.Location;
if (c.Parent != null) loc = c.Parent.PointToScreen(loc);
return new Point(loc.X + c.Width / 2, loc.Y + c.Height / 2); // 控件中心點
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
using (Pen pen = new Pen(Color.Black, 2))
{
e.Graphics.DrawPath(pen, _path); // 沿路徑畫框
}
// 內容文字已由 Label 控件顯示,無需手動 DrawString
}
public partial class MainForm : Form
{
private InfoWindow _tip;
private void btnShowLabelTip_Click(object sender, EventArgs e)
{
_tip = new InfoWindow();
_tip.ShowInfoWindow(label1, "這是 Label 的提示說明");
}
private void btnShowButtonTip_Click(object sender, EventArgs e)
{
_tip = new InfoWindow();
_tip.ShowInfoWindow(button2, "按鈕操作提示");
}
private void btnShowTextBoxTip_Click(object sender, EventArgs e)
{
_tip = new InfoWindow();
_tip.ShowInfoWindow(textBox1, "請輸入有效郵箱地址");
}
}
ToastNotifications、MaterialSkin、Guna.UI2 皆含現成氣球提示元件。自繪不規則表單展示了 `GraphicsPath` + `Region` 的強大組合。核心在於幾何路徑構建順序與螢幕座標動態計算。雖 WinForms 時代技術,但理解其原理有助於掌握自訂視窗形狀、非矩形點擊測試、高 DPI 適配等進階繪圖技巧。新專案建議優先使用內建 `ToolTip.IsBalloon` 或 WPF/MAUI 現代化方案。