From 1c4c6a5232f9d0fa23ddc9e208470bac276d6699 Mon Sep 17 00:00:00 2001 From: mkpaz Date: Mon, 3 Oct 2022 16:32:03 +0400 Subject: [PATCH] Add RingProgressIndicator control --- .../base/controls/RingProgressIndicator.java | 85 ++++++ .../controls/RingProgressIndicatorSkin.java | 261 ++++++++++++++++++ .../sampler/page/components/ProgressPage.java | 121 ++++++-- .../resources/assets/styles/scss/index.scss | 2 +- styles/src/components/_progress.scss | 40 +++ 5 files changed, 478 insertions(+), 31 deletions(-) create mode 100755 base/src/main/java/atlantafx/base/controls/RingProgressIndicator.java create mode 100755 base/src/main/java/atlantafx/base/controls/RingProgressIndicatorSkin.java diff --git a/base/src/main/java/atlantafx/base/controls/RingProgressIndicator.java b/base/src/main/java/atlantafx/base/controls/RingProgressIndicator.java new file mode 100755 index 0000000..da552b3 --- /dev/null +++ b/base/src/main/java/atlantafx/base/controls/RingProgressIndicator.java @@ -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 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 graphicProperty() { + return graphic; + } + + // ~ + + protected final ObjectProperty> stringConverter = new SimpleObjectProperty<>(this, "converter", null); + + public StringConverter getStringConverter() { + return stringConverterProperty().get(); + } + + public void setStringConverter(StringConverter stringConverter) { + this.stringConverterProperty().set(stringConverter); + } + + /** Optional converter to transform progress value to string. */ + public ObjectProperty> 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(); + } +} diff --git a/base/src/main/java/atlantafx/base/controls/RingProgressIndicatorSkin.java b/base/src/main/java/atlantafx/base/controls/RingProgressIndicatorSkin.java new file mode 100755 index 0000000..60795b9 --- /dev/null +++ b/base/src/main/java/atlantafx/base/controls/RingProgressIndicatorSkin.java @@ -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 { + + 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> 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 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 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 getStyleableProperty(RingProgressIndicator n) { + final RingProgressIndicatorSkin skin = (RingProgressIndicatorSkin) n.getSkin(); + return (StyleableProperty) (WritableValue) skin.indeterminateAnimationTimeProperty(); + } + }; + + private static final List> STYLEABLES; + + static { + final List> styleables = new ArrayList<>(SkinBase.getClassCssMetaData()); + styleables.add(INDETERMINATE_ANIMATION_TIME); + STYLEABLES = Collections.unmodifiableList(styleables); + } + } + + public static List> getClassCssMetaData() { + return RingProgressIndicatorSkin.StyleableProperties.STYLEABLES; + } +} diff --git a/sampler/src/main/java/atlantafx/sampler/page/components/ProgressPage.java b/sampler/src/main/java/atlantafx/sampler/page/components/ProgressPage.java index 8ce7c81..8064a7c 100644 --- a/sampler/src/main/java/atlantafx/sampler/page/components/ProgressPage.java +++ b/sampler/src/main/java/atlantafx/sampler/page/components/ProgressPage.java @@ -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() { + @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() { diff --git a/sampler/src/main/resources/assets/styles/scss/index.scss b/sampler/src/main/resources/assets/styles/scss/index.scss index 7f48be1..fa3218c 100644 --- a/sampler/src/main/resources/assets/styles/scss/index.scss +++ b/sampler/src/main/resources/assets/styles/scss/index.scss @@ -2,4 +2,4 @@ @use "general"; @use "layout"; -@use "widgets"; \ No newline at end of file +@use "widgets"; diff --git a/styles/src/components/_progress.scss b/styles/src/components/_progress.scss index 6f1e88e..291a1cd 100755 --- a/styles/src/components/_progress.scss +++ b/styles/src/components/_progress.scss @@ -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, @@ -162,4 +165,41 @@ $size: ( -fx-shape: "M45.0 26.0 a3.5,3.5 0 1,1 0,1 Z"; } } +} + +.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; + } + } + } } \ No newline at end of file