A few days ago I managed to create a custom button with JavaFX by creating a simple button and modifying it's style using the setStyle()
method with String
objects (whose values vary depending on if the button was clicked or not) as parameters.
I didn't know how to convert that customized button into a class
that I can import each time that I want to use it, so I've been researching and I found this project, which includes several JavaFX controllers customized with Material Design. The controller in which I'm interested right now is MaterialButton
, whose source code is the following:
import com.sun.javafx.scene.control.skin.ButtonSkin;
import javafx.animation.Animation;
import javafx.animation.FadeTransition;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.ParallelTransition;
import javafx.animation.SequentialTransition;
import javafx.animation.Timeline;
import javafx.animation.Transition;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.scene.control.Button;
import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.effect.BlurType;
import javafx.scene.effect.DropShadow;
import javafx.scene.input.MouseEvent;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.util.Duration;
@SuppressWarnings("restriction")
public class CustomButton extends Button {
private static final Duration RIPPLE_DURATION = Duration.millis(250); // Duration of the ripple effect
private static final Duration SHADOW_DURATION = Duration.millis(350); // Duration of the shadow effect
private static final Color RIPPLE_COLOR = Color.web("#FFF", 0.3); // Ripple color
public CustomButton() { // Except from the setPrefHeifht() method, everything between this braces seems useless.
// Probably I'm wrong, but why would you want to do this?
textProperty().addListener((ObservableValue<? extends String> observable, String oldValue, String newValue) -> {
if (!oldValue.endsWith(newValue.toUpperCase())) {
textProperty().set(newValue.toUpperCase());
}
});
setPrefHeight(36); // Height of the button
}
@Override
public Skin<?> createDefaultSkin() { // Overrides the default skin of the button.
ButtonSkin buttonSkin = (ButtonSkin) getSkin();
if (buttonSkin == null) {
buttonSkin = new ButtonSkin(this);
Circle circleRipple = new Circle(0.1, RIPPLE_COLOR); // Creates the circle that must appear when the
// ripple effect animation is displayed.
buttonSkin.getChildren().add(0, circleRipple); // Adds the nodes to the screen.
setSkin(buttonSkin);
createRippleEffect(circleRipple); // Creates the ripple effect animation.
getStyleClass().add("ripple-button"); // I don't know what this line does, but if it is
// removed, the button is narrowed.
}
return buttonSkin;
}
public ButtonSkin getButtonSkin() { // Returns the skin casted to a ButtonSkin.
return (ButtonSkin) getSkin();
}
public void setFlated(boolean flated) { // The button is "flated" when it's pressed, so I guess that this is the same that saying "clicked".
if (flated) {
getStyleClass().add("flat"); // I don't know what this does.
} else {
getStyleClass().remove("flat"); // I don't know what does this do, either.
}
}
public boolean getFlated() {
return getStyleClass().indexOf("flat") != -1; // If the style class doesn't contain "flat", it returns false.
}
public void toggled(boolean toggled) { // For as far as I know, a toggle is the switch from one effect to another.
if (toggled) {
getStyleClass().add("toggle"); // I know as much about this line as I know about the other "getStyleClass()" lines.
} else {
getStyleClass().remove("toggle"); // I know as much about this line as I know about the other "getStyleClass()" lines.
}
}
public boolean getToggled() {
return getStyleClass().indexOf("toggle") != -1; // If the style class doesn't contain "toggle". it returns false.
}
private void createRippleEffect(Circle circleRipple) { // Defines the ripple effect animation.
Rectangle rippleClip = new Rectangle(); // Creates a new Rectangle
rippleClip.widthProperty().bind(widthProperty()); // For as far as I understand, it binds the width property of the
// rippleClip to itself. Why would you do that?
rippleClip.heightProperty().bind(heightProperty()); // For as far as I understand, it binds the width property of the
// rippleClip to itself. Why would you do that?
circleRipple.setClip(rippleClip); // For as far as I know, clipping is the process that consists
// in hiding everything that is outside of a specified area.
// What this does is specifying that area so that the parts of the circle
// that are outside of the rectangle, can be hided.
circleRipple.setOpacity(0.0); // Sets the circle's opacity to 0.
/*Fade Transition*/
FadeTransition fadeTransition = new FadeTransition(RIPPLE_DURATION, circleRipple); // Creates the fadeTransition.
fadeTransition.setInterpolator(Interpolator.EASE_OUT);
fadeTransition.setFromValue(1.0);
fadeTransition.setToValue(0.0);
/*ScaleTransition*/
final Timeline scaleRippleTimeline = new Timeline(); // Creates the scaleRippleTimeLine Timeline.
DoubleBinding circleRippleRadius = new DoubleBinding() { // Binds the radius of the circle to a double value.
{
bind(heightProperty(), widthProperty());
}
@Override
protected double computeValue() {
return Math.max(heightProperty().get(), widthProperty().get() * 0.45); // Returns the greater of both.
}
};
// The below line adds a listener to circleRippleRadius.
circleRippleRadius.addListener((ObservableValue<? extends Number> observable, Number oldValue, Number newValue) -> {
KeyValue scaleValue = new KeyValue(circleRipple.radiusProperty(), newValue, Interpolator.EASE_OUT);
KeyFrame scaleFrame = new KeyFrame(RIPPLE_DURATION, scaleValue);
scaleRippleTimeline.getKeyFrames().add(scaleFrame);
});
/*ShadowTransition*/
Animation animation = new Transition() { // Creates and defines the animation Transition.
{
setCycleDuration(SHADOW_DURATION); // Sets the duration of "animation".
setInterpolator(Interpolator.EASE_OUT); // It sets the EASE_OUT interpolator,
// so that the shadow isn't displayed forever and its an animation.
}
@Override
protected void interpolate(double frac) {
setEffect(new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.30), 5 + (10 * frac), 0.10 + ((3 * frac) / 10), 0, 2 + (4 * frac)));
// Creates a a DropShadow effect and then sets it to "animation".
}
};
animation.setCycleCount(2);
animation.setAutoReverse(true);
final SequentialTransition rippleTransition = new SequentialTransition(); // Creates a SequentialTransition. The circle's scaling is the
// first transition to occur, and then the color of the button
// changes to the original one with fadeTransition
rippleTransition.getChildren().addAll(
scaleRippleTimeline,
fadeTransition
);
final ParallelTransition parallelTransition = new ParallelTransition();
getStyleClass().addListener((ListChangeListener.Change<? extends String> c) -> { // For as far as I understand, each time that the
// Style class changes, the lines of code between the
// braces are executed, but I still don't understand how
// does the Style class work.
if (c.getList().indexOf("flat") == -1 && c.getList().indexOf("toggle") == -1) {
setMinWidth(88);
setEffect(new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.30), 5, 0.10, 0, 2));
parallelTransition.getChildren().addAll(rippleTransition, animation);
} else {
parallelTransition.getChildren().addAll(rippleTransition);
setMinWidth(USE_COMPUTED_SIZE);
setEffect(null);
}
});
this.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> { // When the button is clicked, each object's value is assigned to the first
// that it must have at the beginning of the animation. Then, the animation
// starts.
parallelTransition.stop();
circleRipple.setOpacity(0.0);
circleRipple.setRadius(0.1);
circleRipple.setCenterX(event.getX());
circleRipple.setCenterY(event.getY());
parallelTransition.playFromStart();
});
}
public void setRippleColor(Color color) {
((Shape) ((SkinBase) getSkin()).getChildren().get(0)).setFill(color); // I don't understand this line of code.
}
}
As I'm pretty new to JavaFX, I see the whole GitHub project as a gold mine, since not only I have access to examples that show how to create a custom controller as a class that can be imported from another, but also it shows how to customize several other controllers too.
The problem is that there are some lines of code that I don't understand (as you'll se if you read the comments I made on the source code).
As an example, there are several times in which getStyleClass().add("something")
is used.
I know how getStylesheets().add()
works, but this is different; I would say that the "Style" class is different from a CSS file.
If that's the case, how does it work? For as far as I understand, the String
s used as a parameter for the getStyleClass().add()
method are used for being able to determine if it is inside of the "Style" class with an if()
statement later; but what exactly is this class? I haven't seend any documentation about it on the internet.
I have problems understanding the setRippleColor()
method at the end of the source code, too. If somebody has an idea of how it works or what should I look up for for understanding it, I'd appreciate it.
Thanks in advance.
UPDATE: Somebody pointed out that ripple-button
is part of a CSS file that can be found on the GitHub project.
I copied the MaterialButton
class and pasted it in a new project, so it can't access to ripple-button
by just mentioning it. Nevertheless, it turns out that if I delete this line of code, the button is narrowed. I can change "ripple-button" by anything and the result is going to be the same, but the line has to be there. Why does this happen?
UPDATE 2: I already understood what the setRippleColor(Color color)
method does: basically it gets the skin of the circle and gets its children so then it can change the rectangle's color once it's casted into a Shape
. It's casted into a shape because Rectangle
extends Shape
. It's pretty simple actually.
There are some issues that might shed some light on your confusion.
First the things are not called 'controllers' but 'controls', this just for clarity as it might be easily confused.
The method
getStyleSheets()
returns anObservableList
of typeString
. This list contains the various path to the.css
files defining the style of the application. Styles are added either on aScene
or aControl
of typeParent
. For more details check the linked JavaDoc or these articles:The style sheets define the style of the controls. They also provide some additional style classes that can be set on any
Node
throughgetStyleClass()
, which also returns anObservableList
of typeString
, this time defining the style class names. When rendering the name is looked up in the set of style sheets defined for thatNode
and then applied. If no such style class is found it is just ignored. ANode
is the base class for any control.The method
createDefaultSkin()
does not override the default skin, as you mentioned in your comment, but it defines the default skin (Well you are partially correct asCustomButton
extendsButton
which'sSkin
is overriden). Generally a control is made up of a 'control' class and a 'skin' class, at least this was the case with JavaFX up till version 8, when it changed. See the article on the control architecture for full detail.