package com.imyeyu.fx.ui.components; import com.imyeyu.fx.bean.Interpolates; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.DoubleProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.collections.ListChangeListener; import javafx.geometry.Bounds; import javafx.scene.control.SkinBase; import javafx.scene.control.ToggleButton; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.util.Duration; /** * 单选按钮组皮肤 * * @author 夜雨 * @since 2026-06-26 */ public class RadioButtonGroupSkin extends SkinBase { /** 高度补偿 */ private static final double HEIGHT_COMPENSATE = 2; /** 动画时长 */ private static final Duration SLIDER_DURATION = Duration.millis(180); /** 按钮容器 */ private final HBox box; /** 滑块 */ private final Region slider; /** 滑块 x */ private final DoubleProperty sliderX; /** 滑块 y */ private final DoubleProperty sliderY; /** 滑块宽度 */ private final DoubleProperty sliderWidth; /** 滑块高度 */ private final DoubleProperty sliderHeight; /** 滑块动画 */ private Timeline sliderTimeline; /** * 构造按钮组皮肤 * * @param control 按钮组控件 */ public RadioButtonGroupSkin(RadioButtonGroup control) { super(control); sliderX = new SimpleDoubleProperty(); sliderY = new SimpleDoubleProperty(); sliderWidth = new SimpleDoubleProperty(); sliderHeight = new SimpleDoubleProperty(); sliderTimeline = new Timeline(); box = new HBox(); box.getStyleClass().add(RadioButtonGroup.STYLE_CLASS_BOX); slider = new Region(); slider.getStyleClass().add(RadioButtonGroup.STYLE_CLASS_SLIDER); slider.setManaged(false); slider.setMouseTransparent(true); sliderX.addListener((obs, o, n) -> applySliderBounds()); sliderY.addListener((obs, o, n) -> applySliderBounds()); sliderWidth.addListener((obs, o, n) -> applySliderBounds()); sliderHeight.addListener((obs, o, n) -> applySliderBounds()); box.alignmentProperty().bind(control.alignmentProperty()); box.spacingProperty().bind(Bindings.createDoubleBinding( () -> control.getMode() == RadioButtonGroup.Mode.BUTTON && control.getSpacing() < 1 ? -1 : control.getSpacing(), control.modeProperty(), control.spacingProperty() )); getChildren().add(new StackPane() {{ getStyleClass().add(RadioButtonGroup.STYLE_CLASS_BOX); getChildren().addAll(slider, box); }}); rebuildButtons(); initListeners(control); updateMode(); } /** * 初始化监听 * * @param control 控件 */ private void initListeners(RadioButtonGroup control) { control.getButtons().addListener((ListChangeListener) change -> rebuildButtons()); control.selectedButtonProperty().addListener((obs, o, n) -> syncSlider()); control.modeProperty().addListener((obs, o, n) -> { updateMode(); syncSlider(); }); box.layoutBoundsProperty().addListener((obs, o, n) -> { syncSlider(); }); } /** * 重建按钮结构 */ private void rebuildButtons() { box.getChildren().setAll(getSkinnable().getButtons()); syncButtonStyleClass(); syncSlider(); } /** * 同步按钮样式 */ private void syncButtonStyleClass() { int size = getSkinnable().getButtons().size(); for (int i = 0; i < size; i++) { ToggleButton button = getSkinnable().getButtons().get(i); button.getStyleClass().removeAll( RadioButtonGroup.STYLE_CLASS_BUTTON, RadioButtonGroup.STYLE_CLASS_FIRST, RadioButtonGroup.STYLE_CLASS_MIDDLE, RadioButtonGroup.STYLE_CLASS_LAST, RadioButtonGroup.STYLE_CLASS_SINGLE ); button.getStyleClass().add(RadioButtonGroup.STYLE_CLASS_BUTTON); button.selectedProperty().addListener((obs, o, n) -> button.setViewOrder(n ? -1 : 0)); button.setViewOrder(button.isSelected() ? -1 : 0); button.layoutBoundsProperty().addListener((obs, o, n) -> syncSlider()); button.boundsInParentProperty().addListener((obs, o, n) -> syncSlider()); if (size == 1) { button.getStyleClass().add(RadioButtonGroup.STYLE_CLASS_SINGLE); continue; } if (i == 0) { button.getStyleClass().add(RadioButtonGroup.STYLE_CLASS_FIRST); continue; } if (i == size - 1) { button.getStyleClass().add(RadioButtonGroup.STYLE_CLASS_LAST); continue; } button.getStyleClass().add(RadioButtonGroup.STYLE_CLASS_MIDDLE); } } /** * 刷新按钮尺寸策略 */ private void refreshButtonSizing() { boolean sliderMode = getSkinnable().getMode() == RadioButtonGroup.Mode.SLIDER; for (ToggleButton button : getSkinnable().getButtons()) { button.setMinSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); button.setPrefHeight(Region.USE_COMPUTED_SIZE); if (sliderMode) { HBox.setHgrow(button, Priority.NEVER); button.setMaxWidth(Region.USE_PREF_SIZE); } else { HBox.setHgrow(button, Priority.ALWAYS); button.setMaxWidth(Double.MAX_VALUE); } } } /** * 应用滑块位置和尺寸 */ private void applySliderBounds() { slider.resizeRelocate(sliderX.get(), sliderY.get(), sliderWidth.get(), sliderHeight.get()); } /** * 更新模式 */ private void updateMode() { boolean sliderMode = getSkinnable().getMode() == RadioButtonGroup.Mode.SLIDER; slider.setVisible(sliderMode); } /** * 同步滑块位置 */ private void syncSlider() { if (getSkinnable().getMode() != RadioButtonGroup.Mode.SLIDER) { sliderTimeline.stop(); sliderX.set(0); sliderY.set(0); sliderWidth.set(0); sliderHeight.set(0); return; } ToggleButton button = getSkinnable().getSelectedButton(); if (button == null || !box.getChildren().contains(button)) { sliderTimeline.stop(); sliderX.set(0); sliderY.set(0); sliderWidth.set(0); sliderHeight.set(0); return; } Bounds bounds = button.getBoundsInParent(); double targetX = bounds.getMinX() + 2; double targetWidth = Math.max(0, bounds.getWidth() - 4); double targetHeight = Math.max(0, box.getHeight() - 4); double targetY = 2; if (sliderWidth.get() <= 0 || sliderHeight.get() <= 0) { sliderTimeline.stop(); sliderX.set(targetX); sliderY.set(targetY); sliderWidth.set(targetWidth); sliderHeight.set(targetHeight); return; } sliderTimeline.stop(); sliderTimeline = new Timeline( new KeyFrame( SLIDER_DURATION, new KeyValue(sliderX, targetX, Interpolates.EASE_OUT_EXPO.getValue()), new KeyValue(sliderY, targetY, Interpolates.EASE_OUT_EXPO.getValue()), new KeyValue(sliderWidth, targetWidth, Interpolates.EASE_OUT_EXPO.getValue()), new KeyValue(sliderHeight, targetHeight, Interpolates.EASE_OUT_EXPO.getValue()) ) ); sliderTimeline.play(); } }