Procházet zdrojové kódy

:bookmark: Version 11.4.0-EA1

:sparkles: Added new control, MFXTitledPane

Signed-off-by: palexdev <alessandro.parisi406@gmail.com>
palexdev před 3 roky
rodič
revize
ef67779103
21 změnil soubory, kde provedl 966 přidání a 16 odebrání
  1. 8 1
      CHANGELOG.md
  2. 1 1
      build.gradle
  3. 1 1
      demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Buttons.css
  4. 1 1
      demo/src/main/resources/io/github/palexdev/materialfx/demo/css/ChecksRadiosToggles.css
  5. 1 1
      demo/src/main/resources/io/github/palexdev/materialfx/demo/css/ComboBoxes.css
  6. 1 1
      demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Common.css
  7. 1 1
      demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Demo.css
  8. 0 0
      demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Fonts.css
  9. 1 1
      demo/src/main/resources/io/github/palexdev/materialfx/demo/css/ListViews.css
  10. 1 1
      demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Pickers.css
  11. 1 1
      demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Progress.css
  12. 1 1
      demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Sliders.css
  13. 1 1
      demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Stepper.css
  14. 1 1
      demo/src/main/resources/io/github/palexdev/materialfx/demo/css/TextFields.css
  15. 2 2
      materialfx/gradle.properties
  16. 115 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/ExpandGroup.java
  17. 482 0
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/MFXTitledPane.java
  18. 1 1
      materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/SpinnerModel.java
  19. 28 0
      materialfx/src/main/java/io/github/palexdev/materialfx/enums/HeaderPosition.java
  20. 279 0
      materialfx/src/main/java/io/github/palexdev/materialfx/skins/MFXTitledPaneSkin.java
  21. 39 0
      materialfx/src/main/resources/io/github/palexdev/materialfx/css/MFXTitledPane.css

+ 8 - 1
CHANGELOG.md

@@ -13,13 +13,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - **Removed** for now removed features.
 - **Fixed** for any bug fixes.
 
-[//]: ##[Unreleased]
+## [Unreleased] - 21-03-2022
+
+### Added
+
+- New control: MFXTitledPane
 
 ## [11.13.3] - 10-03-2022
+
 ### Added
+
 - MFXTextField: added a label to specify the unit of measure (optional, leave blank string to remove)
 
 ### Changed
+
 - Update Gradle plugins
 - Update VirtualizedFX to 11.2.5
 - Improve ROADMAP

+ 1 - 1
build.gradle

@@ -4,7 +4,7 @@ plugins {
 }
 
 group 'io.github.palexdev'
-version '11.13.3'
+version '11.14.0-EA1'
 
 repositories {
     mavenCentral()

+ 1 - 1
demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Buttons.css

@@ -16,7 +16,7 @@
  * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-@import '../fonts/Fonts.css';
+@import 'Fonts.css';
 @import 'Common.css';
 @import 'MFXColors.css';
 

+ 1 - 1
demo/src/main/resources/io/github/palexdev/materialfx/demo/css/ChecksRadiosToggles.css

@@ -16,7 +16,7 @@
  * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-@import '../fonts/Fonts.css';
+@import 'Fonts.css';
 @import 'Common.css';
 @import 'MFXColors.css';
 

+ 1 - 1
demo/src/main/resources/io/github/palexdev/materialfx/demo/css/ComboBoxes.css

@@ -16,7 +16,7 @@
  * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-@import '../fonts/Fonts.css';
+@import 'Fonts.css';
 @import 'Common.css';
 @import 'MFXColors.css';
 

+ 1 - 1
demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Common.css

@@ -16,7 +16,7 @@
  * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-@import '../fonts/Fonts.css';
+@import 'Fonts.css';
 @import 'MFXColors.css';
 
 .header-label {

+ 1 - 1
demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Demo.css

@@ -16,7 +16,7 @@
  * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-@import '../fonts/Fonts.css';
+@import 'Fonts.css';
 @import 'MFXColors.css';
 
 /**************************************************

+ 0 - 0
demo/src/main/resources/io/github/palexdev/materialfx/demo/fonts/Fonts.css → demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Fonts.css


+ 1 - 1
demo/src/main/resources/io/github/palexdev/materialfx/demo/css/ListViews.css

@@ -16,7 +16,7 @@
  * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-@import '../fonts/Fonts.css';
+@import 'Fonts.css';
 @import 'Common.css';
 @import 'MFXColors.css';
 

+ 1 - 1
demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Pickers.css

@@ -16,7 +16,7 @@
  * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-@import '../fonts/Fonts.css';
+@import 'Fonts.css';
 @import 'Common.css';
 @import 'MFXColors.css';
 

+ 1 - 1
demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Progress.css

@@ -16,7 +16,7 @@
  * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-@import '../fonts/Fonts.css';
+@import 'Fonts.css';
 @import 'Common.css';
 @import 'MFXColors.css';
 

+ 1 - 1
demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Sliders.css

@@ -16,7 +16,7 @@
  * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-@import '../fonts/Fonts.css';
+@import 'Fonts.css';
 @import 'Common.css';
 @import 'MFXColors.css';
 

+ 1 - 1
demo/src/main/resources/io/github/palexdev/materialfx/demo/css/Stepper.css

@@ -16,7 +16,7 @@
  * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-@import '../fonts/Fonts.css';
+@import 'Fonts.css';
 @import 'Common.css';
 @import 'MFXColors.css';
 

+ 1 - 1
demo/src/main/resources/io/github/palexdev/materialfx/demo/css/TextFields.css

@@ -16,7 +16,7 @@
  * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-@import '../fonts/Fonts.css';
+@import 'Fonts.css';
 @import 'Common.css';
 @import 'MFXColors.css';
 

+ 2 - 2
materialfx/gradle.properties

@@ -1,6 +1,6 @@
 GROUP=io.github.palexdev
 POM_ARTIFACT_ID=materialfx
-VERSION_NAME=11.13.3
+VERSION_NAME=11.14.0-EA1
 
 POM_NAME=materialfx
 POM_DESCRIPTION=Material Desgin components for JavaFX
@@ -10,7 +10,7 @@ POM_URL=https://github.com/palexdev/MaterialFX
 POM_SCM_URL=https://github.com/palexdev/MaterialFX
 
 POM_LICENCE_NAME=GNU LGPLv3
-POM_LICENCE_URL=https://www.gnu.org/licenses/gpl-3.0.html
+POM_LICENCE_URL=https://www.gnu.org/licenses/lgpl-3.0.html
 POM_LICENCE_DIST=repo
 
 POM_DEVELOPER_ID=palexdev

+ 115 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/ExpandGroup.java

@@ -0,0 +1,115 @@
+package io.github.palexdev.materialfx.controls;
+
+import javafx.beans.property.ReadOnlyObjectProperty;
+import javafx.beans.property.ReadOnlyObjectWrapper;
+import javafx.collections.FXCollections;
+import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
+import javafx.scene.Node;
+import javafx.scene.control.ToggleGroup;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * This class acts as a {@link ToggleGroup} but for {@link MFXTitledPane}s.
+ */
+public class ExpandGroup {
+	private Set<Node> prevPanes = new HashSet<>();
+	private final ReadOnlyObjectWrapper<MFXTitledPane> expandedPane = new ReadOnlyObjectWrapper<>() {
+		@Override
+		public void set(MFXTitledPane newValue) {
+			if (isBound()) {
+				throw new RuntimeException("A bound value cannot be set.");
+			}
+			MFXTitledPane old = get();
+			if (old == newValue) return;
+
+			if (setExpanded(newValue, true) ||
+					(newValue != null && newValue.getExpandGroup() == ExpandGroup.this) ||
+					(newValue == null)) {
+				if (old == null || old.getExpandGroup() == ExpandGroup.this || !old.isExpanded())
+					setExpanded(old, false);
+				super.set(newValue);
+			}
+		}
+	};
+	private final ObservableList<MFXTitledPane> panes = FXCollections.observableArrayList();
+
+	public ExpandGroup() {
+		panes.addListener((ListChangeListener<? super MFXTitledPane>) c -> {
+			while (c.next()) {
+				List<? extends MFXTitledPane> addedList = c.getAddedSubList();
+
+				for (MFXTitledPane removed : c.getRemoved()) {
+					if (removed.isExpanded()) {
+						setExpandedPane(null);
+					}
+
+					if (!addedList.contains(removed)) {
+						removed.setExpandGroup(null);
+					}
+				}
+
+				for (MFXTitledPane added : addedList) {
+					if (prevPanes.contains(added))
+						throw new IllegalArgumentException("Duplicate panes are not allowed!");
+					if (!this.equals(added.getExpandGroup())) {
+						if (added.getExpandGroup() != null) added.getExpandGroup().getPanes().remove(added);
+						added.setExpandGroup(this);
+					}
+				}
+
+				for (MFXTitledPane added : addedList) {
+					if (added.isExpanded()) {
+						setExpandedPane(added);
+						break;
+					}
+				}
+			}
+			prevPanes = new HashSet<>(c.getList());
+		});
+	}
+
+	private boolean setExpanded(MFXTitledPane pane, boolean expanded) {
+		if (pane != null &&
+				pane.getExpandGroup() == this &&
+				!pane.expandedProperty().isBound()) {
+			pane.setExpanded(expanded);
+			return true;
+		}
+		return false;
+	}
+
+	public final void clearExpandedPane() {
+		if (!getExpandedPane().isExpanded()) {
+			for (MFXTitledPane pane : getPanes()) {
+				if (pane.isExpanded()) return;
+			}
+		}
+		setExpandedPane(null);
+	}
+
+	public MFXTitledPane getExpandedPane() {
+		return expandedPane.get();
+	}
+
+	public ReadOnlyObjectProperty<MFXTitledPane> expandedPaneProperty() {
+		return expandedPane.getReadOnlyProperty();
+	}
+
+	public final void setExpandedPane(MFXTitledPane expandedPane) {
+		this.expandedPane.set(expandedPane);
+	}
+
+	public ObservableList<MFXTitledPane> getPanes() {
+		return FXCollections.unmodifiableObservableList(panes);
+	}
+
+	public static void addToGroup(ExpandGroup group, MFXTitledPane... panes) {
+		for (MFXTitledPane pane : panes) {
+			pane.setExpandGroup(group);
+		}
+	}
+}

+ 482 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/controls/MFXTitledPane.java

@@ -0,0 +1,482 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.controls;
+
+import io.github.palexdev.materialfx.MFXResourcesLoader;
+import io.github.palexdev.materialfx.beans.properties.functional.SupplierProperty;
+import io.github.palexdev.materialfx.beans.properties.styleable.StyleableBooleanProperty;
+import io.github.palexdev.materialfx.beans.properties.styleable.StyleableObjectProperty;
+import io.github.palexdev.materialfx.effects.Interpolators;
+import io.github.palexdev.materialfx.enums.HeaderPosition;
+import io.github.palexdev.materialfx.font.MFXFontIcon;
+import io.github.palexdev.materialfx.skins.MFXTitledPaneSkin;
+import io.github.palexdev.materialfx.utils.AnimationUtils;
+import io.github.palexdev.materialfx.utils.NodeUtils;
+import io.github.palexdev.materialfx.utils.StyleablePropertiesUtils;
+import javafx.beans.binding.Bindings;
+import javafx.beans.property.*;
+import javafx.css.CssMetaData;
+import javafx.css.PseudoClass;
+import javafx.css.Styleable;
+import javafx.css.StyleablePropertyFactory;
+import javafx.geometry.Pos;
+import javafx.scene.Node;
+import javafx.scene.control.Control;
+import javafx.scene.control.Label;
+import javafx.scene.control.Skin;
+import javafx.scene.control.TitledPane;
+import javafx.scene.input.MouseButton;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.StackPane;
+import javafx.scene.layout.VBox;
+import javafx.util.Duration;
+
+import java.util.List;
+import java.util.function.Supplier;
+
+/**
+ * This is the implementation of a JavaFX's {@link TitledPane} remade from scratch to give it
+ * more features, flexibility and of course a modern look.
+ * <p></p>
+ * Unlike the original pane, this one allows you to set whatever you want as header by setting
+ * the {@link #headerSupplierProperty()}, just keep in mind that a {@code null} supplier or a {@code null} return value
+ * won't be accepted. When using this constructor, {@link MFXTitledPane#MFXTitledPane(String, Node)}, the supplier
+ * will be set to build a new {@link DefaultHeader} pane.
+ * <p>
+ * So, the {@link #titleProperty()}, is only relevant when using the default header supplier, or (of course) if
+ * by making your own header you decide to use it in some way (a Label bound to the title for example).
+ * <p></p>
+ * There are also three other new features:
+ * <p> - You can set the header position wherever you like, TOP/RIGHT/BOTTOM/LEFT
+ * <p> - There's no need to use an accordion anymore, you can simply arrange multiple {@code MFXTitledPanes} in a
+ * container, like a VBox or HBox, and use an {@link ExpandGroup} to achieve the same behavior
+ * <p> - Unlike the original one, you can specify the duration of the expand/collapse animation
+ */
+public class MFXTitledPane extends Control {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final String STYLE_CLASS = "mfx-titled-pane";
+	private final String STYLESHEET = MFXResourcesLoader.load("css/MFXTitledPane.css");
+
+	private final StringProperty title = new SimpleStringProperty();
+	private final SupplierProperty<Node> headerSupplier = new SupplierProperty<>();
+	private final ObjectProperty<Node> content = new SimpleObjectProperty<>();
+
+	private final BooleanProperty expanded = new SimpleBooleanProperty() {
+		@Override
+		protected void invalidated() {
+			boolean state = get();
+			pseudoClassStateChanged(EXPANDED_PSEUDO_CLASS, state);
+			pseudoClassStateChanged(COLLAPSED_PSEUDO_CLASS, !state);
+		}
+	};
+	private final ObjectProperty<ExpandGroup> expandGroup = new SimpleObjectProperty<>();
+
+	protected static final PseudoClass EXPANDED_PSEUDO_CLASS = PseudoClass.getPseudoClass("expanded");
+	protected static final PseudoClass COLLAPSED_PSEUDO_CLASS = PseudoClass.getPseudoClass("collapsed");
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public MFXTitledPane() {
+		this("Title", null);
+	}
+
+	public MFXTitledPane(String title, Node content) {
+		defaultHeaderSupplier();
+		setTitle(title);
+		setContent(content);
+		initialize();
+	}
+
+	public MFXTitledPane(Supplier<Node> headerSupplier, Node content) {
+		setHeaderSupplier(headerSupplier);
+		setContent(content);
+		initialize();
+	}
+
+	//================================================================================
+	// Methods
+	//================================================================================
+	private void initialize() {
+		getStyleClass().add(STYLE_CLASS);
+
+		expandGroup.addListener((observable, oldGroup, newGroup) -> {
+			if (newGroup != null && newGroup.getPanes().contains(this)) {
+				if (oldGroup != null) oldGroup.getPanes().remove(this);
+				newGroup.getPanes().add(this);
+			} else if (newGroup == null) {
+				oldGroup.getPanes().remove(this);
+			}
+		});
+		expanded.addListener(invalidated -> {
+			ExpandGroup eg = getExpandGroup();
+			if (eg != null) {
+				if (isExpanded()) {
+					eg.setExpandedPane(this);
+				} else if (eg.getExpandedPane() == this) {
+					eg.clearExpandedPane();
+				}
+			}
+		});
+	}
+
+	/**
+	 * Resets the {@link #headerSupplierProperty()} to the default supplier.
+	 */
+	public void defaultHeaderSupplier() {
+		setHeaderSupplier(DefaultHeader::new);
+	}
+
+	//================================================================================
+	// Styleable Properties
+	//================================================================================
+	private final StyleableBooleanProperty animated = new StyleableBooleanProperty(
+			StyleableProperties.ANIMATED,
+			this,
+			"animated",
+			true
+	);
+
+	private final StyleableObjectProperty<Duration> animationDuration = new StyleableObjectProperty<>(
+			StyleableProperties.ANIMATION_DURATION,
+			this,
+			"animationDuration",
+			Duration.millis(300)
+	);
+
+	private final StyleableBooleanProperty collapsible = new StyleableBooleanProperty(
+			StyleableProperties.COLLAPSIBLE,
+			this,
+			"collapsible",
+			true
+	);
+
+	private final StyleableObjectProperty<HeaderPosition> headerPos = new StyleableObjectProperty<>(
+			StyleableProperties.HEADER_POS,
+			this,
+			"headerPos",
+			HeaderPosition.TOP
+	);
+
+	public boolean isAnimated() {
+		return animated.get();
+	}
+
+	/**
+	 * Specifies whether to animate the expand/collapse transition.
+	 */
+	public StyleableBooleanProperty animatedProperty() {
+		return animated;
+	}
+
+	public void setAnimated(boolean animated) {
+		this.animated.set(animated);
+	}
+
+	public Duration getAnimationDuration() {
+		return animationDuration.get();
+	}
+
+	/**
+	 * Specifies the duration of the expand/collapse animation.
+	 */
+	public StyleableObjectProperty<Duration> animationDurationProperty() {
+		return animationDuration;
+	}
+
+	public void setAnimationDuration(Duration animationDuration) {
+		this.animationDuration.set(animationDuration);
+	}
+
+	public boolean isCollapsible() {
+		return collapsible.get();
+	}
+
+	/**
+	 * Specifies whether the pane can be collapsed.
+	 */
+	public StyleableBooleanProperty collapsibleProperty() {
+		return collapsible;
+	}
+
+	public void setCollapsible(boolean collapsible) {
+		this.collapsible.set(collapsible);
+	}
+
+	public HeaderPosition getHeaderPos() {
+		return headerPos.get();
+	}
+
+	/**
+	 * Specifies the position of the header node.
+	 */
+	public StyleableObjectProperty<HeaderPosition> headerPosProperty() {
+		return headerPos;
+	}
+
+	public void setHeaderPos(HeaderPosition headerPos) {
+		this.headerPos.set(headerPos);
+	}
+
+	//================================================================================
+	// CSSMetaData
+	//================================================================================
+	private static class StyleableProperties {
+		private static final StyleablePropertyFactory<MFXTitledPane> FACTORY = new StyleablePropertyFactory<>(Control.getClassCssMetaData());
+		private static final List<CssMetaData<? extends Styleable, ?>> cssMetaDataList;
+
+		private static final CssMetaData<MFXTitledPane, Boolean> ANIMATED =
+				FACTORY.createBooleanCssMetaData(
+						"-mfx-animated",
+						MFXTitledPane::animatedProperty,
+						true
+				);
+
+		private static final CssMetaData<MFXTitledPane, Duration> ANIMATION_DURATION =
+				FACTORY.createDurationCssMetaData(
+						"-mfx-animation-duration",
+						MFXTitledPane::animationDurationProperty,
+						Duration.millis(300)
+				);
+
+		private static final CssMetaData<MFXTitledPane, Boolean> COLLAPSIBLE =
+				FACTORY.createBooleanCssMetaData(
+						"-mfx-collapsible",
+						MFXTitledPane::collapsibleProperty,
+						true
+				);
+
+		private static final CssMetaData<MFXTitledPane, HeaderPosition> HEADER_POS =
+				FACTORY.createEnumCssMetaData(
+						HeaderPosition.class,
+						"-mfx-pos",
+						MFXTitledPane::headerPosProperty,
+						HeaderPosition.TOP
+				);
+
+		static {
+			cssMetaDataList = StyleablePropertiesUtils.cssMetaDataList(
+					Control.getClassCssMetaData(),
+					ANIMATED, ANIMATION_DURATION, COLLAPSIBLE, HEADER_POS
+			);
+		}
+	}
+
+	public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
+		return StyleableProperties.cssMetaDataList;
+	}
+
+	@Override
+	protected List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
+		return getClassCssMetaData();
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+	@Override
+	protected Skin<?> createDefaultSkin() {
+		return new MFXTitledPaneSkin(this);
+	}
+
+	@Override
+	public String getUserAgentStylesheet() {
+		return STYLESHEET;
+	}
+
+	//================================================================================
+	// Getters/Setters
+	//================================================================================
+	public String getTitle() {
+		return title.get();
+	}
+
+	/**
+	 * Specifies the pane's title.
+	 */
+	public StringProperty titleProperty() {
+		return title;
+	}
+
+	public void setTitle(String title) {
+		this.title.set(title);
+	}
+
+	public Supplier<Node> getHeaderSupplier() {
+		return headerSupplier.get();
+	}
+
+	/**
+	 * Specifies the {@link Supplier} used to build the header node.
+	 * <p></p>
+	 * The default one builds a new {@link DefaultHeader}.
+	 */
+	public SupplierProperty<Node> headerSupplierProperty() {
+		return headerSupplier;
+	}
+
+	public void setHeaderSupplier(Supplier<Node> headerSupplier) {
+		this.headerSupplier.set(headerSupplier);
+	}
+
+	public Node getContent() {
+		return content.get();
+	}
+
+	/**
+	 * Specifies the pane's content, can be null and changed at runtime.
+	 */
+	public ObjectProperty<Node> contentProperty() {
+		return content;
+	}
+
+	public void setContent(Node content) {
+		this.content.set(content);
+	}
+
+	public boolean isExpanded() {
+		return expanded.get();
+	}
+
+	/**
+	 * Specifies the expand state of the pane.
+	 */
+	public BooleanProperty expandedProperty() {
+		return expanded;
+	}
+
+	public void setExpanded(boolean expanded) {
+		this.expanded.set(expanded);
+	}
+
+	public ExpandGroup getExpandGroup() {
+		return expandGroup.get();
+	}
+
+	/**
+	 * Specifies the {@link ExpandGroup} this pane belongs to.
+	 */
+	public ObjectProperty<ExpandGroup> expandGroupProperty() {
+		return expandGroup;
+	}
+
+	public void setExpandGroup(ExpandGroup expandGroup) {
+		this.expandGroup.set(expandGroup);
+	}
+
+	//================================================================================
+	// Default Header Class
+	//================================================================================
+
+	/**
+	 * Default header used by {@link MFXTitledPane}s.
+	 * <p></p>
+	 * It basically consists in a {@link Label} which has its text bound to the pane's {@link #titleProperty()},
+	 * and a {@link MFXFontIcon} (wrapped in a {@link MFXIconWrapper}) for the arrow, which is responsible
+	 * for expanding/collapsing the pane.
+	 * <p></p>
+	 * This header is capable of rearranging itself when the {@link #headerPosProperty()}, to make things easier
+	 * the layout is not manual but automatically managed, see {@link #initializeContainer()} for more info.
+	 * <p>
+	 * The icon also depends on the {@link #headerPosProperty()}, see {@link #iconForPosition()}.
+	 */
+	public class DefaultHeader extends StackPane {
+		private final Label label;
+		private final MFXIconWrapper wrapped;
+
+		public DefaultHeader() {
+			label = new Label();
+			label.textProperty().bind(titleProperty());
+			label.getStyleClass().add("header-label");
+			label.setMaxWidth(Double.MAX_VALUE);
+			HBox.setHgrow(label, Priority.ALWAYS);
+
+			MFXFontIcon icon = new MFXFontIcon("mfx-chevron-left", 14);
+			icon.descriptionProperty().bind(Bindings.createStringBinding(this::iconForPosition, headerPosProperty()));
+
+			wrapped = new MFXIconWrapper(icon, 20).defaultRippleGeneratorBehavior();
+			wrapped.setRotate(isExpanded() ? -180 : 0);
+			NodeUtils.makeRegionCircular(wrapped);
+
+			wrapped.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
+				if (event.getButton() != MouseButton.PRIMARY || (isExpanded() && !isCollapsible())) return;
+				setExpanded(!isExpanded());
+			});
+
+			expanded.addListener((observable, oldValue, newValue) -> {
+				double rotate = newValue ? -180 : 0;
+				AnimationUtils.TimelineBuilder.build()
+						.add(AnimationUtils.KeyFrames.of(200, wrapped.rotateProperty(), rotate, Interpolators.INTERPOLATOR_V1))
+						.getAnimation()
+						.play();
+			});
+
+			headerPos.addListener(invalidated -> initializeContainer());
+			initializeContainer();
+
+			getStyleClass().add("header-pane");
+		}
+
+		/**
+		 * Responsible for rearranging the header's content when the {@link #headerPosProperty()} changes.
+		 * <p></p>
+		 * When it is LEFT or RIGHT, both the label and icon will be contained by a VBox,
+		 * otherwise they will be contained by a HBox.
+		 */
+		private void initializeContainer() {
+			HeaderPosition position = getHeaderPos();
+			Node container;
+			if (position == HeaderPosition.LEFT || position == HeaderPosition.RIGHT) {
+				container = new VBox(label, wrapped);
+				((VBox) container).setAlignment(Pos.CENTER);
+			} else {
+				container = new HBox(label, wrapped);
+				((HBox) container).setAlignment(Pos.CENTER);
+			}
+			getChildren().setAll(container);
+		}
+
+		/**
+		 * Responsible for changing the icon when {@link #headerPosProperty()} changes.
+		 * <p>
+		 * <p> - Case RIGHT: "mfx-chevron-left"
+		 * <p> - Case BOTTOM: "mfx-chevron-down"
+		 * <p> - Case LEFT: "mfx-chevron-right"
+		 * <p> - Case TOP: "mfx-chevron-up"
+		 */
+		protected String iconForPosition() {
+			HeaderPosition position = getHeaderPos();
+			switch (position) {
+				case RIGHT:
+					return "mfx-chevron-left";
+				case BOTTOM:
+					return "mfx-chevron-down";
+				case LEFT:
+					return "mfx-chevron-right";
+				case TOP:
+				default:
+					return "mfx-chevron-up";
+			}
+		}
+	}
+}

+ 1 - 1
materialfx/src/main/java/io/github/palexdev/materialfx/controls/models/spinner/SpinnerModel.java

@@ -27,7 +27,7 @@ import javafx.util.StringConverter;
  * <p>
  * {@code SpinnerModel} is basically an helper class to allow the spinner to work on any object
  * type as long as a model exists for it. The model is responsible for changing the spinner's value by
- * going forward or backwards ({@link #next() or {@link #previous()}}.
+ * going forward or backwards ({@link #next()}, or {@link #previous()}).
  * <p>
  * Along this core functionality the model also specifies a {@link StringConverter} which will be
  * used to convert the T value to a String, which will be the spinner's text.

+ 28 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/enums/HeaderPosition.java

@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.enums;
+
+import io.github.palexdev.materialfx.controls.MFXTitledPane;
+
+/**
+ * Enumeration used by {@link MFXTitledPane} to specify the header's position.
+ */
+public enum HeaderPosition {
+	TOP, RIGHT, BOTTOM, LEFT
+}

+ 279 - 0
materialfx/src/main/java/io/github/palexdev/materialfx/skins/MFXTitledPaneSkin.java

@@ -0,0 +1,279 @@
+/*
+ * Copyright (C) 2022 Parisi Alessandro
+ * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX).
+ *
+ * MaterialFX is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * MaterialFX is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with MaterialFX.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package io.github.palexdev.materialfx.skins;
+
+import io.github.palexdev.materialfx.controls.MFXTitledPane;
+import io.github.palexdev.materialfx.effects.Interpolators;
+import io.github.palexdev.materialfx.enums.HeaderPosition;
+import io.github.palexdev.materialfx.utils.AnimationUtils.KeyFrames;
+import io.github.palexdev.materialfx.utils.AnimationUtils.TimelineBuilder;
+import io.github.palexdev.materialfx.utils.ExecutionUtils;
+import javafx.beans.property.DoubleProperty;
+import javafx.beans.value.ObservableValue;
+import javafx.scene.Node;
+import javafx.scene.control.SkinBase;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.StackPane;
+import javafx.scene.shape.Rectangle;
+
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+/**
+ * Default skin implementation used by {@link MFXTitledPane}.
+ * <p></p>
+ * It consists in three main nodes:
+ * <p> - A {@link BorderPane} which is the top container, automatically manages the layout
+ * for use making things way easier. It is also very convenient for the {@link MFXTitledPane#headerPosProperty()} feature,
+ * see {@link #updatePane()}.
+ * <p> - A {@link StackPane} which contains the {@link MFXTitledPane#contentProperty()}
+ * <p> - A {@link Rectangle} to clip the above {@code StackPane}
+ * <p></p>
+ * Since the header position can change, the whole system needs to change as well. What I mean, is:
+ * if the header is at the TOP or BOTTOM then we want to work with the content pane's prefHeight,
+ * otherwise we want to work with the content pane's prefWidth.
+ * This deeply influences both the clip and the expand/collapse code.
+ * <p></p>
+ * For this reason we use a smart system with three functions:
+ * <p> - A {@link Supplier} which gives us the content pane's prefWidth/prefHeight property (sizeSupplier)
+ * <p> - A {@link Supplier} which gives us the content's prefWidth/prefHeight (targetSizeSupplier)
+ * <p> - A {@link Consumer} which accepts a target prefWidth/prefHeight and sets it on the content pane (setter)
+ */
+public class MFXTitledPaneSkin extends SkinBase<MFXTitledPane> {
+	//================================================================================
+	// Properties
+	//================================================================================
+	private final BorderPane bp;
+	private final StackPane contentPane;
+	private final Rectangle clip;
+
+	private Node header;
+	private Node content;
+	private Supplier<DoubleProperty> sizeSupplier;
+	private Supplier<Double> targetSizeSupplier;
+	private Consumer<Double> setter;
+
+	//================================================================================
+	// Constructors
+	//================================================================================
+	public MFXTitledPaneSkin(MFXTitledPane pane) {
+		super(pane);
+
+		header = pane.getHeaderSupplier().get();
+		content = pane.getContent();
+
+		contentPane = new StackPane();
+		contentPane.getStyleClass().add("content-pane");
+		if (content != null) contentPane.getChildren().add(content);
+		contentPane.setMinSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE);
+
+		clip = new Rectangle();
+		clip();
+
+		bp = new BorderPane();
+		bp.setCenter(contentPane);
+		updateSuppliers();
+		updatePane();
+		getChildren().setAll(bp);
+
+		addListeners();
+	}
+
+	//================================================================================
+	// Methods
+	//================================================================================
+
+	/**
+	 * Adds the following listeners/handlers:
+	 * <p> - A listener to the {@link MFXTitledPane#expandedProperty()} to call {@link #expandCollapse()}
+	 * <p> - A listener to the {@link MFXTitledPane#collapsibleProperty()} to call {@link #expandCollapse()}
+	 * <p> - A listener to the {@link MFXTitledPane#headerSupplierProperty()} to call {@link #updatePane()}
+	 * <p> - A listener to the {@link MFXTitledPane#contentProperty()} to update the content pane and call {@link #expandCollapse()}
+	 * <p> - A listener to the {@link MFXTitledPane#headerPosProperty()} to call {@link #updateSuppliers()}, {@link #clip()}, {@link #updatePane()} and {@link #expandCollapse()}
+	 * <p> - A MouseEvent.MOUSE_PRESSED event handler to acquire focus
+	 * <p></p>
+	 * There's also a call to {@link ExecutionUtils#executeWhen(ObservableValue, BiConsumer, boolean, BiFunction, boolean)},
+	 * which triggers when the content pane is laid out and calls {@link #expandCollapse()} to initialize the control.
+	 */
+	private void addListeners() {
+		MFXTitledPane pane = getSkinnable();
+
+		pane.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> pane.requestFocus());
+
+		pane.expandedProperty().addListener(invalidated -> expandCollapse());
+		pane.collapsibleProperty().addListener(invalidated -> expandCollapse());
+		pane.headerSupplierProperty().addListener((observable, oldValue, newValue) -> {
+			bp.getChildren().remove(header);
+			header = newValue.get();
+			updatePane();
+		});
+		pane.contentProperty().addListener((observable, oldValue, newValue) -> {
+			content = newValue;
+			if (newValue != null) contentPane.getChildren().setAll(newValue);
+			expandCollapse();
+		});
+		pane.headerPosProperty().addListener(invalidated -> {
+			updateSuppliers();
+			clip();
+			updatePane();
+			expandCollapse();
+		});
+
+		ExecutionUtils.executeWhen(
+				contentPane.layoutBoundsProperty(),
+				(oldValue, newValue) -> expandCollapse(),
+				false,
+				(oldValue, newValue) -> newValue.getWidth() != 0 || newValue.getHeight() != 0,
+				true
+		);
+	}
+
+	/**
+	 * Responsible for updating the sizes' functions when {@link MFXTitledPane#headerPosProperty()} changes.
+	 * <p></p>
+	 * Case RIGHT, LEFT:
+	 * <p> - Pref Height set to {@link Region#USE_COMPUTED_SIZE}, sizeSupplier set to {@code contentPane::prefWidthProperty},
+	 * targetSizeSupplier set to {@code content.prefWidth(-1)} (plus insets), setter set to {@code contentPane::setPrefWidth}
+	 * Case TOP, BOTTOM:
+	 * <p> - Pref Width set to {@link Region#USE_COMPUTED_SIZE}, sizeSupplier set to {@code contentPane::prefHeightProperty},
+	 * targetSizeSupplier set to {@code content.prefHeight(-1)} (plus insets), setter set to {@code contentPane::setPrefHeight}
+	 */
+	private void updateSuppliers() {
+		MFXTitledPane pane = getSkinnable();
+		HeaderPosition position = pane.getHeaderPos();
+		if (position == HeaderPosition.RIGHT || position == HeaderPosition.LEFT) {
+			contentPane.setPrefHeight(Region.USE_COMPUTED_SIZE);
+			sizeSupplier = contentPane::prefWidthProperty;
+			targetSizeSupplier = () -> contentPane.snappedLeftInset() + content.prefWidth(-1) + contentPane.snappedRightInset();
+			setter = contentPane::setPrefWidth;
+		} else {
+			contentPane.setPrefWidth(Region.USE_COMPUTED_SIZE);
+			sizeSupplier = contentPane::prefHeightProperty;
+			targetSizeSupplier = () -> contentPane.snappedTopInset() + content.prefHeight(-1) + contentPane.snappedBottomInset();
+			setter = contentPane::setPrefHeight;
+		}
+	}
+
+	/**
+	 * Responsible for updating the header position in the {@link BorderPane}
+	 * according to {@link MFXTitledPane#headerPosProperty()}.
+	 */
+	private void updatePane() {
+		MFXTitledPane pane = getSkinnable();
+		HeaderPosition position = pane.getHeaderPos();
+
+		switch (position) {
+			case RIGHT:
+				bp.setRight(header);
+				break;
+			case BOTTOM:
+				bp.setBottom(header);
+				break;
+			case LEFT:
+				bp.setLeft(header);
+				break;
+			case TOP:
+			default:
+				bp.setTop(header);
+		}
+	}
+
+	/**
+	 * Responsible for expanding/collapsing the content pane according to:
+	 * {@link MFXTitledPane#expandedProperty()}, {@link MFXTitledPane#collapsibleProperty()}, {@link MFXTitledPane#animatedProperty()}.
+	 * <p></p>
+	 * If the content is null the size is set to 0
+	 * <p>
+	 * If it's not expanded and it's not collapsible the size is set to {@code targetSizeSupplier.get()} and returns immediately.
+	 * <p></p>
+	 * Otherwise, the target size is computed according to the expand state, and the size is then set directly or by an animation,
+	 * the opacity is also animated.
+	 */
+	private void expandCollapse() {
+		if (content == null) {
+			setter.accept(0.0);
+			return;
+		}
+
+		MFXTitledPane pane = getSkinnable();
+		boolean isExpanded = pane.isExpanded();
+		if (!isExpanded && !pane.isCollapsible()) {
+			setter.accept(targetSizeSupplier.get());
+			return;
+		}
+
+		DoubleProperty property = sizeSupplier.get();
+		double targetSize = isExpanded ? targetSizeSupplier.get() : 0;
+		double targetOp = isExpanded ? 1.0 : 0.0;
+		if (pane.isAnimated()) {
+			TimelineBuilder.build()
+					.add(KeyFrames.of(pane.getAnimationDuration(), contentPane.opacityProperty(), targetOp, Interpolators.INTERPOLATOR_V1))
+					.add(KeyFrames.of(pane.getAnimationDuration(), property, targetSize, Interpolators.INTERPOLATOR_V1))
+					.getAnimation()
+					.play();
+		} else {
+			contentPane.setOpacity(targetOp);
+			setter.accept(targetSize);
+		}
+	}
+
+	/**
+	 * Responsible for clipping the content pane according to the
+	 * {@link MFXTitledPane#headerPosProperty()}.
+	 */
+	private void clip() {
+		MFXTitledPane pane = getSkinnable();
+		HeaderPosition position = pane.getHeaderPos();
+
+		contentPane.setClip(null);
+		if (position == HeaderPosition.RIGHT || position == HeaderPosition.LEFT) {
+			clip.widthProperty().bind(contentPane.prefWidthProperty());
+			clip.heightProperty().bind(pane.heightProperty());
+		} else {
+			clip.widthProperty().bind(pane.widthProperty());
+			clip.heightProperty().bind(contentPane.prefHeightProperty());
+		}
+		contentPane.setClip(clip);
+	}
+
+	//================================================================================
+	// Overridden Methods
+	//================================================================================
+	@Override
+	protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
+		HeaderPosition position = getSkinnable().getHeaderPos();
+		if (position == HeaderPosition.RIGHT || position == HeaderPosition.LEFT) {
+			return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset);
+		}
+		return super.computeMaxWidth(height, topInset, rightInset, bottomInset, leftInset);
+	}
+
+	@Override
+	protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
+		HeaderPosition position = getSkinnable().getHeaderPos();
+		if (position == HeaderPosition.TOP || position == HeaderPosition.BOTTOM) {
+			return super.computePrefHeight(width, topInset, rightInset, bottomInset, leftInset);
+		}
+		return super.computeMaxHeight(width, topInset, rightInset, bottomInset, leftInset);
+	}
+}

+ 39 - 0
materialfx/src/main/resources/io/github/palexdev/materialfx/css/MFXTitledPane.css

@@ -0,0 +1,39 @@
+@import "Fonts.css";
+@import "MFXColors.css";
+
+.mfx-titled-pane {
+	-fx-background-color: white;
+	-fx-background-radius: 6;
+	-fx-border-color: lightgray;
+	-fx-border-radius: 6;
+}
+
+.mfx-titled-pane:focused {
+	-fx-border-color: -mfx-purple;
+}
+
+.mfx-titled-pane .header-pane {
+	-fx-padding: 5;
+}
+
+.mfx-titled-pane .header-pane .header-label {
+	-fx-font-family: "Open Sans SemiBold";
+	-fx-text-fill: -mfx-text-he;
+}
+
+.mfx-titled-pane:focused .header-pane .mfx-icon-wrapper .mfx-font-icon {
+	-mfx-color: -mfx-purple;
+}
+
+.mfx-titled-pane .header-pane .mfx-icon-wrapper .mfx-ripple-generator {
+	-mfx-ripple-color: derive(-mfx-purple, 145%);
+}
+
+.mfx-titled-pane .content-pane {
+	-fx-padding: 10 5 10 5;
+	-fx-border-color: lightgray transparent transparent transparent;
+}
+
+.mfx-titled-pane:focused .content-pane {
+	-fx-border-color: -mfx-purple transparent transparent transparent;
+}