All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.github.palexdev.mfxcomponents.controls.MaterialSurface Maven / Gradle / Ivy

The newest version!
/*
 * Copyright (C) 2024 Parisi Alessandro - [email protected]
 * 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 .
 */

package io.github.palexdev.mfxcomponents.controls;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;

import io.github.palexdev.mfxcomponents.controls.base.MFXStyleable;
import io.github.palexdev.mfxcomponents.theming.enums.PseudoClasses;
import io.github.palexdev.mfxcore.base.properties.styleable.StyleableBooleanProperty;
import io.github.palexdev.mfxcore.base.properties.styleable.StyleableDoubleProperty;
import io.github.palexdev.mfxcore.base.properties.styleable.StyleableObjectProperty;
import io.github.palexdev.mfxcore.utils.fx.StyleUtils;
import io.github.palexdev.mfxeffects.animations.Animations;
import io.github.palexdev.mfxeffects.animations.motion.M3Motion;
import io.github.palexdev.mfxeffects.enums.ElevationLevel;
import io.github.palexdev.mfxeffects.ripple.MFXRippleGenerator;
import javafx.animation.Animation;
import javafx.beans.InvalidationListener;
import javafx.css.CssMetaData;
import javafx.css.PseudoClass;
import javafx.css.Styleable;
import javafx.css.StyleablePropertyFactory;
import javafx.scene.Node;
import javafx.scene.effect.DropShadow;
import javafx.scene.effect.Effect;
import javafx.scene.layout.Background;
import javafx.scene.layout.Region;

/**
 * Material Design 3 components are stratified. Different layers have different purposes. Among the various layers, two
 * are quite important: the state layer and the focus ring layer.
 * 

*

- The {@code state layer} is a region on which a color, which is in contrast with the main layer color, is applied * at specific levels of opacity according to the various interaction states. Hover -> 8%; Press and Focus -> 12%; * Dragged -> 16%. Additionally, on this layer ripple effect can be generated to further emphasize press/click interactions. *

- The {@code focus ring layer} is an effect applied only when the component is being focused by a keyboard * event, so {@link Node#focusVisibleProperty()} is true. A border is applied around the component. *

* There are also components that may also need a shadow effect to further separate themselves from other UI elements, * making them appear 3D. This is implemented with some caveats through the {@link #elevationProperty()}. *

* The goal of this region is to replicate such effects while still keeping the nodes count as low as possible. * Like the name suggests, this is intended to be used like an extra background on top of another region. For this reason, * it needs the instance of the region, called 'owner', on which this will act as an overlay. *

* The overlay is carried by a separate region that can be selected in CSS with the ".bg" style class. Despite having * another node just for the overlay, it is still more performant than animating the background color because * using {@link Region#setBackground(Background)} is much more expensive than just animating a node's opacity. *

* There are pros and cons deriving from this: *

Pros: *

- Implementing the {@code state layer} is much easier, as it's enough to specify the surface background color, * which will then just change in opacity as needed *

- The transition between the different states is a short animation, which makes component look prettier *

Cons: *

- I expect a slight impact on performance since we use two extra nodes now. And also because of the animations, however * they can be disabled globally via a public static flag, or per component via {@link #animatedProperty()} * (more convenient to set it through CSS since most of the time the MaterialSurface is part of a skin) *

- I expect another very slight impact on performance because to change the opacity according to the current interaction state, * a listener is added on the owner's {@link Node#getPseudoClassStates()}. Being a Set, the lookup will still be * pretty fast though. And also, before resorting to the lookup, some checks are first performed directly on the node's * properties. *

* This is intended to be used in skins of components that need to visually distinguish between the various interaction * states. When the skin is being disposed, {@link #dispose()} should be called too. Also, always make sure that this * is and remains the first child of the component to avoid this from covering the other children. *

* A recent update reworked the states' system. When a change is detected on the node and the background opacity should * change, the target opacity is determined by {@link #getTargetOpacity()}. To make the surface more flexible, and allow * for custom states (like "selected" which is technically out of specs), a "priority" list is used, {@link #getStates()}. * One can easily add/remove/replace states to adapt the surface to its own needs. */ // TODO dragged state is not implemented yet public class MaterialSurface extends Region implements MFXStyleable { //================================================================================ // Static Properties //================================================================================ public static boolean GLOBAL_ANIMATED = true; //================================================================================ // Properties //================================================================================ private Region owner; private MFXRippleGenerator rg; private InvalidationListener stateListener; private final List states = new ArrayList<>(State.DEFAULTS); protected final Region bg; protected Animation animation; protected double lastOpacity; //================================================================================ // Constructors //================================================================================ public MaterialSurface(Region owner) { this.owner = owner; bg = new Region(); bg.getStyleClass().add("bg"); bg.setOpacity(0.0); rg = new MFXRippleGenerator(bg); initialize(); } //================================================================================ // Methods //================================================================================ private void initialize() { setManaged(false); setMaxSize(USE_PREF_SIZE, USE_PREF_SIZE); getStyleClass().setAll(defaultStyleClasses()); getChildren().addAll(bg, rg); stateListener = i -> updateBackground(); owner.getPseudoClassStates().addListener(stateListener); } /** * Fluent way to set up the surface's {@link MFXRippleGenerator}. */ public MaterialSurface initRipple(Consumer config) { config.accept(rg); return this; } /** * This is the core method responsible for setting the surface's background opacity. *

* The opacity is determined by the {@link #getTargetOpacity()} method. *

* The opacity is set immediately or through an animation started by {@link #animate(double)}. */ public void updateBackground() { double target = getTargetOpacity(); if (lastOpacity == target) return; if (GLOBAL_ANIMATED && isAnimated()) { animate(target); } else { bg.setOpacity(target); } lastOpacity = target; } /** * Stops any previous animation, then creates a new one and transitions the background opacity to the target value. * * @param opacity the opacity specified by the new state */ protected void animate(double opacity) { if (Animations.isPlaying(animation)) animation.stop(); animation = Animations.TimelineBuilder.build() .add(Animations.KeyFrames.of(M3Motion.SHORT4, bg.opacityProperty(), opacity)) .getAnimation(); animation.play(); } /** * Iterates over the interaction states in {@link #getStates()}. The first state whose {@link Predicate} returns * {@code true} determines the target opacity, given by its {@link State#getOpacityFunction()}. *

* In case no state results "active", then uses {@link State#FALLBACK}. */ protected double getTargetOpacity() { for (State state : states) { if (state.isActive(owner)) return state.opacity(this); } return State.FALLBACK.opacity(this); } /** * Removes any added listener, disposes the {@link MFXRippleGenerator}. */ public void dispose() { getChildren().clear(); rg.dispose(); rg = null; states.clear(); owner.getPseudoClassStates().removeListener(stateListener); stateListener = null; owner = null; } //================================================================================ // Overridden Methods //================================================================================ @Override public List defaultStyleClasses() { return List.of("surface"); } @Override protected void layoutChildren() { double w = getWidth(); double h = getHeight(); if (rg != null) rg.resizeRelocate(0, 0, w, h); bg.resizeRelocate(0, 0, w, h); } //================================================================================ // Styleable Properties //================================================================================ private final StyleableBooleanProperty animated = new StyleableBooleanProperty( StyleableProperties.ANIMATED, this, "animated", true ); private final StyleableDoubleProperty disabledOpacity = new StyleableDoubleProperty( StyleableProperties.DISABLED_OPACITY, this, "disabledOpacity", 0.0 ) { @Override public void set(double v) { double oldValue = get(); super.set(v); if (!Objects.equals(oldValue, v)) updateBackground(); } }; private final StyleableDoubleProperty pressedOpacity = new StyleableDoubleProperty( StyleableProperties.PRESSED_OPACITY, this, "pressedOpacity", 0.0 ) { @Override public void set(double v) { double oldValue = get(); super.set(v); if (!Objects.equals(oldValue, v)) updateBackground(); } }; private final StyleableDoubleProperty focusedOpacity = new StyleableDoubleProperty( StyleableProperties.FOCUSED_OPACITY, this, "focusedOpacity", 0.0 ) { @Override public void set(double v) { double oldValue = get(); super.set(v); if (!Objects.equals(oldValue, v)) updateBackground(); } }; private final StyleableDoubleProperty hoverOpacity = new StyleableDoubleProperty( StyleableProperties.HOVER_OPACITY, this, "hoverOpacity", 0.0 ) { @Override public void set(double v) { double oldValue = get(); super.set(v); if (!Objects.equals(oldValue, v)) updateBackground(); } }; private final StyleableObjectProperty elevation = new StyleableObjectProperty<>( StyleableProperties.ELEVATION, this, "elevation", ElevationLevel.LEVEL0 ) { @Override public void set(ElevationLevel newValue) { if (newValue == ElevationLevel.LEVEL0) { owner.setEffect(null); super.set(newValue); return; } Effect effect = owner.getEffect(); if (effect == null) { owner.setEffect(newValue.toShadow()); super.set(newValue); return; } if (!(effect instanceof DropShadow)) { return; } ElevationLevel oldValue = get(); if (oldValue != null && newValue != null && oldValue != newValue) oldValue.animateTo((DropShadow) effect, newValue); super.set(newValue); } }; public boolean isAnimated() { return animated.get(); } /** * Specifies whether to animate the background's opacity when the interaction state changes, * see {@link #updateBackground()} and {@link #animate(double)}. *

* Can be set in CSS via the property: '-mfx-animated'. */ public StyleableBooleanProperty animatedProperty() { return animated; } public void setAnimated(boolean animated) { this.animated.set(animated); } public double getDisabledOpacity() { return disabledOpacity.get(); } /** * Specifies the surface's background opacity when the owner is disabled. *

* Can be set in CSS via the property: '-mfx-disabled-opacity'. */ public StyleableDoubleProperty disabledOpacityProperty() { return disabledOpacity; } public void setDisabledOpacity(double disabledOpacity) { this.disabledOpacity.set(disabledOpacity); } public double getPressedOpacity() { return pressedOpacity.get(); } /** * Specifies the surface's background opacity when the owner is pressed. *

* Can be set in CSS via the property: '-mfx-pressed-opacity'. */ public StyleableDoubleProperty pressedOpacityProperty() { return pressedOpacity; } public void setPressedOpacity(double pressedOpacity) { this.pressedOpacity.set(pressedOpacity); } public double getFocusedOpacity() { return focusedOpacity.get(); } /** * Specifies the surface's background opacity when the owner is focused. *

* Can be set in CSS via the property: '-mfx-focused-opacity'. */ public StyleableDoubleProperty focusedOpacityProperty() { return focusedOpacity; } public void setFocusedOpacity(double focusedOpacity) { this.focusedOpacity.set(focusedOpacity); } public double getHoverOpacity() { return hoverOpacity.get(); } /** * Specifies the surface's background opacity when the owner is hovered. *

* Can be set in CSS via the property: '-mfx-hover-opacity'. */ public StyleableDoubleProperty hoverOpacityProperty() { return hoverOpacity; } public void setHoverOpacity(double hoverOpacity) { this.hoverOpacity.set(hoverOpacity); } public ElevationLevel getElevation() { return elevation.get(); } /** * Specifies the elevation level of the owner, not the surface! Each level corresponds to a different {@link DropShadow} * effect. {@link ElevationLevel#LEVEL0} corresponds to {@code null}. *

* Unfortunately since the crap that is JavaFX, handles the effects in strange ways, the shadow cannot be applied to the * surface for various reasons. So, the effect will be applied on the owner instead. *

* Can be set in CSS via the property: '-mfx-elevation'. */ public StyleableObjectProperty elevationProperty() { return elevation; } public void setElevation(ElevationLevel elevation) { this.elevation.set(elevation); } //================================================================================ // CssMetaData //================================================================================ private static class StyleableProperties { private static final StyleablePropertyFactory FACTORY = new StyleablePropertyFactory<>(Region.getClassCssMetaData()); private static final List> cssMetaDataList; private static final CssMetaData ANIMATED = FACTORY.createBooleanCssMetaData( "-mfx-animated", MaterialSurface::animatedProperty, true ); private static final CssMetaData DISABLED_OPACITY = FACTORY.createSizeCssMetaData( "-mfx-disabled-opacity", MaterialSurface::disabledOpacityProperty, 0.0 ); private static final CssMetaData PRESSED_OPACITY = FACTORY.createSizeCssMetaData( "-mfx-press-opacity", MaterialSurface::pressedOpacityProperty, 0.0 ); private static final CssMetaData FOCUSED_OPACITY = FACTORY.createSizeCssMetaData( "-mfx-focus-opacity", MaterialSurface::focusedOpacityProperty, 0.0 ); private static final CssMetaData HOVER_OPACITY = FACTORY.createSizeCssMetaData( "-mfx-hover-opacity", MaterialSurface::hoverOpacityProperty, 0.0 ); private static final CssMetaData ELEVATION = FACTORY.createEnumCssMetaData( ElevationLevel.class, "-mfx-elevation", MaterialSurface::elevationProperty, ElevationLevel.LEVEL0 ); static { cssMetaDataList = StyleUtils.cssMetaDataList( Region.getClassCssMetaData(), ANIMATED, DISABLED_OPACITY, PRESSED_OPACITY, FOCUSED_OPACITY, HOVER_OPACITY, ELEVATION ); } } public static List> getClassCssMetaData() { return StyleableProperties.cssMetaDataList; } @Override public List> getCssMetaData() { return getClassCssMetaData(); } //================================================================================ // Getters //================================================================================ public Region getOwner() { return owner; } public MFXRippleGenerator getRippleGenerator() { return rg; } public List getStates() { return states; } //================================================================================ // Inner Classes //================================================================================ /** * This class represents interaction states with a node called 'owner'. It's a simple wrapper for two values: *

1) A {@link Predicate} used to check whether the state is active on the owner *

2) A {@link Function} that determines the state's opacity *

* There are 5 default states: *

1) {@link #FALLBACK} *

2) {@link #DISABLED} *

3) {@link #PRESSED} *

4) {@link #FOCUSED} *

5) {@link #HOVER} *

* Their opacity, depend on the properties defined in {@link MaterialSurface} (hence why a function and not a supplier). */ public static class State { //================================================================================ // Defaults //================================================================================ /** * Special state whose predicate is always {@code true}. Used when none of the other states is active. Opacity is 0.0. */ public static final State FALLBACK = State.of(n -> true, s -> 0.0); /** * This state is activated when the node is disabled or the {@link PseudoClass} ':disabled' is active. * The opacity is retrieved from {@link MaterialSurface#disabledOpacityProperty()}. */ public static final State DISABLED = State.of( n -> n.isDisabled() || isPseudoActive(n, PseudoClasses.DISABLED), MaterialSurface::getDisabledOpacity ); /** * This state is activated when the node is pressed or the {@link PseudoClass} ':pressed' is active. * The opacity is retrieved from {@link MaterialSurface#pressedOpacityProperty()}. */ public static final State PRESSED = State.of( n -> n.isPressed() || isPseudoActive(n, PseudoClasses.PRESSED), MaterialSurface::getPressedOpacity ); /** * This state is activated when the node or any of its children are focused. Or the {@link PseudoClass}es * ':focused' or ':focus-within' are active. * The opacity is retrieved from {@link MaterialSurface#focusedOpacityProperty()}. */ public static final State FOCUSED = State.of( n -> n.isFocused() || n.isFocusWithin() || isPseudoActive(n, PseudoClasses.FOCUSED, PseudoClasses.FOCUS_WITHIN), MaterialSurface::getFocusedOpacity ); /** * This state is activated when the node is hovered or the {@link PseudoClass} ':hover: is active. * The opacity is retrieved from {@link MaterialSurface#hoverOpacityProperty()}. */ public static final State HOVER = State.of( n -> n.isHover() || isPseudoActive(n, PseudoClasses.HOVER), MaterialSurface::getHoverOpacity ); /** * This list contains all the default states, common to pretty much any surface/component. The order by priority * is: disabled, pressed, focused, hover. */ public static final List DEFAULTS = List.of(DISABLED, PRESSED, FOCUSED, HOVER); //================================================================================ // Properties //================================================================================ private final Predicate condition; private final Function opacityFunction; public State(Predicate condition, Function opacityFunction) { this.condition = condition; this.opacityFunction = opacityFunction; } public static State of(Predicate condition, Function opacityFunction) { return new State(condition, opacityFunction); } //================================================================================ // Methods //================================================================================ /** * Shortcut method for `getCondition().test(node)`. */ public boolean isActive(Node node) { return condition.test(node); } /** * Shortcut method for `getOpacityFunction().apply(surface)`. */ public double opacity(MaterialSurface surface) { return opacityFunction.apply(surface); } //================================================================================ // Static Methods //================================================================================ /** * Convenience method to check whether at least one of the given pseudo classes are active on the owner node. */ public static boolean isPseudoActive(Node owner, PseudoClass... classes) { for (PseudoClass pClass : classes) { if (PseudoClasses.isActiveOn(owner, pClass)) return true; } return false; } /** * Convenience method to check whether at least one of the given pseudo classes are active on the owner node. */ public static boolean isPseudoActive(Node owner, PseudoClasses... classes) { for (PseudoClasses pClass : classes) { if (pClass.isActiveOn(owner)) return true; } return false; } //================================================================================ // Getters //================================================================================ /** * @return the {@link Predicate} used to check whether the state is active on a given node */ public Predicate getCondition() { return condition; } /** * @return the {@link Function} which determines the state's opacity */ public Function getOpacityFunction() { return opacityFunction; } } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy