Add RingProgressIndicator control

This commit is contained in:
mkpaz 2022-10-03 16:32:03 +04:00
parent 2716cca2a0
commit 1c4c6a5232
5 changed files with 478 additions and 31 deletions

@ -0,0 +1,85 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.controls;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Node;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.Skin;
import javafx.util.StringConverter;
public class RingProgressIndicator extends ProgressIndicator {
public RingProgressIndicator() { }
public RingProgressIndicator(double progress) {
this(progress, false);
}
public RingProgressIndicator(double progress, boolean reverse) {
super(progress);
this.reverse.set(reverse);
}
@Override
protected Skin<?> createDefaultSkin() {
return new RingProgressIndicatorSkin(this);
}
///////////////////////////////////////////////////////////////////////////
// Properties //
///////////////////////////////////////////////////////////////////////////
protected final ObjectProperty<Node> graphic = new SimpleObjectProperty<>(this, "graphic", null);
public Node getGraphic() {
return graphicProperty().get();
}
public void setGraphic(Node graphic) {
graphicProperty().set(graphic);
}
/**
* Any node to be displayed within the progress indicator. If null,
* it will fall back to the Label with integer progress value from 1 to 100.
*/
public ObjectProperty<Node> graphicProperty() {
return graphic;
}
// ~
protected final ObjectProperty<StringConverter<Double>> stringConverter = new SimpleObjectProperty<>(this, "converter", null);
public StringConverter<Double> getStringConverter() {
return stringConverterProperty().get();
}
public void setStringConverter(StringConverter<Double> stringConverter) {
this.stringConverterProperty().set(stringConverter);
}
/** Optional converter to transform progress value to string. */
public ObjectProperty<StringConverter<Double>> stringConverterProperty() {
return stringConverter;
}
// ~
private final ReadOnlyBooleanWrapper reverse = new ReadOnlyBooleanWrapper(this, "reverse", false);
public boolean isReverse() {
return reverse.get();
}
/**
* Reverse progress indicator scale. For indeterminate variant
* this means it will be rotated counterclockwise.
*/
public ReadOnlyBooleanProperty reverseProperty() {
return reverse.getReadOnlyProperty();
}
}

@ -0,0 +1,261 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.controls;
import javafx.animation.Animation;
import javafx.animation.Interpolator;
import javafx.animation.RotateTransition;
import javafx.beans.property.DoubleProperty;
import javafx.beans.value.WritableValue;
import javafx.css.CssMetaData;
import javafx.css.Styleable;
import javafx.css.StyleableDoubleProperty;
import javafx.css.StyleableProperty;
import javafx.css.converter.SizeConverter;
import javafx.scene.CacheHint;
import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Arc;
import javafx.scene.shape.Circle;
import javafx.util.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class RingProgressIndicatorSkin extends SkinBase<RingProgressIndicator> {
protected static final double DEFAULT_ANIMATION_TIME = 3;
protected final StackPane container = new StackPane();
protected final Circle trackCircle = new Circle();
protected final Arc progressArc = new Arc();
protected final Label progressLabel = new Label();
protected final RotateTransition transition = new RotateTransition(
Duration.seconds(DEFAULT_ANIMATION_TIME), progressArc
);
public RingProgressIndicatorSkin(RingProgressIndicator indicator) {
super(indicator);
trackCircle.getStyleClass().add("track");
trackCircle.setManaged(false);
trackCircle.setFill(Color.TRANSPARENT);
progressArc.getStyleClass().add("ring");
progressArc.setManaged(false);
progressArc.setStartAngle(90);
progressArc.setLength(calcProgressArcLength());
progressArc.setCache(true);
progressArc.setCacheHint(CacheHint.ROTATE);
progressArc.setFill(Color.TRANSPARENT);
transition.setAutoReverse(false);
transition.setByAngle(-getMaxAngle());
transition.setCycleCount(Animation.INDEFINITE);
transition.setDelay(Duration.ZERO);
transition.setInterpolator(Interpolator.LINEAR);
progressLabel.getStyleClass().add("progress");
container.getStyleClass().addAll("container");
container.setMaxHeight(Region.USE_PREF_SIZE);
container.setMaxWidth(Region.USE_PREF_SIZE);
container.getChildren().addAll(trackCircle, progressArc);
container.getChildren().add(indicator.getGraphic() != null ? indicator.getGraphic() : progressLabel);
indicator.getStyleClass().add("ring-progress-indicator");
indicator.setMaxHeight(Region.USE_PREF_SIZE);
indicator.setMaxWidth(Region.USE_PREF_SIZE);
getChildren().add(container);
// == INIT LISTENERS ==
updateProgressLabel();
toggleIndeterminate();
registerChangeListener(indicator.progressProperty(), e -> {
updateProgressLabel();
progressArc.setLength(calcProgressArcLength());
});
registerChangeListener(indicator.indeterminateProperty(), e -> toggleIndeterminate());
registerChangeListener(indicator.visibleProperty(), e -> {
if (indicator.isVisible() && indicator.isIndeterminate()) {
transition.play();
} else {
transition.pause();
}
});
registerChangeListener(indeterminateAnimationTimeProperty(), e -> {
transition.setDuration(Duration.seconds(getIndeterminateAnimationTime()));
if (indicator.isIndeterminate()) {
transition.playFromStart();
}
});
registerChangeListener(indicator.graphicProperty(), e -> {
if (indicator.getGraphic() != null) {
container.getChildren().remove(progressLabel);
container.getChildren().add(indicator.getGraphic());
} else {
if (container.getChildren().size() > 1) {
container.getChildren().remove(1);
container.getChildren().add(progressLabel);
updateProgressLabel();
}
}
});
}
private int getMaxAngle() {
return getSkinnable().isReverse() ? 360 : -360;
}
private double calcProgressArcLength() {
var progress = getSkinnable().getProgress();
return getSkinnable().isReverse() ? (1 - progress) * getMaxAngle() : progress * getMaxAngle();
}
protected void updateProgressLabel() {
var progress = getSkinnable().getProgress();
if (getSkinnable().getStringConverter() != null) {
progressLabel.setText(getSkinnable().getStringConverter().toString(progress));
return;
}
if (progress >= 0) {
progressLabel.setText((int) Math.ceil(progress * 100) + "%");
}
}
protected void toggleIndeterminate() {
var indeterminate = getSkinnable().isIndeterminate();
progressLabel.setManaged(!indeterminate);
if (indeterminate) {
if (getSkinnable().isVisible()) {
transition.play();
}
} else {
progressArc.setRotate(0);
transition.stop();
}
}
@Override
protected void layoutChildren(double x, double y, double w, double h) {
var size = Math.max(w, h);
var radius = (size / 2) - (progressArc.getStrokeWidth() / 2);
trackCircle.setCenterX(size / 2);
trackCircle.setCenterY(size / 2);
trackCircle.setRadius(radius);
progressArc.setCenterX(size / 2);
progressArc.setCenterY(size / 2);
progressArc.setRadiusX(radius);
progressArc.setRadiusY(radius);
container.resizeRelocate(x, y, size, size);
}
// Control height is always equal to its width.
@Override
protected double computeMinHeight(double width, double topInset, double rightInset,
double bottomInset, double leftInset) {
return super.computeMinWidth(0, topInset, rightInset, bottomInset, leftInset);
}
@Override
protected double computePrefHeight(double width, double topInset, double rightInset,
double bottomInset, double leftInset) {
return super.computePrefWidth(0, topInset, rightInset, bottomInset, leftInset);
}
@Override
protected double computeMaxHeight(double width, double topInset, double rightInset,
double bottomInset, double leftInset) {
return super.computeMaxWidth(0, topInset, rightInset, bottomInset, leftInset);
}
@Override
public void dispose() {
transition.stop();
}
@Override
public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
return getClassCssMetaData();
}
///////////////////////////////////////////////////////////////////////////
// Styleable Properties //
///////////////////////////////////////////////////////////////////////////
protected DoubleProperty indeterminateAnimationTime = null;
private DoubleProperty indeterminateAnimationTimeProperty() {
if (indeterminateAnimationTime == null) {
indeterminateAnimationTime = new StyleableDoubleProperty(DEFAULT_ANIMATION_TIME) {
@Override
public Object getBean() {
return RingProgressIndicatorSkin.this;
}
@Override
public String getName() {
return "indeterminateAnimationTime";
}
@Override
public CssMetaData<RingProgressIndicator, Number> getCssMetaData() {
return StyleableProperties.INDETERMINATE_ANIMATION_TIME;
}
};
}
return indeterminateAnimationTime;
}
public double getIndeterminateAnimationTime() {
return indeterminateAnimationTime == null ? DEFAULT_ANIMATION_TIME : indeterminateAnimationTime.get();
}
private static class StyleableProperties {
private static final CssMetaData<RingProgressIndicator, Number> INDETERMINATE_ANIMATION_TIME =
new CssMetaData<>("-fx-indeterminate-animation-time", SizeConverter.getInstance(), DEFAULT_ANIMATION_TIME) {
@Override
public boolean isSettable(RingProgressIndicator n) {
return n.getSkin() instanceof RingProgressIndicatorSkin s &&
(s.indeterminateAnimationTime == null || !s.indeterminateAnimationTime.isBound());
}
@Override
@SuppressWarnings("RedundantCast")
public StyleableProperty<Number> getStyleableProperty(RingProgressIndicator n) {
final RingProgressIndicatorSkin skin = (RingProgressIndicatorSkin) n.getSkin();
return (StyleableProperty<Number>) (WritableValue<Number>) skin.indeterminateAnimationTimeProperty();
}
};
private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
static {
final List<CssMetaData<? extends Styleable, ?>> styleables = new ArrayList<>(SkinBase.getClassCssMetaData());
styleables.add(INDETERMINATE_ANIMATION_TIME);
STYLEABLES = Collections.unmodifiableList(styleables);
}
}
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
return RingProgressIndicatorSkin.StyleableProperties.STYLEABLES;
}
}

@ -1,6 +1,7 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.page.components;
import atlantafx.base.controls.RingProgressIndicator;
import atlantafx.sampler.page.AbstractPage;
import atlantafx.sampler.page.Page;
import atlantafx.sampler.page.SampleBlock;
@ -12,11 +13,11 @@ import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.layout.*;
import javafx.scene.text.Text;
import javafx.util.StringConverter;
import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon;
import static atlantafx.base.theme.Styles.*;
import static atlantafx.sampler.page.SampleBlock.BLOCK_HGAP;
@ -33,50 +34,111 @@ public class ProgressPage extends AbstractPage {
public ProgressPage() {
super();
setUserContent(new VBox(Page.PAGE_VGAP,
expandingHBox(basicBarSample(), basicIndicatorSample()),
expandingHBox(barSizeSample(), colorChangeSample())
));
var grid = new GridPane();
grid.setHgap(Page.PAGE_HGAP);
grid.setVgap(Page.PAGE_VGAP);
grid.add(basicBarSample(), 0, 0);
grid.add(basicIndicatorSample(), 1, 0);
grid.add(ringIndicatorSample(), 0, 1);
grid.add(barSizeSample(), 1, 1);
grid.add(colorChangeSample(), 0, 2);
setUserContent(grid);
}
private SampleBlock basicBarSample() {
var flowPane = new FlowPane(
BLOCK_HGAP, BLOCK_VGAP,
createBar(0, false),
createBar(0.5, false),
createBar(1, false),
createBar(0.5, true)
);
var flowPane = new FlowPane(BLOCK_HGAP, BLOCK_VGAP, createBar(0, false), createBar(0.5, false), createBar(1, false), createBar(0.5, true));
flowPane.setAlignment(Pos.CENTER_LEFT);
return new SampleBlock("Progress Bar", flowPane);
}
private SampleBlock basicIndicatorSample() {
var flowPane = new FlowPane(
BLOCK_HGAP, BLOCK_VGAP,
createIndicator(0, false),
createIndicator(0.5, false),
createIndicator(1, false),
createIndicator(0.5, true)
);
var flowPane = new FlowPane(BLOCK_HGAP, BLOCK_VGAP, createIndicator(0, false), createIndicator(0.5, false), createIndicator(1, false), createIndicator(0.5, true));
flowPane.setAlignment(Pos.TOP_LEFT);
return new SampleBlock("Progress Indicator", flowPane);
}
private SampleBlock barSizeSample() {
var container = new VBox(
BLOCK_VGAP,
new HBox(20, createBar(0.5, false, SMALL), new Text("small")),
new HBox(20, createBar(0.5, false, MEDIUM), new Text("medium")),
new HBox(20, createBar(0.5, false, LARGE), new Text("large"))
);
var container = new VBox(BLOCK_VGAP, new HBox(20, createBar(0.5, false, SMALL), new Text("small")), new HBox(20, createBar(0.5, false, MEDIUM), new Text("medium")), new HBox(20, createBar(0.5, false, LARGE), new Text("large")));
container.getChildren().forEach(c -> ((HBox) c).setAlignment(Pos.CENTER_LEFT));
return new SampleBlock("Size", container);
}
private SampleBlock ringIndicatorSample() {
var basicIndicator = new RingProgressIndicator(0, false);
var customTextIndicator = new RingProgressIndicator(0.5, false);
customTextIndicator.setPrefSize(75, 75);
customTextIndicator.setStringConverter(new StringConverter<>() {
@Override
public String toString(Double progress) {
return (int) Math.ceil(progress * 100) + "°";
}
@Override
public Double fromString(String progress) {
return 0d;
}
});
var reverseIndicator = new RingProgressIndicator(0.25, true);
reverseIndicator.setPrefSize(150, 150);
var reverseIndicatorLabel = new Label("25%");
reverseIndicatorLabel.getStyleClass().add(TITLE_4);
var reversePlayButton = new Button("", new FontIcon(Feather.PLAY));
reversePlayButton.getStyleClass().addAll(BUTTON_CIRCLE, FLAT);
reversePlayButton.disableProperty().bind(reverseIndicator.progressProperty().greaterThan(0.25));
reversePlayButton.setOnAction(e1 -> {
var task = new Task<Void>() {
@Override
protected Void call() throws Exception {
int steps = 100;
for (int i = 25; i <= steps; i++) {
Thread.sleep(100);
updateProgress(i, steps);
updateMessage(i + "%");
}
return null;
}
};
// reset properties, so we can start a new task
task.setOnSucceeded(e2 -> {
reverseIndicator.progressProperty().unbind();
reverseIndicatorLabel.textProperty().unbind();
reverseIndicator.setProgress(0.25);
reverseIndicatorLabel.setText("25%");
});
reverseIndicator.progressProperty().bind(task.progressProperty());
reverseIndicatorLabel.textProperty().bind(task.messageProperty());
new Thread(task).start();
});
var reverseBox = new VBox(10, reverseIndicatorLabel, reversePlayButton);
reverseBox.setAlignment(Pos.CENTER);
reverseIndicator.setGraphic(reverseBox);
// ~
var box = new HBox(BLOCK_HGAP, basicIndicator, customTextIndicator, reverseIndicator);
box.setAlignment(Pos.CENTER_LEFT);
return new SampleBlock("Ring Indicator", box);
}
private SampleBlock colorChangeSample() {
var stateSuccess = PseudoClass.getPseudoClass("state-success");
var stateDanger = PseudoClass.getPseudoClass("state-danger");
@ -124,8 +186,7 @@ public class ProgressPage extends AbstractPage {
.example:state-danger .label {
-fx-text-fill: -color-fg-emphasis;
}
"""
).addTo(content);
""").addTo(content);
runBtn.setOnAction(e1 -> {
var task = new Task<Void>() {

@ -10,6 +10,9 @@
$color-bar-track: -color-bg-subtle !default;
$color-bar-fill: -color-accent-emphasis !default;
$color-ring-indicator-track: -color-bg-subtle !default;
$color-ring-indicator-fill: -color-accent-emphasis !default;
$size: (
"small": 2px,
"medium": 0.4em,
@ -163,3 +166,40 @@ $size: (
}
}
}
.ring-progress-indicator {
-fx-indeterminate-animation-time: 3;
-color-progress-indicator-track: $color-ring-indicator-track;
-color-progress-indicator-fill: $color-ring-indicator-fill;
>.container {
// for extra small/large indicators you should also change the stroke width
-fx-min-width: 4em;
>.track {
-fx-stroke: -color-progress-indicator-track;
-fx-stroke-width: 5px;
}
>.ring {
-fx-stroke: -color-progress-indicator-fill;
-fx-stroke-width: 5px;
}
}
&:indeterminate {
>.container {
// for extra small/large indicators you should also change the stroke width
-fx-min-width: 1.5em;
>.track {
visibility: hidden;
}
>.ring {
-fx-stroke: linear-gradient(-color-bg-default, -color-progress-indicator-fill);
-fx-stroke-width: 2px;
}
}
}
}