修复异步控件测量与监听生命周期

This commit is contained in:
张家训
2026-08-01 15:54:53 +08:00
parent edba13531d
commit ce211c82a4
4 changed files with 197 additions and 24 deletions
@@ -2,9 +2,13 @@ package com.example.workutils.system.ui.async;
import javafx.beans.value.ChangeListener;
import javafx.css.PseudoClass;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.layout.Region;
import javafx.scene.text.Text;
import java.util.Objects;
@@ -12,6 +16,7 @@ public final class LoadingButtonBinder {
private static final double SPINNER_SIZE = 14;
private static final PseudoClass RUNNING = PseudoClass.getPseudoClass("running");
private static final Object BINDING_KEY = new Object();
private LoadingButtonBinder() {
}
@@ -20,18 +25,13 @@ public final class LoadingButtonBinder {
String idleText) {
Objects.requireNonNull(button, "button");
Objects.requireNonNull(controller, "controller");
Node idleGraphic = button.getGraphic();
String idleAccessibleText = button.getAccessibleText();
preserveIdleWidth(button);
ProgressIndicator spinner = createSpinner();
ChangeListener<OperationSnapshot> listener =
(observable, oldValue, snapshot) ->
apply(button, idleGraphic, idleAccessibleText, spinner,
snapshot, idleText);
controller.snapshotProperty().addListener(listener);
apply(button, idleGraphic, idleAccessibleText, spinner,
controller.snapshot(), idleText);
Objects.requireNonNull(idleText, "idleText");
ButtonBinding binding = (ButtonBinding) button.getProperties().get(BINDING_KEY);
if (binding == null) {
binding = new ButtonBinding(button, idleText);
button.getProperties().put(BINDING_KEY, binding);
}
binding.bind(controller);
}
public static String textFor(OperationSnapshot snapshot, String idleText) {
@@ -45,31 +45,52 @@ public final class LoadingButtonBinder {
};
}
private static void apply(Button button, Node idleGraphic, String idleAccessibleText,
ProgressIndicator spinner, OperationSnapshot snapshot,
String idleText) {
private static void apply(ButtonBinding binding, OperationSnapshot snapshot) {
Button button = binding.button;
boolean busy = snapshot.state() == OperationState.RUNNING
|| snapshot.state() == OperationState.CANCELLING;
String visibleText = textFor(snapshot, idleText);
String visibleText = textFor(snapshot, binding.idleText);
button.setText(visibleText);
button.setAccessibleText(busy ? visibleText : idleAccessibleText);
button.setAccessibleText(busy ? visibleText : binding.idleAccessibleText);
button.setDisable(busy);
button.setGraphic(busy ? spinner : idleGraphic);
button.setGraphic(busy ? binding.spinner : binding.idleGraphic);
button.pseudoClassStateChanged(RUNNING, busy);
}
private static void preserveIdleWidth(Button button) {
button.applyCss();
double idleWidth = button.prefWidth(-1);
private static void preserveIdleWidth(ButtonBinding binding) {
Button button = binding.button;
Text text = new Text(binding.idleText);
text.setFont(button.getFont());
double idleWidth = text.getLayoutBounds().getWidth();
if (binding.idleGraphic != null) {
double graphicWidth = preferredWidth(binding.idleGraphic);
idleWidth += graphicWidth;
if (!binding.idleText.isEmpty() && graphicWidth > 0) {
idleWidth += button.getGraphicTextGap();
}
}
Insets padding = button.getPadding();
idleWidth += padding.getLeft() + padding.getRight();
double configuredMinWidth = button.getMinWidth();
if (configuredMinWidth >= 0) {
idleWidth = Math.max(idleWidth, configuredMinWidth);
}
if (Double.isFinite(idleWidth) && idleWidth > 0) {
button.setMinWidth(idleWidth);
button.setMinWidth(Math.ceil(idleWidth));
}
}
private static double preferredWidth(Node node) {
if (node instanceof Region region) {
double prefWidth = region.prefWidth(-1);
if (Double.isFinite(prefWidth) && prefWidth > 0) {
return prefWidth;
}
}
Bounds bounds = node.getLayoutBounds();
return Math.max(0, bounds.getWidth());
}
private static String trimEllipsis(String message) {
String trimmed = message.trim();
while (trimmed.endsWith("…")) {
@@ -86,4 +107,44 @@ public final class LoadingButtonBinder {
spinner.setMaxSize(SPINNER_SIZE, SPINNER_SIZE);
return spinner;
}
private static final class ButtonBinding {
private final Button button;
private final String idleText;
private final Node idleGraphic;
private final String idleAccessibleText;
private final ProgressIndicator spinner = createSpinner();
private AsyncOperationController<?> controller;
private ChangeListener<OperationSnapshot> snapshotListener;
private ButtonBinding(Button button, String idleText) {
this.button = button;
this.idleText = idleText;
idleGraphic = button.getGraphic();
idleAccessibleText = button.getAccessibleText();
button.sceneProperty().addListener(
(observable, oldScene, newScene) -> recalibrate(newScene != null));
button.skinProperty().addListener(
(observable, oldSkin, newSkin) -> recalibrate(newSkin != null));
recalibrate(button.getScene() != null);
}
private void bind(AsyncOperationController<?> newController) {
if (controller != null && snapshotListener != null) {
controller.snapshotProperty().removeListener(snapshotListener);
}
controller = newController;
snapshotListener = (observable, oldValue, snapshot) -> apply(this, snapshot);
controller.snapshotProperty().addListener(snapshotListener);
apply(this, controller.snapshot());
}
private void recalibrate(boolean applyCss) {
if (applyCss) {
button.applyCss();
}
preserveIdleWidth(this);
}
}
}
@@ -1,6 +1,7 @@
package com.example.workutils.system.ui.async;
import com.example.workutils.system.ui.Theme;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
@@ -16,6 +17,9 @@ public final class OperationStatusPane extends HBox {
private final Label messageLabel = new Label("就绪");
private final Label percentageLabel = new Label();
private final Button cancelButton;
private final AsyncOperationController<?> controller;
private final ChangeListener<OperationSnapshot> snapshotListener;
private boolean disposed;
/** @deprecated Use {@link #OperationStatusPane(AsyncOperationController, boolean)}. */
@Deprecated
@@ -33,6 +37,7 @@ public final class OperationStatusPane extends HBox {
public OperationStatusPane(AsyncOperationController<?> controller, boolean cancellable) {
super(Theme.S2);
Objects.requireNonNull(controller, "controller");
this.controller = controller;
setAlignment(Pos.CENTER_LEFT);
getStyleClass().add("operation-status");
@@ -56,11 +61,26 @@ public final class OperationStatusPane extends HBox {
} else {
cancelButton = null;
}
controller.snapshotProperty().addListener(
(observable, oldValue, value) -> render(value));
snapshotListener = (observable, oldValue, value) -> render(value);
controller.snapshotProperty().addListener(snapshotListener);
render(controller.snapshot());
}
/**
* Releases controller listeners when this pane is no longer used.
* Calling this method more than once is safe.
*/
public void dispose() {
if (disposed) {
return;
}
disposed = true;
controller.snapshotProperty().removeListener(snapshotListener);
if (cancelButton != null) {
cancelButton.setOnAction(null);
}
}
private void render(OperationSnapshot snapshot) {
progressBar.setProgress(snapshot.progress());
boolean active = snapshot.state() == OperationState.RUNNING