Add Tile control

This commit is contained in:
mkpaz 2023-05-22 16:41:21 +04:00
parent 9c5936515c
commit 47e2d4b9a5
8 changed files with 569 additions and 5 deletions

@ -0,0 +1,39 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.controls;
import java.util.Objects;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.scene.Node;
import javafx.scene.layout.Pane;
final class SlotListener implements ChangeListener<Node> {
private static final PseudoClass FILLED = PseudoClass.getPseudoClass("filled");
private final Pane slot;
public SlotListener(Node slot) {
Objects.requireNonNull(slot, "Slot cannot be null.");
if (slot instanceof Pane pane) {
this.slot = pane;
} else {
throw new IllegalArgumentException("Invalid slot type. Pane is required.");
}
}
@Override
public void changed(ObservableValue<? extends Node> obs, Node old, Node val) {
if (val != null) {
slot.getChildren().setAll(val);
} else {
slot.getChildren().clear();
}
slot.setVisible(val != null);
slot.setManaged(val != null);
slot.pseudoClassStateChanged(FILLED, val != null);
}
}

@ -0,0 +1,138 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.controls;
import javafx.beans.NamedArg;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
/**
* A versatile container that can used in various contexts such as dialog headers,
* list items, and cards. It can contain a graphic, a title, subtitle, and optional
* actions.
*/
public class Tile extends Control {
public Tile() {
this(null, null, null);
}
public Tile(@NamedArg("title") String title,
@NamedArg("subtitle") String subtitle) {
this(title, subtitle, null);
}
public Tile(@NamedArg("title") String title,
@NamedArg("subtitle") String subtitle,
@NamedArg("graphic") Node graphic) {
super();
setTitle(title);
setSubTitle(subtitle);
setGraphic(graphic);
}
@Override
protected Skin<?> createDefaultSkin() {
return new TileSkin(this);
}
///////////////////////////////////////////////////////////////////////////
// Properties //
///////////////////////////////////////////////////////////////////////////
/**
* The property representing the tiles graphic node. It is commonly used
* to add images or icons that are associated with the tile.
*/
private final ObjectProperty<Node> graphic = new SimpleObjectProperty<>(this, "graphic");
public Node getGraphic() {
return graphic.get();
}
public ObjectProperty<Node> graphicProperty() {
return graphic;
}
public void setGraphic(Node graphic) {
this.graphic.set(graphic);
}
/**
* The property representing the tiles title. Although it is not mandatory,
* you typically would not want to have a tile without a title.
*/
private final StringProperty title = new SimpleStringProperty(this, "title");
public String getTitle() {
return title.get();
}
public StringProperty titleProperty() {
return title;
}
public void setTitle(String title) {
this.title.set(title);
}
/**
* The property representing the tiles subtitle. This is usually an optional
* text or a description.
*/
private final StringProperty subTitle = new SimpleStringProperty(this, "subTitle");
public String getSubTitle() {
return subTitle.get();
}
public StringProperty subTitleProperty() {
return subTitle;
}
public void setSubTitle(String subTitle) {
this.subTitle.set(subTitle);
}
/**
* The property representing the tiles action node. It is commonly used
* to place an action controls that are associated with the tile.
*/
private final ObjectProperty<Node> action = new SimpleObjectProperty<>(this, "action");
public Node getAction() {
return action.get();
}
public ObjectProperty<Node> actionProperty() {
return action;
}
public void setAction(Node action) {
this.action.set(action);
}
/**
* The property representing the tiles action handler. Setting an action handler
* makes the tile interactive or clickable. When a user clicks on the interactive
* tile, the specified action handler will be called.
*/
private final ObjectProperty<Runnable> actionHandler = new SimpleObjectProperty<>(this, "actionHandler");
public Runnable getActionHandler() {
return actionHandler.get();
}
public ObjectProperty<Runnable> actionHandlerProperty() {
return actionHandler;
}
public void setActionHandler(Runnable actionHandler) {
this.actionHandler.set(actionHandler);
}
}

@ -0,0 +1,135 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.controls;
import atlantafx.base.theme.Styles;
import javafx.beans.value.ChangeListener;
import javafx.css.PseudoClass;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
public class TileSkin extends SkinBase<Tile> {
private static final PseudoClass FILLED = PseudoClass.getPseudoClass("filled");
protected final HBox root = new HBox();
protected final StackPane graphicSlot;
protected final ChangeListener<Node> graphicSlotListener;
protected final VBox headerBox;
protected final Label titleLbl;
protected final Label subTitleLbl;
protected final StackPane actionSlot;
protected final ChangeListener<Node> actionSlotListener;
public TileSkin(Tile control) {
super(control);
graphicSlot = new StackPane();
graphicSlot.getStyleClass().add("graphic");
graphicSlotListener = new SlotListener(graphicSlot);
control.graphicProperty().addListener(graphicSlotListener);
graphicSlotListener.changed(control.graphicProperty(), null, control.getGraphic());
titleLbl = new Label(control.getTitle());
titleLbl.getStyleClass().add("title");
titleLbl.textProperty().bind(control.titleProperty());
subTitleLbl = new Label(control.getSubTitle());
subTitleLbl.setWrapText(true);
subTitleLbl.getStyleClass().add("subtitle");
subTitleLbl.setVisible(control.getSubTitle() != null);
subTitleLbl.setManaged(control.getSubTitle() != null);
headerBox = new VBox(titleLbl, subTitleLbl);
headerBox.setFillWidth(true);
headerBox.getStyleClass().add("header");
HBox.setHgrow(headerBox, Priority.ALWAYS);
headerBox.setMinHeight(Region.USE_COMPUTED_SIZE);
headerBox.setPrefHeight(Region.USE_COMPUTED_SIZE);
headerBox.setMaxHeight(Region.USE_COMPUTED_SIZE);
registerChangeListener(control.subTitleProperty(), o -> {
var value = getSkinnable().getSubTitle();
subTitleLbl.setText(value);
subTitleLbl.setVisible(value != null);
subTitleLbl.setManaged(value != null);
// header is considered to be filled when a subtitle is set
// because a tile without a title is nonsense
headerBox.pseudoClassStateChanged(FILLED, value != null);
});
actionSlot = new StackPane();
actionSlot.getStyleClass().add("action");
actionSlotListener = new SlotListener(actionSlot);
control.actionProperty().addListener(actionSlotListener);
actionSlotListener.changed(control.actionProperty(), null, control.getAction());
// use pref size for slots, or they will be resized
// to the bare minimum due to Priority.ALWAYS
graphicSlot.setMinWidth(Region.USE_PREF_SIZE);
actionSlot.setMinWidth(Region.USE_PREF_SIZE);
// label text wrapping inside VBox won't work without this
subTitleLbl.setMaxWidth(Region.USE_PREF_SIZE);
subTitleLbl.setMinHeight(Region.USE_PREF_SIZE);
// do not resize children or container won't restore
// to its original size after expanding
root.setFillHeight(false);
root.getStyleClass().add("tile");
root.getChildren().setAll(graphicSlot, headerBox, actionSlot);
root.pseudoClassStateChanged(Styles.STATE_INTERACTIVE, control.getActionHandler() != null);
registerChangeListener(
control.actionHandlerProperty(),
o -> root.pseudoClassStateChanged(Styles.STATE_INTERACTIVE, getSkinnable().getActionHandler() != null)
);
root.setOnMouseClicked(e -> {
if (control.getActionHandler() != null) {
control.getActionHandler().run();
}
});
getChildren().setAll(root);
}
protected double calcHeight() {
var headerHeight = headerBox.getSpacing()
+ headerBox.getInsets().getTop()
+ headerBox.getInsets().getBottom()
+ titleLbl.getBoundsInLocal().getHeight()
+ (subTitleLbl.isManaged() ? subTitleLbl.getBoundsInLocal().getHeight() : 0);
return Math.max(Math.max(graphicSlot.getHeight(), actionSlot.getHeight()), headerHeight)
+ root.getPadding().getTop()
+ root.getPadding().getBottom();
}
@Override
protected double computeMinHeight(double width, double topInset, double rightInset,
double bottomInset, double leftInset) {
// change the control height when label changed its height due to text wrapping,
// no other way to do that, all JavaFX containers completely ignore _the actual_
// height of its children
return calcHeight();
}
@Override
public void dispose() {
titleLbl.textProperty().unbind();
unregisterChangeListeners(getSkinnable().subTitleProperty());
getSkinnable().graphicProperty().removeListener(graphicSlotListener);
getSkinnable().actionProperty().removeListener(actionSlotListener);
unregisterChangeListeners(getSkinnable().actionHandlerProperty());
super.dispose();
}
}

@ -26,11 +26,6 @@ public final class Styles {
public static final String WARNING = "warning";
public static final String DANGER = "danger";
public static final PseudoClass STATE_ACCENT = PseudoClass.getPseudoClass(ACCENT);
public static final PseudoClass STATE_SUCCESS = PseudoClass.getPseudoClass(SUCCESS);
public static final PseudoClass STATE_WARNING = PseudoClass.getPseudoClass(WARNING);
public static final PseudoClass STATE_DANGER = PseudoClass.getPseudoClass(DANGER);
// Controls
public static final String TEXT = "text";
@ -90,6 +85,14 @@ public final class Styles {
public static final String TEXT_MUTED = "text-muted";
public static final String TEXT_SUBTLE = "text-subtle";
// Pseudo-classes
public static final PseudoClass STATE_ACCENT = PseudoClass.getPseudoClass(ACCENT);
public static final PseudoClass STATE_SUCCESS = PseudoClass.getPseudoClass(SUCCESS);
public static final PseudoClass STATE_WARNING = PseudoClass.getPseudoClass(WARNING);
public static final PseudoClass STATE_DANGER = PseudoClass.getPseudoClass(DANGER);
public static final PseudoClass STATE_INTERACTIVE = PseudoClass.getPseudoClass(INTERACTIVE);
private Styles() {
// Default constructor
}

@ -40,6 +40,7 @@ import atlantafx.sampler.page.components.TabPanePage;
import atlantafx.sampler.page.components.TableViewPage;
import atlantafx.sampler.page.components.TextAreaPage;
import atlantafx.sampler.page.components.TextFieldPage;
import atlantafx.sampler.page.components.TilePage;
import atlantafx.sampler.page.components.TitledPanePage;
import atlantafx.sampler.page.components.ToggleButtonPage;
import atlantafx.sampler.page.components.ToggleSwitchPage;
@ -164,6 +165,7 @@ public class MainModel {
NAV_TREE.get(SeparatorPage.class),
NAV_TREE.get(SplitPanePage.class),
NAV_TREE.get(PopoverPage.class),
NAV_TREE.get(TilePage.class),
NAV_TREE.get(TitledPanePage.class),
NAV_TREE.get(ToolBarPage.class)
);
@ -297,6 +299,7 @@ public class MainModel {
map.put(SliderPage.class, NavTree.Item.page(SliderPage.NAME, SliderPage.class));
map.put(SpinnerPage.class, NavTree.Item.page(SpinnerPage.NAME, SpinnerPage.class));
map.put(SplitPanePage.class, NavTree.Item.page(SplitPanePage.NAME, SplitPanePage.class));
map.put(TilePage.class, NavTree.Item.page(TilePage.NAME, TilePage.class));
map.put(TableViewPage.class, NavTree.Item.page(TableViewPage.NAME, TableViewPage.class));
map.put(TabPanePage.class, NavTree.Item.page(TabPanePage.NAME, TabPanePage.class));
map.put(TextAreaPage.class, NavTree.Item.page(TextAreaPage.NAME, TextAreaPage.class));

@ -0,0 +1,199 @@
package atlantafx.sampler.page.components;
import atlantafx.base.controls.PasswordTextField;
import atlantafx.base.controls.ToggleSwitch;
import atlantafx.base.theme.Styles;
import atlantafx.base.util.BBCodeParser;
import atlantafx.sampler.Resources;
import atlantafx.base.controls.Tile;
import atlantafx.sampler.page.ExampleBox;
import atlantafx.sampler.page.OutlinePage;
import atlantafx.sampler.page.Snippet;
import java.net.URI;
import java.util.function.BiFunction;
import javafx.collections.FXCollections;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.Separator;
import javafx.scene.control.Spinner;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2OutlinedAL;
public class TilePage extends OutlinePage {
public static final String NAME = "Tile";
@Override
public String getName() {
return NAME;
}
@Override
public URI getJavadocUri() {
return URI.create(String.format(AFX_JAVADOC_URI_TEMPLATE, "controls/" + getName()));
}
public TilePage() {
super();
addPageHeader();
addFormattedText("""
The Tile is a versatile container that can used in various contexts \
such as dialog headers, list items, and cards. It can contain a graphic, \
a title, subtitle, and optional actions."""
);
addNode(skeleton());
addSection("Usage", usageExample());
addSection("Interactive", interactiveExample());
addSection("Stacking", stackingExample());
}
private Node skeleton() {
BiFunction<String, Pos, Node> cellBuilder = (s, pos) -> {
var cell = new VBox(new Label(s));
cell.setPadding(new Insets(10));
cell.setFillWidth(true);
cell.setAlignment(pos);
Styles.appendStyle(cell, "-fx-border-color", "-color-accent-muted");
Styles.appendStyle(cell, "-fx-border-width", "5px");
return cell;
};
var grid = new GridPane();
grid.setHgap(10);
grid.setVgap(10);
grid.setMaxWidth(600);
grid.add(cellBuilder.apply("Graphic", Pos.CENTER), 0, 0, 1, GridPane.REMAINING);
grid.add(cellBuilder.apply("Title", Pos.CENTER_LEFT), 1, 0, 1, 1);
grid.add(cellBuilder.apply("Subtitle", Pos.CENTER_LEFT), 1, 1, 1, 1);
grid.add(cellBuilder.apply("Action", Pos.CENTER), 2, 0, 1, GridPane.REMAINING);
grid.getColumnConstraints().setAll(
new ColumnConstraints(-1, -1, -1, Priority.NEVER, HPos.CENTER, true),
new ColumnConstraints(-1, -1, -1, Priority.ALWAYS, HPos.CENTER, true),
new ColumnConstraints(-1, -1, -1, Priority.NEVER, HPos.CENTER, true)
);
return grid;
}
private Node usageExample() {
//snippet_1:start
var tile1 = new Tile(
"Title",
FAKER.lorem().sentence(15)
);
var tile2 = new Tile(
FAKER.name().fullName(),
FAKER.elderScrolls().quote()
);
var img = new ImageView(new Image(
Resources.getResourceAsStream(
"assets/fxml/blueprints/resources/avatar1.png"
)
));
img.setFitWidth(64);
img.setFitHeight(64);
tile2.setGraphic(img);
var tile3 = new Tile("Photos", "Last updated: Jun 9, 2019");
var btn = new Button("", new FontIcon(Material2OutlinedAL.DELETE));
btn.getStyleClass().addAll(Styles.BUTTON_CIRCLE, Styles.FLAT);
tile3.setAction(btn);
//snippet_1:end
var box = new VBox(
tile1, new Separator(),
tile2, new Separator(),
tile3
);
var description = BBCodeParser.createFormattedText("""
[i]Tile[/i] does not have any mandatory properties, but you will not want \
to use it without a title. Additionally, note that only the subtitle supports \
text wrapping."""
);
return new ExampleBox(box, new Snippet(getClass(), 1), description);
}
private Node interactiveExample() {
//snippet_2:start
var tile = new Tile(
"Password",
"Please enter your authentication password to unlock the content"
);
var tf = new PasswordTextField(null);
tf.setPromptText("Click on the tile");
tf.setPrefWidth(150);
tile.setAction(tf);
tile.setActionHandler(tf::requestFocus);
//snippet_2:end
var box = new VBox(tile);
var description = BBCodeParser.createFormattedText("""
A [i]Tile[/i] can be made interactive by setting an action handler that may \
or may not be related to the action slot."""
);
return new ExampleBox(box, new Snippet(getClass(), 2), description);
}
private Node stackingExample() {
//snippet_3:start
var tile1 = new Tile(
"Content filtering",
"Set the content filtering level to restrict downloaded apps"
);
var cmb = new ComboBox<>(FXCollections.observableArrayList(
"Everyone", "Low", "Medium", "High"
));
cmb.getSelectionModel().selectLast();
cmb.setPrefWidth(150);
tile1.setAction(cmb);
var tile2 = new Tile(
"Password",
"Require password for purchase"
);
var tgl2 = new ToggleSwitch();
tile2.setAction(tgl2);
tile2.setActionHandler(() -> tgl2.setSelected(!tgl2.isSelected()));
var tile3 = new Tile("Cache Size (Mb)", null);
var spinner = new Spinner<>(10, 100, 50);
spinner.setPrefWidth(150);
tile3.setAction(spinner);
var tile4 = new Tile(
"Notifications",
"Notify me about updates to apps that I downloaded"
);
var tgl3 = new ToggleSwitch();
tile4.setAction(tgl3);
tile4.setActionHandler(() -> tgl3.setSelected(!tgl3.isSelected()));
var box = new VBox(tile1, tile2, tile3, new Separator(), tile4);
//snippet_3:end
var description = BBCodeParser.createFormattedText("""
You can stack several [i]Tiles[/i] vertically. Optionally, use the [i]Separator[/i] \
to split them into groups."""
);
return new ExampleBox(box, new Snippet(getClass(), 3), description);
}
}

@ -27,6 +27,7 @@
@use "split-pane";
@use "tab-pane";
@use "text-input";
@use "tile";
@use "titled-pane";
@use "toggle-button";
@use "toggle-switch";

@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT
$color-interactive: -color-bg-subtle !default;
@use "../settings/config" as cfg;
.tile {
-fx-padding: 0.75em 1em 0.75em 1em;
-fx-alignment: CENTER_LEFT;
-fx-background-radius: cfg.$border-radius;
&:hover:interactive {
-fx-background-color: $color-interactive;
-fx-cursor: hand;
}
>.graphic {
&:filled {
-fx-padding: 0 1em 0 0;
}
}
>.header {
-fx-alignment: CENTER_LEFT;
-fx-padding: 0;
>.title {
-fx-font-size: cfg.$font-title-4;
}
>.subtitle {
-fx-text-fill: -color-fg-muted;
}
&:filled {
-fx-spacing: 5px;
-fx-alignment: TOP_LEFT;
}
}
>.action {
&:filled {
-fx-padding: 0 0 0 1em;
}
}
}