diff --git a/src/main/java/com/imyeyu/fx/ui/components/Navigation.java b/src/main/java/com/imyeyu/fx/ui/components/Navigation.java deleted file mode 100644 index ccfb7c2..0000000 --- a/src/main/java/com/imyeyu/fx/ui/components/Navigation.java +++ /dev/null @@ -1,236 +0,0 @@ -package com.imyeyu.fx.ui.components; - -import com.imyeyu.fx.TimiFX; -import com.imyeyu.fx.ui.TimiFXUI; -import com.imyeyu.fx.utils.SmoothScroll; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.collections.FXCollections; -import javafx.collections.ListChangeListener; -import javafx.collections.ObservableList; -import javafx.geometry.Insets; -import javafx.scene.Node; -import javafx.scene.control.ScrollPane; -import javafx.scene.control.TitledPane; -import javafx.scene.control.ToggleButton; -import javafx.scene.control.ToggleGroup; -import javafx.scene.input.MouseEvent; -import javafx.scene.layout.VBox; -import lombok.Getter; - -import java.util.List; - -/** - * 纵向导航组件,可实现二级导航,折叠导航 - * - * @author 夜雨 - * @since 2022-02-17 00:11 - */ -public class Navigation extends ScrollPane implements TimiFXUI { - - /** 导航数据列表 */ - @Getter - protected final ObservableList items; - - /** 已选中监听 */ - protected final ObjectProperty selectedItem; - - /** 默认构造器 */ - public Navigation() { - items = FXCollections.observableArrayList(); - selectedItem = new SimpleObjectProperty<>(); - - VBox root = new VBox(); - root.setBorder(Stroke.BOTTOM); - - getStyleClass().addAll("navigation", "sp-border"); - setMaxWidth(Double.MAX_VALUE); - setVbarPolicy(ScrollBarPolicy.NEVER); - setFitToWidth(true); - setContent(root); - - SmoothScroll.scrollPaneV(this); - - // ---------- 事件 ---------- - - ToggleGroup group = new ToggleGroup(); - - // 主动选择(代码触发) - selectedItem.addListener((obs, o, newSelectedItem) -> group.selectToggle(newSelectedItem)); - - // 被动选择(操作触发) - group.selectedToggleProperty().addListener((obs, o, toggle) -> { - if (toggle instanceof ToggleButton btn) { - selectedItem.set(btn); - } - }); - ObservableList children = root.getChildren(); - - // 响应 TimiFXUI - items.addListener((ListChangeListener) c -> { - while (c.next()) { - if (c.wasAdded()) { - // 添加 - List list = c.getAddedSubList(); - for (ToggleButton toggleButton : list) { - toggleButton.setMaxWidth(Double.MAX_VALUE); - toggleButton.getStyleClass().setAll(CSS.MINECRAFT, "navigation-button"); - toggleButton.addEventFilter(MouseEvent.MOUSE_PRESSED, TimiFX.EVENT_CONSUME_TG_BTN); - - if (toggleButton.getProperties().get("OWNER") instanceof TitledPane pane) { - // 存在所属组 - if (!children.contains(pane)) { - // 未添加组 - children.add(pane); - } - if (pane.getContent() instanceof VBox box) { - box.getChildren().add(toggleButton); - } - } else { - // 单独项 - if (!children.isEmpty()) { - if (children.getLast() instanceof TitledPane) { - // 上一项是组导航,添加上边框 - toggleButton.getStyleClass().add("after-group"); - } - } - children.add(toggleButton); - } - // 归组 - group.getToggles().add(toggleButton); - } - return; - } - if (c.wasRemoved()) { - // 移除 - List list = c.getRemoved(); - for (ToggleButton toggleButton : list) { - if (toggleButton.getProperties().get("OWNER") instanceof TitledPane pane) { - // 存在所属组 - if (pane.getContent() instanceof VBox box) { - box.getChildren().remove(toggleButton); - if (box.getChildren().isEmpty()) { - // 该组已没有列表项 - children.remove(box); - } - } - } else { - // 单独项 - children.remove(toggleButton); - } - // 从组移除 - group.getToggles().remove(toggleButton); - } - } - } - }); - } - - /** - * 添加导航按钮 - * - * @param buttons 导航按钮 - */ - public void add(ToggleButton... buttons) { - getItems().addAll(buttons); - } - - /** - * 添加默认没有展开的导航组 - * - * @param title 标题 - * @param buttons 导航项 - * @return 构造的折叠面板 - */ - public TitledPane addGroup(String title, ToggleButton... buttons) { - return addGroup(title, false, buttons); - } - - /** - * 添加默认展开的导航组 - * - * @param title 标题 - * @param buttons 导航项 - * @return 构造的折叠面板 - */ - public TitledPane addExpandedGroup(String title, ToggleButton... buttons) { - return addGroup(title, true, buttons); - } - - /** - * 添加导航组 - * - * @param title 标题 - * @param isExpanded true 为默认展开 - * @param buttons 导航项 - * @return 构造的折叠面板 - */ - public TitledPane addGroup(String title, boolean isExpanded, ToggleButton... buttons) { - TitledPane pane = new TitledPane(); - pane.setText(title); - return addGroup(pane, isExpanded, buttons); - } - - /** - * 添加导航组 - * - * @param pane 所属组 - * @param isExpanded true 为默认展开 - * @param buttons 导航项 - * @return 原折叠面板 - */ - public TitledPane addGroup(TitledPane pane, boolean isExpanded, ToggleButton... buttons) { - VBox content = new VBox(); - content.setPadding(Insets.EMPTY); - - pane.setContent(content); - pane.setExpanded(isExpanded); - pane.getStyleClass().add("group-pane"); - - for (ToggleButton button : buttons) { - button.getProperties().put("OWNER", pane); - items.add(button); - } - return pane; - } - - /** - * 获取该按钮所属组 - * - * @param btn 按钮 - * @return 所属组,null 时为不属于任何组 - */ - public TitledPane getGroup(ToggleButton btn) { - if (btn.getProperties().get("OWNER") instanceof TitledPane pane) { - return pane; - } - return null; - } - - /** - * 设置当前激活导航项 - * - * @param button 导航项 - */ - public void setSelectedItem(ToggleButton button) { - selectedItem.set(button); - } - - /** - * 获取当前激活的导航项 - * - * @return 当前激活的导航项 - */ - public ToggleButton getSelectedItem() { - return selectedItem.get(); - } - - /** - * 获取激活导航项监听 - * - * @return 激活导航项监听 - */ - public ObjectProperty selectedItem() { - return selectedItem; - } -} diff --git a/src/main/java/com/imyeyu/fx/ui/components/navigation/Navigation.java b/src/main/java/com/imyeyu/fx/ui/components/navigation/Navigation.java new file mode 100644 index 0000000..6a5d662 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/navigation/Navigation.java @@ -0,0 +1,570 @@ +package com.imyeyu.fx.ui.components.navigation; + +import com.imyeyu.fx.TimiFX; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.fx.utils.SmoothScroll; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TitledPane; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import lombok.Getter; + +import java.util.List; + +/** + * 纵向导航组件,可实现多级折叠导航 + * + * @author 夜雨 + * @since 2026-06-16 + */ +public class Navigation extends ScrollPane implements TimiFXUI { + + /** 默认缩进像素 */ + static final double DEFAULT_INDENT_SIZE = 16; + + /** 导航项原始标题属性 */ + private static final String RAW_ITEM_TEXT = "RAW_ITEM_TEXT"; + + /** 导航项原始图标属性 */ + private static final String RAW_ITEM_GRAPHIC = "RAW_ITEM_GRAPHIC"; + + /** 导航项图标同步锁属性 */ + private static final String GRAPHIC_SYNCING = "GRAPHIC_SYNCING"; + + /** 导航项是否已初始化 */ + private static final String INITIALIZED = "INITIALIZED"; + + /** 导航数据列表 */ + @Getter + protected final ObservableList items; + + /** 已选中监听 */ + protected final ObjectProperty selectedItem; + + /** 缩进像素 */ + private final DoubleProperty indentSize; + + /** 根容器 */ + private final VBox contentBox; + + /** 选择组 */ + private final ToggleGroup toggleGroup; + + /** + * 默认构造器 + */ + public Navigation() { + items = FXCollections.observableArrayList(); + selectedItem = new SimpleObjectProperty<>(); + indentSize = new SimpleDoubleProperty(DEFAULT_INDENT_SIZE); + contentBox = new VBox(); + toggleGroup = new ToggleGroup(); + + initView(); + initSelection(); + initPropertyListener(); + initItemsListener(); + } + + /** + * 初始化视图 + */ + private void initView() { + contentBox.setBorder(Stroke.BOTTOM); + + getStyleClass().addAll("navigation", "sp-border"); + setMaxWidth(Double.MAX_VALUE); + setVbarPolicy(ScrollBarPolicy.NEVER); + setFitToWidth(true); + setContent(contentBox); + + SmoothScroll.scrollPaneV(this); + } + + /** + * 初始化选中同步 + */ + private void initSelection() { + selectedItem.addListener((obs, o, newSelectedItem) -> { + if (newSelectedItem == null) { + toggleGroup.selectToggle(null); + return; + } + if (toggleGroup.getToggles().contains(newSelectedItem)) { + toggleGroup.selectToggle(newSelectedItem); + } + }); + toggleGroup.selectedToggleProperty().addListener((obs, o, toggle) -> { + if (toggle instanceof ToggleButton button) { + selectedItem.set(button); + return; + } + selectedItem.set(null); + }); + } + + /** + * 初始化属性监听 + */ + private void initPropertyListener() { + indentSize.addListener((obs, o, n) -> rebuild()); + } + + /** + * 初始化导航数据监听 + */ + private void initItemsListener() { + items.addListener(this::handleItemsChanged); + } + + /** + * 处理导航数据变更 + * + * @param change 变更内容 + */ + private void handleItemsChanged(ListChangeListener.Change change) { + while (change.next()) { + if (change.wasAdded()) { + for (Node node : change.getAddedSubList()) { + bindGroup(node); + } + } + if (change.wasRemoved()) { + for (Node node : change.getRemoved()) { + unbindGroup(node); + } + } + } + rebuild(); + } + + /** + * 绑定导航组刷新回调 + * + * @param node 子节点 + */ + private void bindGroup(Node node) { + if (node instanceof NavigationGroup group) { + group.setStructureChangedAction(this::rebuild); + } + } + + /** + * 解绑导航组刷新回调 + * + * @param node 子节点 + */ + private void unbindGroup(Node node) { + if (node instanceof NavigationGroup group) { + group.setStructureChangedAction(null); + } + } + + /** + * 重建导航结构 + */ + private void rebuild() { + contentBox.getChildren().clear(); + toggleGroup.getToggles().clear(); + + for (Node node : items) { + Node attachedNode = buildNode(node, 0); + if (attachedNode != null) { + contentBox.getChildren().add(attachedNode); + } + } + + refreshStandaloneItemStyle(); + if (selectedItem.get() != null && !toggleGroup.getToggles().contains(selectedItem.get())) { + selectedItem.set(null); + } + if (selectedItem.get() != null) { + toggleGroup.selectToggle(selectedItem.get()); + } + } + + /** + * 构建节点 + * + * @param node 节点 + * @param level 层级 + * @return 可挂载节点 + */ + private Node buildNode(Node node, int level) { + if (node instanceof NavigationGroup group) { + return buildGroup(group, level); + } + if (node instanceof ToggleButton button) { + return buildItem(button, level); + } + return null; + } + + /** + * 构建导航组 + * + * @param group 导航组 + * @param level 层级 + * @return 导航组节点 + */ + private NavigationGroup buildGroup(NavigationGroup group, int level) { + int currentLevel = level + group.getLevelOffset(); + group.applyLevel(currentLevel, getIndentSize()); + if (!group.getStyleClass().contains("group-pane")) { + group.getStyleClass().add("group-pane"); + } + + VBox groupContent = group.getContentBox(); + groupContent.getChildren().clear(); + for (Node child : group.getItems()) { + Node childNode = buildNode(child, currentLevel + 1); + if (childNode != null) { + groupContent.getChildren().add(childNode); + } + } + return group; + } + + /** + * 构建导航项 + * + * @param button 导航项 + * @param level 层级 + * @return 导航项节点 + */ + private ToggleButton buildItem(ToggleButton button, int level) { + configureButton(button, level); + toggleGroup.getToggles().add(button); + return button; + } + + /** + * 刷新独立导航项样式 + */ + private void refreshStandaloneItemStyle() { + List children = contentBox.getChildren(); + for (int i = 0; i < children.size(); i++) { + Node node = children.get(i); + if (node instanceof ToggleButton button) { + button.getStyleClass().remove("after-group"); + if (0 < i && children.get(i - 1) instanceof TitledPane) { + button.getStyleClass().add("after-group"); + } + } + } + } + + /** + * 配置导航项 + * + * @param button 导航项 + * @param level 层级 + */ + private void configureButton(ToggleButton button, int level) { + cacheItemState(button); + button.setText(resolveItemText(button)); + button.setMaxWidth(Double.MAX_VALUE); + button.setAlignment(Pos.CENTER_LEFT); + applyButtonIndent(button, Math.max(0, level) * getIndentSize()); + button.getStyleClass().setAll(CSS.MINECRAFT, "navigation-button"); + + if (!Boolean.TRUE.equals(button.getProperties().get(INITIALIZED))) { + button.addEventFilter(MouseEvent.MOUSE_PRESSED, TimiFX.EVENT_CONSUME_TG_BTN); + button.graphicProperty().addListener((obs, o, n) -> { + if (!Boolean.TRUE.equals(button.getProperties().get(GRAPHIC_SYNCING))) { + button.getProperties().put(RAW_ITEM_GRAPHIC, n); + } + }); + button.getProperties().put(INITIALIZED, true); + } + } + + /** + * 缓存导航项原始状态 + * + * @param button 导航项 + */ + private void cacheItemState(ToggleButton button) { + if (!(button.getProperties().get(RAW_ITEM_TEXT) instanceof String)) { + button.getProperties().put(RAW_ITEM_TEXT, button.getText()); + } + if (!button.getProperties().containsKey(RAW_ITEM_GRAPHIC)) { + button.getProperties().put(RAW_ITEM_GRAPHIC, button.getGraphic()); + } + } + + /** + * 获取导航项原始标题 + * + * @param button 导航项 + * @return 原始标题 + */ + private String resolveItemText(ToggleButton button) { + cacheItemState(button); + return (String) button.getProperties().get(RAW_ITEM_TEXT); + } + + /** + * 应用导航项缩进 + * + * @param button 导航项 + * @param indentWidth 缩进宽度 + */ + private void applyButtonIndent(ToggleButton button, double indentWidth) { + Node rawGraphic = (Node) button.getProperties().get(RAW_ITEM_GRAPHIC); + if (0 == indentWidth) { + button.getProperties().put(GRAPHIC_SYNCING, true); + button.setGraphic(rawGraphic); + button.getProperties().put(GRAPHIC_SYNCING, false); + return; + } + + Region spacer = new Region(); + spacer.setMinWidth(indentWidth); + spacer.setPrefWidth(indentWidth); + spacer.setMaxWidth(indentWidth); + + HBox graphicBox = new HBox(); + graphicBox.getChildren().add(spacer); + if (rawGraphic != null) { + graphicBox.getChildren().add(rawGraphic); + } + + button.getProperties().put(GRAPHIC_SYNCING, true); + button.setGraphic(graphicBox); + button.getProperties().put(GRAPHIC_SYNCING, false); + } + + /** + * 添加导航按钮 + * + * @param buttons 导航按钮 + */ + public void add(ToggleButton... buttons) { + getItems().addAll(buttons); + } + + /** + * 添加导航组 + * + * @param groups 导航组 + */ + public void add(NavigationGroup... groups) { + getItems().addAll(groups); + } + + /** + * 添加导航节点 + * + * @param nodes 导航节点 + */ + public void add(Node... nodes) { + getItems().addAll(nodes); + } + + /** + * 添加默认没有展开的导航组 + * + * @param title 标题 + * @param buttons 导航项 + * @return 构造的导航组 + */ + public NavigationGroup addGroup(String title, ToggleButton... buttons) { + return addGroup(title, 0, false, buttons); + } + + /** + * 添加默认展开的导航组 + * + * @param title 标题 + * @param buttons 导航项 + * @return 构造的导航组 + */ + public NavigationGroup addExpandedGroup(String title, ToggleButton... buttons) { + return addGroup(title, 0, true, buttons); + } + + /** + * 添加默认没有展开的导航组 + * + * @param title 标题 + * @param level 缩进偏移量 + * @param buttons 导航项 + * @return 构造的导航组 + */ + public NavigationGroup addGroup(String title, int level, ToggleButton... buttons) { + return addGroup(title, level, false, buttons); + } + + /** + * 添加默认展开的导航组 + * + * @param title 标题 + * @param level 缩进偏移量 + * @param buttons 导航项 + * @return 构造的导航组 + */ + public NavigationGroup addExpandedGroup(String title, int level, ToggleButton... buttons) { + return addGroup(title, level, true, buttons); + } + + /** + * 添加导航组 + * + * @param title 标题 + * @param level 缩进偏移量 + * @param isExpanded true 为默认展开 + * @param buttons 导航项 + * @return 构造的导航组 + */ + public NavigationGroup addGroup(String title, int level, boolean isExpanded, ToggleButton... buttons) { + NavigationGroup group = new NavigationGroup(title).levelOffset(level).expanded(isExpanded); + group.add(buttons); + add(group); + return group; + } + + /** + * 添加导航组 + * + * @param group 导航组 + * @return 原导航组 + */ + public NavigationGroup addGroup(NavigationGroup group) { + add(group); + return group; + } + + /** + * 添加导航组 + * + * @param group 导航组 + * @param isExpanded true 为默认展开 + * @param buttons 导航项 + * @return 原导航组 + */ + public NavigationGroup addGroup(NavigationGroup group, boolean isExpanded, ToggleButton... buttons) { + return addGroup(group, group.getLevelOffset(), isExpanded, buttons); + } + + /** + * 添加导航组 + * + * @param group 导航组 + * @param level 缩进偏移量 + * @param isExpanded true 为默认展开 + * @param buttons 导航项 + * @return 原导航组 + */ + public NavigationGroup addGroup(NavigationGroup group, int level, boolean isExpanded, ToggleButton... buttons) { + group.levelOffset(level).expanded(isExpanded).add(buttons); + add(group); + return group; + } + + /** + * 获取该按钮所属组 + * + * @param button 按钮 + * @return 所属组,null 时为不属于任何组 + */ + public NavigationGroup getGroup(ToggleButton button) { + for (Node node : items) { + NavigationGroup group = findGroup(node, button); + if (group != null) { + return group; + } + } + return null; + } + + /** + * 设置当前激活导航项 + * + * @param button 导航项 + */ + public void setSelectedItem(ToggleButton button) { + selectedItem.set(button); + } + + /** + * 获取当前激活的导航项 + * + * @return 当前激活的导航项 + */ + public ToggleButton getSelectedItem() { + return selectedItem.get(); + } + + /** + * 获取激活导航项监听 + * + * @return 激活导航项监听 + */ + public ObjectProperty selectedItem() { + return selectedItem; + } + + /** + * 设置缩进像素 + * + * @param indentSize 缩进像素 + */ + public void setIndentSize(double indentSize) { + this.indentSize.set(Math.max(0, indentSize)); + } + + /** + * 获取缩进像素 + * + * @return 缩进像素 + */ + public double getIndentSize() { + return indentSize.get(); + } + + /** + * 获取缩进像素属性 + * + * @return 缩进像素属性 + */ + public DoubleProperty indentSize() { + return indentSize; + } + + /** + * 查找按钮所属导航组 + * + * @param node 当前节点 + * @param button 目标按钮 + * @return 所属导航组 + */ + private NavigationGroup findGroup(Node node, ToggleButton button) { + if (!(node instanceof NavigationGroup group)) { + return null; + } + for (Node child : group.getItems()) { + if (child == button) { + return group; + } + NavigationGroup nestedGroup = findGroup(child, button); + if (nestedGroup != null) { + return nestedGroup; + } + } + return null; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/navigation/NavigationGroup.java b/src/main/java/com/imyeyu/fx/ui/components/navigation/NavigationGroup.java new file mode 100644 index 0000000..ad2d1d1 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/navigation/NavigationGroup.java @@ -0,0 +1,252 @@ +package com.imyeyu.fx.ui.components.navigation; + +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.TitledPane; +import javafx.scene.control.ToggleButton; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import lombok.Getter; + +/** + * 导航组 + * + * @author 夜雨 + * @since 2026-06-16 + */ +public class NavigationGroup extends TitledPane { + + /** 子节点列表 */ + @Getter + private final ObservableList items; + + /** 内容容器 */ + private final VBox contentBox; + + /** 结构变更回调 */ + private Runnable structureChangedAction; + + /** 原始标题 */ + private String rawTitle; + + /** 原始图标 */ + private Node rawGraphic; + + /** 标题同步锁 */ + private boolean textSyncing; + + /** 图标同步锁 */ + private boolean graphicSyncing; + + /** 标题缩进偏移量 */ + private int levelOffset; + + /** + * 默认构造器 + */ + public NavigationGroup() { + items = FXCollections.observableArrayList(); + contentBox = new VBox(); + structureChangedAction = () -> {}; + rawTitle = ""; + + contentBox.setPadding(Insets.EMPTY); + setContent(contentBox); + getStyleClass().add("group-pane"); + items.addListener(this::handleItemsChanged); + initRawStateListener(); + } + + /** + * 通过标题创建导航组 + * + * @param title 标题 + */ + public NavigationGroup(String title) { + this(); + setText(title); + } + + /** + * 添加导航项 + * + * @param buttons 导航项 + * @return 当前导航组 + */ + public NavigationGroup add(ToggleButton... buttons) { + items.addAll(buttons); + return this; + } + + /** + * 添加导航组 + * + * @param groups 导航组 + * @return 当前导航组 + */ + public NavigationGroup add(NavigationGroup... groups) { + items.addAll(groups); + return this; + } + + /** + * 添加子节点 + * + * @param nodes 子节点 + * @return 当前导航组 + */ + public NavigationGroup add(Node... nodes) { + items.addAll(nodes); + return this; + } + + /** + * 设置是否默认展开 + * + * @param expanded true 为默认展开 + * @return 当前导航组 + */ + public NavigationGroup expanded(boolean expanded) { + setExpanded(expanded); + return this; + } + + /** + * 设置标题缩进偏移量 + * + * @param levelOffset 偏移量 + * @return 当前导航组 + */ + public NavigationGroup levelOffset(int levelOffset) { + this.levelOffset = Math.max(0, levelOffset); + return this; + } + + /** + * 获取内容容器 + * + * @return 内容容器 + */ + VBox getContentBox() { + return contentBox; + } + + /** + * 获取标题缩进偏移量 + * + * @return 标题缩进偏移量 + */ + int getLevelOffset() { + return levelOffset; + } + + /** + * 设置结构变更回调 + * + * @param structureChangedAction 回调 + */ + void setStructureChangedAction(Runnable structureChangedAction) { + this.structureChangedAction = structureChangedAction == null ? () -> {} : structureChangedAction; + for (Node node : items) { + if (node instanceof NavigationGroup group) { + group.setStructureChangedAction(this::notifyStructureChanged); + } + } + } + + /** + * 应用显示层级 + * + * @param level 层级 + * @param indentSize 缩进像素 + */ + void applyLevel(int level, double indentSize) { + textSyncing = true; + super.setText(rawTitle); + textSyncing = false; + applyGraphicIndent(Math.max(0, level) * indentSize); + } + + /** + * 处理子节点变更 + * + * @param change 变更内容 + */ + private void handleItemsChanged(ListChangeListener.Change change) { + while (change.next()) { + if (change.wasAdded()) { + for (Node node : change.getAddedSubList()) { + if (node instanceof NavigationGroup group) { + group.setStructureChangedAction(this::notifyStructureChanged); + } + } + } + if (change.wasRemoved()) { + for (Node node : change.getRemoved()) { + if (node instanceof NavigationGroup group) { + group.setStructureChangedAction(null); + } + } + } + } + notifyStructureChanged(); + } + + /** + * 触发结构变更通知 + */ + private void notifyStructureChanged() { + structureChangedAction.run(); + } + + /** + * 初始化原始状态监听 + */ + private void initRawStateListener() { + rawTitle = getText() == null ? "" : getText(); + rawGraphic = getGraphic(); + + textProperty().addListener((obs, o, n) -> { + if (!textSyncing) { + rawTitle = n == null ? "" : n; + } + }); + graphicProperty().addListener((obs, o, n) -> { + if (!graphicSyncing) { + rawGraphic = n; + } + }); + } + + /** + * 应用标题缩进 + * + * @param indentWidth 缩进宽度 + */ + private void applyGraphicIndent(double indentWidth) { + if (0 == indentWidth) { + graphicSyncing = true; + super.setGraphic(rawGraphic); + graphicSyncing = false; + return; + } + + Region spacer = new Region(); + spacer.setMinWidth(indentWidth); + spacer.setPrefWidth(indentWidth); + spacer.setMaxWidth(indentWidth); + + HBox graphicBox = new HBox(); + graphicBox.getChildren().add(spacer); + if (rawGraphic != null) { + graphicBox.getChildren().add(rawGraphic); + } + graphicSyncing = true; + super.setGraphic(graphicBox); + graphicSyncing = false; + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/navigation/NavigationItem.java b/src/main/java/com/imyeyu/fx/ui/components/navigation/NavigationItem.java new file mode 100644 index 0000000..1888166 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/navigation/NavigationItem.java @@ -0,0 +1,28 @@ +package com.imyeyu.fx.ui.components.navigation; + +import javafx.scene.control.ToggleButton; + +/** + * 导航项 + * + * @author 夜雨 + * @since 2026-06-16 + */ +public class NavigationItem extends ToggleButton { + + /** + * 默认构造器 + */ + public NavigationItem() { + super(); + } + + /** + * 通过标题创建导航项 + * + * @param text 标题 + */ + public NavigationItem(String text) { + super(text); + } +} diff --git a/src/main/java/com/imyeyu/fx/ui/components/navigation/package-info.java b/src/main/java/com/imyeyu/fx/ui/components/navigation/package-info.java new file mode 100644 index 0000000..c75ce82 --- /dev/null +++ b/src/main/java/com/imyeyu/fx/ui/components/navigation/package-info.java @@ -0,0 +1,2 @@ +/** 导航组件 */ +package com.imyeyu.fx.ui.components.navigation; diff --git a/src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/NavigationDemo.java b/src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/NavigationDemo.java index 1550bf6..802ead1 100644 --- a/src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/NavigationDemo.java +++ b/src/test/java/com/imyeyu/fx/ui/examples/view/pages/component/NavigationDemo.java @@ -1,7 +1,9 @@ package com.imyeyu.fx.ui.examples.view.pages.component; import com.imyeyu.fx.ui.TimiFXUI; -import com.imyeyu.fx.ui.components.Navigation; +import com.imyeyu.fx.ui.components.navigation.Navigation; +import com.imyeyu.fx.ui.components.navigation.NavigationGroup; +import com.imyeyu.fx.ui.components.navigation.NavigationItem; import com.imyeyu.fx.ui.examples.component.AbstractDemoPane; import com.imyeyu.fx.ui.examples.component.sidebar.SidebarItem; import com.imyeyu.fx.utils.Column; @@ -10,7 +12,6 @@ import com.imyeyu.inject.annotation.Component; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Label; -import javafx.scene.control.ToggleButton; import javafx.scene.layout.GridPane; /** @@ -32,40 +33,55 @@ public class NavigationDemo extends AbstractDemoPane { Label labelCommonly = TimiFXUI.label(TimiFXUI.MULTILINGUAL.text("commonly")); labelCommonly.setAlignment(Pos.CENTER); Navigation commonly = new Navigation(); - commonly.add(new ToggleButton("item 0")); - commonly.add(new ToggleButton("item 1")); - commonly.add(new ToggleButton("item 2")); + commonly.setPrefWidth(240); + commonly.add(new NavigationItem("item 0")); + commonly.add(new NavigationItem("item 1")); + commonly.add(new NavigationItem("item 2")); // 二级 Label labelSecond = TimiFXUI.label(TimiFXUI.MULTILINGUAL.text("fx.example.navigation.second")); labelSecond.setAlignment(Pos.CENTER); Navigation second = new Navigation(); - second.add(new ToggleButton("item 0")); - second.add(new ToggleButton("item 1")); - second.add(new ToggleButton("item 2")); - second.addGroup(TimiFXUI.MULTILINGUAL.text("fx.example.navigation.second"), new ToggleButton("item 0"), new ToggleButton("item 1")); + second.add(new NavigationItem("item 0")); + second.add(new NavigationItem("item 1")); + second.add(new NavigationItem("item 2")); + second.add(new NavigationGroup(TimiFXUI.MULTILINGUAL.text("fx.example.navigation.second")) + .add(new NavigationItem("item 0"), new NavigationItem("item 1"))); // 默认展开 Label labelExpanded = TimiFXUI.label(TimiFXUI.MULTILINGUAL.text("fx.example.navigation.expanded")); labelExpanded.setAlignment(Pos.CENTER); Navigation expanded = new Navigation(); - expanded.add(new ToggleButton("item 0")); - expanded.add(new ToggleButton("item 1")); - expanded.add(new ToggleButton("item 2")); - expanded.addExpandedGroup(TimiFXUI.MULTILINGUAL.text("fx.example.navigation.second"), new ToggleButton("item 0"), new ToggleButton("item 1")); + expanded.add(new NavigationItem("item 0")); + expanded.add(new NavigationItem("item 1")); + expanded.add(new NavigationItem("item 2")); + expanded.add(new NavigationGroup(TimiFXUI.MULTILINGUAL.text("fx.example.navigation.second")) + .expanded(true) + .add(new NavigationItem("item 0"), new NavigationItem("item 1"))); - // 多个二级 + // 三级 Label labelMulti = TimiFXUI.label(TimiFXUI.MULTILINGUAL.text("fx.example.navigation.multi")); labelMulti.setAlignment(Pos.CENTER); Navigation multi = new Navigation(); - multi.add(new ToggleButton("item 0")); - multi.add(new ToggleButton("item 1")); - multi.add(new ToggleButton("item 2")); - multi.addExpandedGroup(TimiFXUI.MULTILINGUAL.text("fx.example.navigation.second"), new ToggleButton("item 0"), new ToggleButton("item 1")); - multi.addExpandedGroup(TimiFXUI.MULTILINGUAL.text("fx.example.navigation.second"), new ToggleButton("item 0"), new ToggleButton("item 1")); + multi.add(new NavigationItem("item 0")); + multi.add(new NavigationItem("item 1")); + multi.add(new NavigationItem("item 2")); + multi.add( + new NavigationGroup(TimiFXUI.MULTILINGUAL.text("fx.example.navigation.second")) + .expanded(true) + .add( + new NavigationItem("item 0"), + new NavigationGroup(TimiFXUI.MULTILINGUAL.text("fx.example.navigation.multi")) + .expanded(true) + .add(new NavigationItem("item 0"), new NavigationItem("item 1")) + ), + new NavigationGroup(TimiFXUI.MULTILINGUAL.text("fx.example.navigation.second")) + .expanded(true) + .add(new NavigationItem("item 0"), new NavigationItem("item 1")) + ); setCenter(new GridPane() {{ - Column col = Column.build().width(120); + Column col = Column.build().width(220); getColumnConstraints().addAll(col, col, col, col); getRowConstraints().addAll(Row.build(), Row.build().alwaysPriority()); setVgap(5);