雖然現代 Java 建議使用 `java.time` (JSR-310) 進行日期時間處理,但理解傳統 `java.util.Calendar` / `GregorianCalendar` 的運作邏輯,對於維護舊系統、閱讀經典教學代碼仍有必要。本文將從零構建一個具備「月份切換、今日高亮、日期點擊事件」功能的 Swing 日曆元件,並對比新舊 API 寫法,幫助開發者平滑遷移。
日曆本質是一個 **6 行 × 7 列** 的二維網格 (最多 6 週)。生成關鍵在於確定:**該月 1 號是星期幾**、**該月總天數**。
/** 純邏輯類:生成指定年月的日曆網格數據 (與 UI 無關) */
public class CalendarModel {
private final int year;
private final int month; // 1-12
public CalendarModel(int year, int month) {
this.year = year;
this.month = month;
}
/** 返回 6x7 陣列,0 代表非本月日期 (灰顯) */
public int[][] getMonthGrid() {
// 1. 使用 GregorianCalendar 獲取關鍵資訊
// 注意: Calendar.MONTH 為 0-based (0=一月)
Calendar cal = new GregorianCalendar(year, month - 1, 1);
int firstDayOfWeek = cal.get(Calendar.DAY_OF_WEEK); // 1=星期日 ... 7=星期六
int daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH);
// 2. 計算上月尾巴天數 (用於填充第一行前方空格)
Calendar prevCal = new GregorianCalendar(year, month - 1, 1);
prevCal.add(Calendar.DAY_OF_MONTH, -1);
int prevMonthDays = prevCal.getActualMaximum(Calendar.DAY_OF_MONTH);
// 3. 填充 6x7 網格
int[][] grid = new int[6][7];
int day = 1;
boolean nextMonth = false;
for (int row = 0; row < 6; row++) {
for (int col = 0; col < 7; col++) {
// 星期對齊: col 0=星期日 ... col 6=星期六
if (row == 0 && col < firstDayOfWeek - 1) {
// 上月日期 (負數標記或單獨標記)
grid[row][col] = -(prevMonthDays - (firstDayOfWeek - 2 - col));
} else if (day <= daysInMonth) {
grid[row][col] = day++;
} else {
// 下月日期 (負數或 > daysInMonth 標記)
grid[row][col] = -(day++ - daysInMonth); // 1, 2, 3...
nextMonth = true;
}
}
if (nextMonth && day > daysInMonth + 7) break; // 提前結束
}
return grid;
}
public int getYear() { return year; }
public int getMonth() { return month; }
public String getMonthTitle() {
return String.format("%d 年 %02d 月", year, month);
}
}
public class CalendarPanel extends JPanel {
private CalendarModel model;
private JLabel titleLabel;
private JPanel gridPanel; // 6x7 GridLayout
private Consumer dateClickListener; // 日期點擊回調
public CalendarPanel() {
// 初始化為本月
LocalDate now = LocalDate.now(); // 現代 API 獲取當前日期
this.model = new CalendarModel(now.getYear(), now.getMonthValue());
setLayout(new BorderLayout(5, 5));
setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
// 頂部標題 + 導航按鈕
JPanel header = new JPanel(new BorderLayout());
JButton prevBtn = new JButton("◀");
JButton nextBtn = new JButton("▶");
titleLabel = new JLabel(model.getMonthTitle(), SwingConstants.CENTER);
titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD, 16f));
prevBtn.addActionListener(e -> navigate(-1));
nextBtn.addActionListener(e -> navigate(1));
header.add(prevBtn, BorderLayout.WEST);
header.add(titleLabel, BorderLayout.CENTER);
header.add(nextBtn, BorderLayout.EAST);
add(header, BorderLayout.NORTH);
// 中央網格
gridPanel = new JPanel(new GridLayout(0, 7, 2, 2)); // 7列, 間距2
// 星期標題行
String[] weekDays = {"日", "一", "二", "三", "四", "五", "六"};
for (String w : weekDays) {
JLabel lbl = new JLabel(w, SwingConstants.CENTER);
lbl.setFont(lbl.getFont().deriveFont(Font.BOLD, 12f));
lbl.setForeground(Color.GRAY);
gridPanel.add(lbl);
}
add(gridPanel, BorderLayout.CENTER);
refreshGrid();
}
private void navigate(int delta) {
// 使用現代 API 進行月份運算,再轉回模型
LocalDate current = LocalDate.of(model.getYear(), model.getMonth(), 1);
LocalDate target = current.plusMonths(delta);
this.model = new CalendarModel(target.getYear(), target.getMonthValue());
titleLabel.setText(model.getMonthTitle());
refreshGrid();
}
private void refreshGrid() {
// 保留星期標題 (前7個元件), 移除舊日期按鈕
while (gridPanel.getComponentCount() > 7) {
gridPanel.remove(7); // 總是移除第8個 (索引7)
}
int[][] grid = model.getMonthGrid();
LocalDate today = LocalDate.now();
boolean isCurrentMonth = (model.getYear() == today.getYear() && model.getMonth() == today.getMonthValue());
for (int row = 0; row < grid.length; row++) {
for (int col = 0; col < 7; col++) {
int dayVal = grid[row][col];
boolean isToday = (isCurrentMonth && dayVal == today.getDayOfMonth());
boolean isCurrentMonthDay = (dayVal > 0);
JButton btn = new JButton(String.valueOf(Math.abs(dayVal)));
btn.setFocusPainted(false);
btn.setBorder(BorderFactory.createLineBorder(Color.LIGHT_GRAY));
btn.setFont(btn.getFont().deriveFont(13f));
if (!isCurrentMonthDay) {
btn.setForeground(Color.LIGHT_GRAY); // 灰顯非本月
btn.setEnabled(false);
} else {
btn.setForeground(Color.BLACK);
if (isToday) {
btn.setBackground(new Color(0xBBDEFB)); // 今日藍底
btn.setBorder(BorderFactory.createLineBorder(new Color(0x1976D2), 2));
}
final int day = dayVal;
btn.addActionListener(e -> {
if (dateClickListener != null) dateClickListener.accept(day);
});
}
gridPanel.add(btn);
}
}
gridPanel.revalidate();
gridPanel.repaint();
}
public void setDateClickListener(Consumer listener) {
this.dateClickListener = listener;
}
// 測試主程式
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
JFrame frame = new JFrame("Java 日曆元件");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
CalendarPanel cal = new CalendarPanel();
cal.setDateClickListener(day ->
JOptionPane.showMessageDialog(cal, "您點選了: " + cal.model.getYear() + "-" + cal.model.getMonth() + "-" + day)
);
frame.add(cal);
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
});
}
}
| 功能 | 舊 API (Calendar/GregorianCalendar) | 新 API (java.time, JDK 8+) | 優勢 |
|---|---|---|---|
| 獲取當前日期 | `Calendar.getInstance()` | `LocalDate.now()` / `LocalDateTime.now()` | 不可變、線程安全、無時區驚喜 |
| 構建指定日期 | `new GregorianCalendar(y, m-1, d)` | `LocalDate.of(y, m, d)` | 月份 1-based 直觀,參數順序自然 |
| 月份天數 | `cal.getActualMaximum(Calendar.DAY_OF_MONTH)` | `date.lengthOfMonth()` / `YearMonth.of(y,m).lengthOfMonth()` | 方法語義清晰 |
| 星期幾 | `cal.get(Calendar.DAY_OF_WEEK)` (1=Sun) | `date.getDayOfWeek()` (Enum: MONDAY=1...SUNDAY=7) | 強類型 Enum,避免魔術數字 |
| 日期加減 | `cal.add(Calendar.MONTH, 1)` | `date.plusMonths(1)` / `date.minusDays(5)` | 不可變鏈式調用,線程安全 |
| 格式化輸出 | `SimpleDateFormat` (非線程安全!) | `DateTimeFormatter` (線程安全、可預編譯) | 解決 `SimpleDateFormat` 經典併發 Bug |
| 時區處理 | `Calendar.setTimeZone()` / `TimeZone` | `ZonedDateTime` / `OffsetDateTime` / `ZoneId` | 領域模型清晰分離:本地時間/瞬間/時區 |
public class ModernCalendarModel {
private final YearMonth yearMonth; // 不可變,線程安全
public ModernCalendarModel(int year, int month) {
this.yearMonth = YearMonth.of(year, month);
}
public int[][] getMonthGrid() {
LocalDate firstDay = yearMonth.atDay(1);
int firstDayOfWeek = firstDay.getDayOfWeek().getValue() % 7; // MON=1->1, SUN=7->0 (調整為 0=Sun)
// 若習慣 0=Mon,可直接用 getValue()-1
int daysInMonth = yearMonth.lengthOfMonth();
// 上月尾巴
YearMonth prevMonth = yearMonth.minusMonths(1);
int prevMonthDays = prevMonth.lengthOfMonth();
int[][] grid = new int[6][7];
int day = 1;
for (int row = 0; row < 6; row++) {
for (int col = 0; col < 7; col++) {
if (row == 0 && col < firstDayOfWeek) {
grid[row][col] = -(prevMonthDays - (firstDayOfWeek - 1 - col));
} else if (day <= daysInMonth) {
grid[row][col] = day++;
} else {
grid[row][col] = -(day++ - daysInMonth);
}
}
if (day > daysInMonth + 7) break;
}
return grid;
}
// ... getYear, getMonth, getMonthTitle 同理委託 yearMonth ...
}
本文實作了一個**模型與視圖分離**、支援**月份導航、今日高亮、點擊回調**的 Swing 日曆元件。關鍵收穫:
掌握日曆網格生成算法,不僅能造出日曆元件,更能應用於排程器、日期選擇器、甘特圖等所有基於時間網格的視覺化組件開發中。