一、项目背景详细介绍
在许多桌面和 Web 应用中,水平菜单栏(如 Swing 的 JMenuBar)是最常见的导航方式,但在一些管理后台、IDE 插件、辅助工具或侧边导航场景下,纵向菜单栏(Vertical Menu Bar)更符合用户习惯,可充分利用侧边栏空间,并且便于层级式导航。纵向菜单栏通常包含图标与文字、支持折叠/展开子菜单、可高亮当前激活项,并在窗口调整时保持定位稳定。常见于 Eclipse、VSCode、各类管理后台及移动端适配界面。
本项目旨在使用 Java(Swing 与 JavaFX 两种 UI 框架)实现一个功能完备的纵向菜单栏组件,具备以下特色:
-
垂直排列主菜单项,支持可折叠的子菜单
-
图标与文字混排,高亮选中状态
-
可设置固定宽度,内容自适应高度滚动
-
支持主题色与样式定制
-
可绑定菜单项点击事件
二、项目需求详细介绍
-
垂直排列
-
主菜单项纵向排列,需支持固定宽度和灵活高度;
-
可自定义图标和文字位置(图标左/右、文字居中等)。
-
-
子菜单折叠/展开
-
支持多级子菜单,点击父项可展开或折叠其子项;
-
展开时子项缩进显示,并支持其自身图标与点击事件;
-
-
选中高亮
-
当前选中项背景和文字需使用主题色高亮;
-
可配置高亮颜色及字体样式;
-
-
滚动支持
-
当菜单项总高度超过容器高度时,需自动出现滚动条;
-
-
事件绑定
-
为每个菜单项绑定
ActionListener(Swing)或回调(JavaFX),触发业务逻辑;
-
-
主题与样式
-
支持通过外部属性文件或 CSS 样式定义背景色、鼠标悬停色、选中色、图标大小;
-
可在运行时切换主题;
-
-
集成简便
-
提供
VerticalMenuBar类,外部只需传入配置或调用添加菜单项方法即可; -
支持动态增删菜单项;
-
-
示例与测试
-
提供 Swing 与 JavaFX 两个 Demo,演示菜单栏在主窗体侧边应用;
-
单元测试或人工测试验证折叠、展开、点击事件;
-
三、相关技术详细介绍
-
Swing 实现
-
使用
JPanel垂直布局(BoxLayout.Y_AXIS)或第三方布局管理器; -
每个菜单项可由
JButton或自定义JPanel组合图标与文本; -
折叠逻辑通过控制子菜单容器的
setVisible(true/false)并重绘; -
滚动支持则将主容器包装在
JScrollPane中; -
主题样式可通过
UIManager.put或组件setBackground、setForeground动态更新。
-
-
JavaFX 实现
-
使用
VBox容器垂直布局; -
菜单项由
HBox包含ImageView与Label; -
折叠子菜单通过修改子节点的
managed与visible属性; -
滚动支持使用
ScrollPane包裹VBox; -
样式可通过外部
.css文件或setStyle绑定样式类。
-
-
配置管理
-
使用
Properties或 JSON/YAML 文件定义菜单结构及样式; -
运行时加载配置并构建菜单;
-
-
事件处理
-
Swing:为每个按钮添加
ActionListener; -
JavaFX:为
Node设置setOnMouseClicked回调; -
统一在管理类中维护菜单项与业务回调的映射关系。
-
-
可扩展性
-
动态增删改:在菜单管理类中提供
addItem/removeItem/updateItem方法; -
主题切换:监听配置变更,动态重新应用样式。
-
四、实现思路详细介绍
-
组件封装
-
定义
VerticalMenuBar类(针对 Swing)及VerticalMenuBarFX(针对 JavaFX),内部持有根容器(JScrollPane/ScrollPane包裹JPanel/VBox);
-
-
菜单模型
-
定义
MenuItemData对象,包含id、text、iconPath、List<MenuItemData> children; -
可从配置文件加载出一棵树形结构;
-
-
构建渲染
-
递归遍历
MenuItemData:-
对于每个数据节点,创建对应的 UI 组件,设置图标、文字与事件;
-
如果有子节点,则在父下方创建一个可折叠的子容器,并预先隐藏;
-
-
-
折叠/展开逻辑
-
菜单项点击时,判断是否有子菜单:
-
若有,切换子容器的可见性,并执行容器
revalidate()/layout()或layout(); -
若无,触发菜单点击的业务回调;
-
-
-
高亮选中
-
在点击时清除之前选中项的高亮样式,为当前项添加选中样式;
-
-
滚动支持
-
菜单整体高于可视范围时,
JScrollPane或ScrollPane自动出现滚动条;
-
-
样式与主题
-
从配置加载背景、悬停、选中等颜色值,并在构建或鼠标悬停/退出事件中
setBackground或添加/移除样式类;
-
-
示例集成
-
在
MainSwingVerticalMenu和MainJavaFXVerticalMenu中,将菜单栏添加到主窗体的左侧(BorderLayout.WEST或BorderPane.left),并演示点击与折叠效果。
-
五、完整实现代码
// =============================================
// File: MenuItemData.java
// 菜单数据模型
// =============================================
package com.example.verticalmenu;
import java.util.List;
/**
* 菜单项数据模型,用于描述文本、图标及子菜单
*/
public class MenuItemData {
private String id; // 唯一标识
private String text; // 菜单显示文本
private String iconPath; // 图标资源路径
private List<MenuItemData> children; // 子菜单项
// 构造、getter、setter
public MenuItemData(String id, String text, String iconPath, List<MenuItemData> children) {
this.id = id; this.text = text; this.iconPath = iconPath; this.children = children;
}
public String getId() { return id; }
public String getText() { return text; }
public String getIconPath() { return iconPath; }
public List<MenuItemData> getChildren() { return children; }
}
// =============================================
// File: VerticalMenuBar.java
// Swing 纵向菜单栏组件
// =============================================
package com.example.verticalmenu;
import javax.swing.*;
import javax.swing.border.EmptyBorder;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.*;
import java.util.List;
import java.util.function.Consumer;
/**
* 纵向菜单栏 Swing 实现
*/
public class VerticalMenuBar extends JScrollPane {
private JPanel container; // 承载所有菜单项的容器
private Map<String, JMenuItemPanel> itemPanelMap = new HashMap<>();// id->面板映射
private Consumer<String> onItemClick; // 菜单点击回调
public VerticalMenuBar(List<MenuItemData> menuData, Consumer<String> callback) {
this.onItemClick = callback;
container = new JPanel();
container.setLayout(new BoxLayout(container, BoxLayout.Y_AXIS));
container.setBackground(Color.WHITE);
container.setBorder(null);
this.setViewportView(container);
this.setBorder(null);
buildMenu(menuData, container, 0);
}
/** 递归构建菜单:level 表示缩进层级 */
private void buildMenu(List<MenuItemData> list, JPanel parent, int level) {
for (MenuItemData data : list) {
JMenuItemPanel panel = new JMenuItemPanel(data, level);
itemPanelMap.put(data.getId(), panel);
parent.add(panel);
if (!data.getChildren().isEmpty()) {
// 子菜单容器,初始隐藏
JPanel childContainer = new JPanel();
childContainer.setLayout(new BoxLayout(childContainer, BoxLayout.Y_AXIS));
childContainer.setBackground(Color.WHITE);
childContainer.setVisible(false);
parent.add(childContainer);
panel.setChildContainer(childContainer);
// 递归
buildMenu(data.getChildren(), childContainer, level + 1);
}
}
}
/** Programmatic select by id */
public void selectItem(String id) {
itemPanelMap.values().forEach(p -> p.setSelected(false));
JMenuItemPanel panel = itemPanelMap.get(id);
if (panel != null) {
panel.setSelected(true);
}
}
// 内部类:每个菜单项面板
private class JMenuItemPanel extends JPanel {
private JLabel iconLabel, textLabel;
private JPanel childContainer;
private boolean expanded = false;
private Color normalBg = Color.WHITE;
private Color hoverBg = new Color(230, 230, 230);
private Color selectedBg = new Color(200, 220, 240);
public JMenuItemPanel(MenuItemData data, int level) {
this.setLayout(new BorderLayout());
this.setMaximumSize(new Dimension(Integer.MAX_VALUE, 40));
this.setBackground(normalBg);
this.setBorder(new EmptyBorder(5, 10 + level * 20, 5, 5));
// 图标
iconLabel = new JLabel();
if (data.getIconPath() != null) {
iconLabel.setIcon(new ImageIcon(getClass().getResource(data.getIconPath())));
}
this.add(iconLabel, BorderLayout.WEST);
// 文本
textLabel = new JLabel(data.getText());
this.add(textLabel, BorderLayout.CENTER);
// 鼠标交互
this.addMouseListener(new MouseAdapter() {
@Override
public void mouseEntered(MouseEvent e) {
if (!expanded) setBackground(hoverBg);
}
@Override
public void mouseExited(MouseEvent e) {
if (!expanded) setBackground(normalBg);
}
@Override
public void mouseClicked(MouseEvent e) {
// 有子菜单则折叠/展开
if (childContainer != null) {
expanded = !expanded;
childContainer.setVisible(expanded);
revalidate();
}
// 高亮选中
itemPanelMap.values().forEach(p -> p.setSelected(false));
setSelected(true);
onItemClick.accept(data.getId());
}
});
}
public void setChildContainer(JPanel child) {
this.childContainer = child;
}
public void setSelected(boolean sel) {
expanded = sel;
setBackground(sel ? selectedBg : normalBg);
}
}
}
// =============================================
// File: MainSwingVerticalMenu.java
// Swing 示例:主窗体中使用纵向菜单栏
// =============================================
package com.example.verticalmenu;
import javax.swing.*;
import java.awt.*;
import java.util.Arrays;
/**
* 演示 VerticalMenuBar 的 Swing 应用
*/
public class MainSwingVerticalMenu {
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
JFrame frame = new JFrame("Swing 纵向菜单栏示例");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(800, 600);
frame.setLayout(new BorderLayout());
// 构造菜单数据
MenuItemData sub1 = new MenuItemData("item1-1", "子项 1-1", null, Arrays.asList());
MenuItemData sub2 = new MenuItemData("item1-2", "子项 1-2", null, Arrays.asList());
MenuItemData m1 = new MenuItemData("item1", "菜单项 1", "/icons/icon1.png", Arrays.asList(sub1, sub2));
MenuItemData m2 = new MenuItemData("item2", "菜单项 2", "/icons/icon2.png", Arrays.asList());
MenuItemData m3 = new MenuItemData("item3", "菜单项 3", "/icons/icon3.png", Arrays.asList());
java.util.List<MenuItemData> data = Arrays.asList(m1, m2, m3);
// 创建纵向菜单栏
VerticalMenuBar menuBar = new VerticalMenuBar(data, id -> {
// 点击回调
System.out.println("点击菜单: " + id);
});
frame.getContentPane().add(menuBar, BorderLayout.WEST);
// 主内容区
JTextArea text = new JTextArea("主内容区");
frame.getContentPane().add(text, BorderLayout.CENTER);
frame.setVisible(true);
});
}
}
// =============================================
// File: VerticalMenuBarFX.java
// JavaFX 纵向菜单栏组件
// =============================================
package com.example.verticalmenufx;
import javafx.geometry.Insets;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.VBox;
import javafx.scene.layout.HBox;
import javafx.scene.paint.Color;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* JavaFX 纵向菜单栏实现
*/
public class VerticalMenuBarFX extends ScrollPane {
private VBox container = new VBox();
private Map<String, HBox> itemMap = new HashMap<>();
private Consumer<String> onItemClick;
public VerticalMenuBarFX(List<MenuItemDataFX> data, Consumer<String> callback) {
this.onItemClick = callback;
container.setSpacing(2);
container.setPadding(new Insets(5));
this.setContent(container);
buildMenu(data, 0);
}
private void buildMenu(List<MenuItemDataFX> list, int level) {
for (MenuItemDataFX d : list) {
HBox hbox = new HBox(5);
hbox.setPadding(new Insets(5,5,5,5 + level * 20));
hbox.setStyle("-fx-background-color: white;");
if (d.getIconPath()!=null) {
ImageView iv = new ImageView(new Image(getClass().getResourceAsStream(d.getIconPath())));
iv.setFitWidth(16); iv.setFitHeight(16);
hbox.getChildren().add(iv);
}
Label lbl = new Label(d.getText());
hbox.getChildren().add(lbl);
container.getChildren().add(hbox);
itemMap.put(d.getId(), hbox);
// 点击处理
hbox.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
// 清空选中样式
itemMap.values().forEach(node -> node.setStyle("-fx-background-color: white;"));
hbox.setStyle("-fx-background-color: lightblue;");
onItemClick.accept(d.getId());
// 折叠/展开
if (!d.getChildren().isEmpty()) {
if (d.isExpanded()) {
d.setExpanded(false);
collapseChildren(hbox, d);
} else {
d.setExpanded(true);
expandChildren(hbox, d, level+1);
}
}
});
}
}
private void expandChildren(Node after, MenuItemDataFX parent, int level) {
int idx = container.getChildren().indexOf(after);
for (MenuItemDataFX c : parent.getChildren()) {
HBox hbox = new HBox(5);
hbox.setPadding(new Insets(5,5,5,5 + level * 20));
hbox.setStyle("-fx-background-color: white;");
if (c.getIconPath()!=null) {
ImageView iv = new ImageView(new Image(getClass().getResourceAsStream(c.getIconPath())));
iv.setFitWidth(16); iv.setFitHeight(16);
hbox.getChildren().add(iv);
}
hbox.getChildren().add(new Label(c.getText()));
container.getChildren().add(++idx, hbox);
itemMap.put(c.getId(), hbox);
}
}
private void collapseChildren(Node after, MenuItemDataFX parent) {
for (MenuItemDataFX c : parent.getChildren()) {
Node node = itemMap.get(c.getId());
container.getChildren().remove(node);
itemMap.remove(c.getId());
}
}
}
// =============================================
// File: MenuItemDataFX.java
// JavaFX 菜单数据模型,带折叠状态
// =============================================
package com.example.verticalmenufx;
import java.util.List;
/**
* JavaFX 版菜单数据模型
*/
public class MenuItemDataFX {
private String id, text, iconPath;
private List<MenuItemDataFX> children;
private boolean expanded = false;
// 构造、getter、setter
}
// =============================================
// File: MainJavaFXVerticalMenu.java
// JavaFX 示例:主窗体中使用纵向菜单栏
// =============================================
package com.example.verticalmenufx;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import java.util.Arrays;
/**
* 演示 VerticalMenuBarFX 的 JavaFX 应用
*/
public class MainJavaFXVerticalMenu extends Application {
@Override
public void start(Stage primaryStage) {
primaryStage.setTitle("JavaFX 纵向菜单栏示例");
BorderPane root = new BorderPane();
Scene scene = new Scene(root, 800, 600);
// 构造数据
MenuItemDataFX sub1 = new MenuItemDataFX("fx1-1","子项1-1",null,Arrays.asList());
MenuItemDataFX sub2 = new MenuItemDataFX("fx1-2","子项1-2",null,Arrays.asList());
MenuItemDataFX m1 = new MenuItemDataFX("fx1","菜单1","/icons/icon1.png",Arrays.asList(sub1, sub2));
MenuItemDataFX m2 = new MenuItemDataFX("fx2","菜单2","/icons/icon2.png",Arrays.asList());
java.util.List<MenuItemDataFX> data = Arrays.asList(m1, m2);
// 创建并放置左侧菜单栏
VerticalMenuBarFX menu = new VerticalMenuBarFX(data, id -> {
System.out.println("点击FX菜单: " + id);
});
root.setLeft(menu);
// 中心显示
root.setCenter(new Label("主内容区"));
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) { launch(args); }
}
六、代码详细解读
-
MenuItemData / MenuItemDataFX
-
数据模型类,存放
id、text、iconPath及children列表(JavaFX 版本还带expanded状态)。
-
-
VerticalMenuBar(Swing)
-
继承
JScrollPane,内部用JPanel垂直布局; -
buildMenu(...):递归遍历数据模型,创建JMenuItemPanel,并在有子菜单时插入一个隐藏的容器; -
JMenuItemPanel:自定义
JPanel,显示图标与文字,处理鼠标悬停、点击事件,切换子容器可见性,触发高亮与回调; -
selectItem(id):外部可编程选中、清除高亮。
-
-
MainSwingVerticalMenu
-
构造示例数据(含子项),将
VerticalMenuBar放置于BorderLayout.WEST,并绑定点击回调;
-
-
VerticalMenuBarFX(JavaFX)
-
继承
ScrollPane,内部用VBox; -
buildMenu(...):遍历MenuItemDataFX列表,为每项创建HBox,设置图标、标签、点击事件; -
点击后清除所有高亮,设置当前高亮,并调用
expandChildren/collapseChildren管理子节点显隐;
-
-
MainJavaFXVerticalMenu
-
在
BorderPane.left放置VerticalMenuBarFX,展示效果并打印点击事件。
-
七、项目详细总结
-
通用数据模型:使用
MenuItemData/MenuItemDataFX定义树形结构,UI 与数据分离,支持动态更新。 -
递归构建:二者均采用递归构建菜单项,支持任意层级折叠与展开。
-
高亮与交互:鼠标悬停与点击状态均给予视觉反馈,高亮与折叠逻辑清晰。
-
滚动支持:容器超高时自动出现滚动条,保证可访问全部菜单项。
-
跨框架对比:Swing 基于
JPanel/JScrollPane与自定义面板,JavaFX 基于VBox/ScrollPane与 CSS 样式,易于对比学习。
八、项目常见问题及解答
-
Q:子菜单展开后如何保证再次点击折叠?
-
A:Swing 版通过
expanded标志与childContainer.setVisible(...);JavaFX 版通过MenuItemDataFX.expanded与动态插入/移除子节点实现。
-
-
Q:如何动态增删菜单?
-
A:可向数据模型中添加新
MenuItemData,并调用buildMenu或专门的addItem方法将其渲染到容器中。
-
-
Q:如何在选中后同步更新中心内容?
-
A:在回调
onItemClick中接收id,再根据id切换主内容面板即可。
-
-
Q:如何自定义主题色?
-
A:在创建面板或
HBox时调用setBackground/setStyle,或提供外部配置加载颜色值并应用。
-
-
Q:如何支持键盘导航?
-
A:为面板或节点设置
Focusable,使用KeyListener(Swing)或setOnKeyPressed(JavaFX)监听上下键并移动焦点。
-
九、扩展方向与性能优化
-
性能:当菜单项众多时,可采用延迟加载:仅在点击展开时构建子菜单面板。
-
动画效果:在展开/折叠时添加过渡动画(JavaFX 可用
Timeline,Swing 可用Timer逐帧调整高度)。 -
多主题:支持深色/浅色模式切换,统一加载 CSS 或
UIManager配置。 -
拖拽排序:可让用户通过拖拽重新排序菜单项,需监听拖放事件并更新数据模型。
-
响应式布局:在窗口宽度变化时自动折叠图标或文本,仅显示图标;
-
可访问性:支持屏幕阅读器提示、焦点可见性、增强键盘操作。
-
Web 版本:将同一模型与逻辑移植到 Java 后端生成 HTML/CSS 或 JavaScript 前端组件。
506

被折叠的 条评论
为什么被折叠?



